745 lines
21 KiB
Dart
745 lines
21 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.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/screens/note_editor_screen.dart';
|
|
import 'package:notas/theme/app_palette.dart';
|
|
import 'package:notas/widgets/note_card.dart';
|
|
import 'package:notas/widgets/sync_status.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> {
|
|
static const double _desktopBreakpoint = 900;
|
|
|
|
final TextEditingController _searchController = TextEditingController();
|
|
final GlobalKey _filterButtonKey = GlobalKey();
|
|
|
|
List<Note> _notes = <Note>[];
|
|
List<Category> _categories = <Category>[];
|
|
bool _isLoading = true;
|
|
String _searchQuery = '';
|
|
String? _selectedCategoryId;
|
|
String? _selectedNoteId;
|
|
DateTime? _lastSyncAt;
|
|
|
|
AppPalette _paletteOf(BuildContext context) {
|
|
return Theme.of(context).extension<AppPalette>() ??
|
|
AppPalette.fromBrightness(Theme.of(context).brightness);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadData();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant HomeScreen oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.refreshToken != widget.refreshToken) {
|
|
_loadData(keepSelection: true);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadData({bool keepSelection = false}) async {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
}
|
|
|
|
try {
|
|
final List<Note> notes = await widget.repository.loadNotes();
|
|
final List<Category> categories = await widget.repository.loadCategories();
|
|
final DateTime? lastSyncAt = await widget.repository.getLastSyncAt();
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_notes = notes;
|
|
_categories = categories;
|
|
_lastSyncAt = lastSyncAt;
|
|
_isLoading = false;
|
|
|
|
if (!keepSelection) {
|
|
_selectedNoteId = null;
|
|
} else if (_selectedNoteId != null &&
|
|
!_notes.any((Note note) => note.id == _selectedNoteId)) {
|
|
_selectedNoteId = null;
|
|
}
|
|
});
|
|
} catch (error, stackTrace) {
|
|
debugPrint('Failed to load home data: $error\n$stackTrace');
|
|
if (widget.onVaultInvalid != null) {
|
|
await widget.onVaultInvalid!();
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _reloadNotes({bool keepSelection = true}) async {
|
|
try {
|
|
final List<Note> notes = await widget.repository.loadNotes();
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_notes = notes;
|
|
if (!keepSelection ||
|
|
(_selectedNoteId != null &&
|
|
!_notes.any((Note note) => note.id == _selectedNoteId))) {
|
|
_selectedNoteId = null;
|
|
}
|
|
});
|
|
} catch (error) {
|
|
debugPrint('Failed to reload notes: $error');
|
|
}
|
|
}
|
|
|
|
List<Note> _visibleNotes() {
|
|
Iterable<Note> notes = _notes;
|
|
|
|
if (_selectedCategoryId != null) {
|
|
notes = notes.where((Note note) => note.categoryId == _selectedCategoryId);
|
|
}
|
|
|
|
if (_searchQuery.isEmpty) {
|
|
return notes.toList();
|
|
}
|
|
|
|
final String query = _searchQuery.toLowerCase();
|
|
return notes
|
|
.where(
|
|
(Note note) =>
|
|
note.title.toLowerCase().contains(query) ||
|
|
noteBodyToPlainText(note.body).toLowerCase().contains(query),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
Note? _selectedNote() {
|
|
final String? selectedId = _selectedNoteId;
|
|
if (selectedId == null) {
|
|
return null;
|
|
}
|
|
|
|
for (final Note note in _notes) {
|
|
if (note.id == selectedId) {
|
|
return note;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
String _formatLastSyncAt() {
|
|
final DateTime? timestamp = _lastSyncAt;
|
|
if (timestamp == null) {
|
|
return 'Última sincronización: nunca';
|
|
}
|
|
|
|
return 'Última sincronización: ${DateFormat('dd/MM/yyyy HH:mm').format(timestamp)}';
|
|
}
|
|
|
|
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,
|
|
);
|
|
|
|
return RelativeRect.fromRect(
|
|
Rect.fromLTRB(
|
|
topLeft.dx,
|
|
topLeft.dy,
|
|
bottomRight.dx,
|
|
bottomRight.dy,
|
|
),
|
|
Offset.zero & overlay.size,
|
|
);
|
|
}
|
|
|
|
Future<T?> _showAnchoredCategoryMenu<T>({
|
|
required BuildContext anchorContext,
|
|
required List<PopupMenuEntry<T>> items,
|
|
}) {
|
|
return showMenu<T>(
|
|
context: anchorContext,
|
|
position: _menuRectFromContext(anchorContext),
|
|
items: items,
|
|
elevation: 10,
|
|
color: _paletteOf(anchorContext).surfaceElevated,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
|
);
|
|
}
|
|
|
|
Future<void> _openCategoryFilter(BuildContext anchorContext) async {
|
|
final String? selectedCategoryId = await _showAnchoredCategoryMenu<String?>(
|
|
anchorContext: anchorContext,
|
|
items: <PopupMenuEntry<String?>>[
|
|
const PopupMenuItem<String?>(
|
|
value: '',
|
|
child: Text('Todas las categorías'),
|
|
),
|
|
const PopupMenuDivider(),
|
|
for (final Category category in _categories)
|
|
PopupMenuItem<String?>(
|
|
value: category.id,
|
|
child: Text(category.name),
|
|
),
|
|
],
|
|
);
|
|
|
|
if (!mounted || selectedCategoryId == null) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_selectedCategoryId = selectedCategoryId.isEmpty
|
|
? null
|
|
: selectedCategoryId;
|
|
if (_selectedNoteId != null &&
|
|
!_visibleNotes().any((Note note) => note.id == _selectedNoteId)) {
|
|
_selectedNoteId = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _changeNoteCategory(BuildContext anchorContext, Note note) async {
|
|
final Category? selected = await _showAnchoredCategoryMenu<Category?>(
|
|
anchorContext: anchorContext,
|
|
items: <PopupMenuEntry<Category?>>[
|
|
const PopupMenuItem<Category?>(
|
|
value: null,
|
|
child: Text('Sin categoría'),
|
|
),
|
|
const PopupMenuDivider(),
|
|
for (final Category category in _categories)
|
|
PopupMenuItem<Category?>(
|
|
value: category,
|
|
child: Text(category.name),
|
|
),
|
|
],
|
|
);
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
final String? categoryId = selected?.id;
|
|
if (categoryId == note.categoryId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final Note updated = await widget.repository.updateNote(
|
|
note.copyWith(
|
|
categoryId: categoryId,
|
|
updatedAt: DateTime.now(),
|
|
isDirty: true,
|
|
),
|
|
);
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_notes = <Note>[
|
|
for (final Note item in _notes)
|
|
if (item.id == updated.id) updated else item,
|
|
];
|
|
});
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('No se pudo cambiar la categoría: $error')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteNote(Note note) async {
|
|
try {
|
|
await widget.repository.deleteNote(note);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_notes = _notes.where((Note item) => item.id != note.id).toList();
|
|
if (_selectedNoteId == note.id) {
|
|
_selectedNoteId = null;
|
|
}
|
|
});
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('No se pudo eliminar la nota: $error')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _createNote({required bool openEditor}) async {
|
|
final DateTime now = DateTime.now();
|
|
final Note draft = Note(
|
|
title: 'Sin título',
|
|
body: '',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
position: 0,
|
|
categoryId: _selectedCategoryId,
|
|
);
|
|
|
|
try {
|
|
if (_searchQuery.isNotEmpty) {
|
|
_searchController.clear();
|
|
_searchQuery = '';
|
|
}
|
|
|
|
final Note created = await widget.repository.createNote(draft);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
await _reloadNotes(keepSelection: false);
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_selectedNoteId = created.id;
|
|
});
|
|
|
|
if (openEditor) {
|
|
await _openEditor(created, embedded: false);
|
|
}
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('No se pudo crear la nota: $error')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _openExistingNote(Note note, {required bool embedded}) async {
|
|
setState(() {
|
|
_selectedNoteId = note.id;
|
|
});
|
|
|
|
if (embedded) {
|
|
return;
|
|
}
|
|
|
|
await _openEditor(note, embedded: false);
|
|
}
|
|
|
|
Future<void> _openEditor(Note note, {required bool embedded}) async {
|
|
final Widget editor = NoteEditorScreen(
|
|
key: ValueKey<String>(note.id),
|
|
repository: widget.repository,
|
|
note: note,
|
|
categories: _categories,
|
|
embedded: embedded,
|
|
onSaved: (Note saved) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_notes = <Note>[
|
|
for (final Note item in _notes)
|
|
if (item.id == saved.id) saved else item,
|
|
];
|
|
});
|
|
},
|
|
);
|
|
|
|
if (embedded) {
|
|
return;
|
|
}
|
|
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute<void>(builder: (_) => editor),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleNoteTap(Note note, bool isDesktop) async {
|
|
if (isDesktop) {
|
|
await _openExistingNote(note, embedded: true);
|
|
return;
|
|
}
|
|
|
|
await _openEditor(note, embedded: false);
|
|
}
|
|
|
|
Future<void> _handleReorder(int oldIndex, int newIndex) async {
|
|
final List<Note> visibleNotes = _visibleNotes();
|
|
if (oldIndex < 0 || oldIndex >= visibleNotes.length) {
|
|
return;
|
|
}
|
|
|
|
final Note movedNote = visibleNotes[oldIndex];
|
|
final List<Note> remainingVisible = <Note>[...visibleNotes]..removeAt(oldIndex);
|
|
final int clampedNewIndex = newIndex.clamp(0, remainingVisible.length);
|
|
|
|
int targetFullIndex;
|
|
if (remainingVisible.isEmpty) {
|
|
targetFullIndex = 0;
|
|
} else if (clampedNewIndex == 0) {
|
|
targetFullIndex = 0;
|
|
} else if (clampedNewIndex >= remainingVisible.length) {
|
|
targetFullIndex = _notes.length - 1;
|
|
} else {
|
|
final Note afterNote = remainingVisible[clampedNewIndex];
|
|
targetFullIndex = _notes.indexWhere((Note note) => note.id == afterNote.id);
|
|
if (targetFullIndex < 0) {
|
|
targetFullIndex = 0;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await widget.repository.moveNote(movedNote, targetFullIndex);
|
|
await _reloadNotes(keepSelection: true);
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('No se pudo reordenar la nota: $error')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildHeader(BuildContext context) {
|
|
final AppPalette palette = _paletteOf(context);
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: palette.transparent,
|
|
border: Border(bottom: BorderSide(color: palette.border, width: 0.5)),
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
child: Row(
|
|
children: [
|
|
Builder(
|
|
builder: (BuildContext buttonContext) {
|
|
return IconButton(
|
|
key: _filterButtonKey,
|
|
onPressed: () => _openCategoryFilter(buttonContext),
|
|
icon: Icon(
|
|
Icons.filter_alt_outlined,
|
|
color: palette.textSecondary,
|
|
),
|
|
tooltip: 'Filtrar por categorías',
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 640),
|
|
child: TextField(
|
|
controller: _searchController,
|
|
onChanged: (String value) {
|
|
setState(() {
|
|
_searchQuery = value.trim();
|
|
});
|
|
},
|
|
decoration: InputDecoration(
|
|
hintText: 'Buscar notas...',
|
|
hintStyle: TextStyle(color: palette.textSecondary),
|
|
prefixIcon: Icon(Icons.search, color: palette.textSecondary),
|
|
suffixIcon: _searchQuery.isEmpty
|
|
? null
|
|
: IconButton(
|
|
onPressed: () {
|
|
_searchController.clear();
|
|
setState(() {
|
|
_searchQuery = '';
|
|
});
|
|
},
|
|
icon: Icon(
|
|
Icons.clear,
|
|
color: palette.textSecondary,
|
|
),
|
|
),
|
|
filled: true,
|
|
fillColor: palette.fill,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
borderSide: BorderSide(color: palette.border),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
borderSide: BorderSide(color: palette.border),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
borderSide: BorderSide(color: palette.accent),
|
|
),
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 12,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
onPressed: widget.onOpenSettings,
|
|
icon: Icon(Icons.settings_outlined, color: palette.textSecondary),
|
|
tooltip: 'Ajustes',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildNoteList(BuildContext context, {required bool isDesktop}) {
|
|
final AppPalette palette = _paletteOf(context);
|
|
final List<Note> visibleNotes = _visibleNotes();
|
|
|
|
if (_isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (visibleNotes.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
'No hay notas para mostrar',
|
|
style: TextStyle(color: palette.textSecondary),
|
|
),
|
|
);
|
|
}
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: () async {
|
|
await widget.onRequestSync();
|
|
await _loadData(keepSelection: true);
|
|
},
|
|
child: ReorderableListView.builder(
|
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 20),
|
|
buildDefaultDragHandles: false,
|
|
itemCount: visibleNotes.length,
|
|
onReorder: _handleReorder,
|
|
footer: Padding(
|
|
padding: const EdgeInsets.only(top: 8, bottom: 88),
|
|
child: Text(
|
|
_formatLastSyncAt(),
|
|
style: TextStyle(
|
|
color: palette.textSecondary,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
itemBuilder: (BuildContext context, int index) {
|
|
final Note note = visibleNotes[index];
|
|
return Padding(
|
|
key: ValueKey<String>(note.id),
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: ReorderableDelayedDragStartListener(
|
|
index: index,
|
|
child: NoteCard(
|
|
note: note,
|
|
isSelected: note.id == _selectedNoteId,
|
|
onTap: () => _handleNoteTap(note, isDesktop),
|
|
onDelete: () => _deleteNote(note),
|
|
onChangeCategory: (BuildContext buttonContext) =>
|
|
_changeNoteCategory(buttonContext, note),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyDetailPane(BuildContext context) {
|
|
final AppPalette palette = _paletteOf(context);
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: palette.surfaceElevated,
|
|
border: Border.all(color: palette.border),
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'Selecciona una nota o crea una nueva para empezar',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: palette.textSecondary,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDesktopLayout(BuildContext context, BoxConstraints constraints) {
|
|
final AppPalette palette = _paletteOf(context);
|
|
final double leftWidth = (constraints.maxWidth * 0.34).clamp(320, 440);
|
|
final Note? selectedNote = _selectedNote();
|
|
final bool selectedIsVisible = selectedNote != null &&
|
|
_visibleNotes().any((Note note) => note.id == selectedNote.id);
|
|
|
|
return Row(
|
|
children: [
|
|
SizedBox(
|
|
width: leftWidth,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: palette.transparent,
|
|
border: Border(right: BorderSide(color: palette.border)),
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
Column(
|
|
children: [
|
|
_buildHeader(context),
|
|
Expanded(child: _buildNoteList(context, isDesktop: true)),
|
|
],
|
|
),
|
|
Positioned(
|
|
right: 20,
|
|
bottom: 20,
|
|
child: FloatingActionButton(
|
|
onPressed: () => _createNote(openEditor: false),
|
|
child: const Icon(Icons.add),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 220),
|
|
child: selectedIsVisible
|
|
? NoteEditorScreen(
|
|
key: ValueKey<String>(selectedNote.id),
|
|
repository: widget.repository,
|
|
note: selectedNote,
|
|
categories: _categories,
|
|
embedded: true,
|
|
onSaved: (Note saved) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_notes = <Note>[
|
|
for (final Note item in _notes)
|
|
if (item.id == saved.id) saved else item,
|
|
];
|
|
});
|
|
},
|
|
)
|
|
: _buildEmptyDetailPane(context),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMobileLayout(BuildContext context) {
|
|
return Stack(
|
|
children: [
|
|
Column(
|
|
children: [
|
|
_buildHeader(context),
|
|
Expanded(child: _buildNoteList(context, isDesktop: false)),
|
|
],
|
|
),
|
|
Positioned(
|
|
right: 20,
|
|
bottom: 20,
|
|
child: FloatingActionButton(
|
|
onPressed: () => _createNote(openEditor: true),
|
|
child: const Icon(Icons.add),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final AppPalette palette = _paletteOf(context);
|
|
|
|
return Scaffold(
|
|
body: Container(
|
|
decoration: BoxDecoration(gradient: palette.backdropGradient),
|
|
child: LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) {
|
|
final bool isDesktop = constraints.maxWidth >= _desktopBreakpoint;
|
|
return isDesktop
|
|
? _buildDesktopLayout(context, constraints)
|
|
: _buildMobileLayout(context);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|