1231 lines
38 KiB
Dart
1231 lines
38 KiB
Dart
import 'dart:async';
|
|
|
|
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/category_style.dart';
|
|
import 'package:notas/widgets/note_card.dart';
|
|
import 'package:notas/widgets/sync_status.dart';
|
|
|
|
class _CategoryDraft {
|
|
const _CategoryDraft({
|
|
required this.name,
|
|
required this.colorValue,
|
|
required this.iconCodePoint,
|
|
});
|
|
|
|
final String name;
|
|
final int colorValue;
|
|
final int iconCodePoint;
|
|
}
|
|
|
|
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;
|
|
static const String _createCategoryMenuValue = '__create_category__';
|
|
|
|
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;
|
|
}
|
|
|
|
Category? _categoryById(String? categoryId) {
|
|
if (categoryId == null) {
|
|
return null;
|
|
}
|
|
|
|
for (final Category category in _categories) {
|
|
if (category.id == categoryId) {
|
|
return category;
|
|
}
|
|
}
|
|
|
|
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)}';
|
|
}
|
|
|
|
Future<_CategoryDraft?> _promptCategoryDetails({
|
|
required String title,
|
|
required String confirmLabel,
|
|
String? initialName,
|
|
int? initialColorValue,
|
|
int? initialIconCodePoint,
|
|
}) async {
|
|
final TextEditingController controller = TextEditingController(
|
|
text: initialName ?? '',
|
|
);
|
|
final List<Color> colorOptions = CategoryStyle.colorsOf(context);
|
|
final List<IconData> iconOptions = CategoryStyle.icons;
|
|
final int fallbackColorValue =
|
|
initialColorValue ?? colorOptions.first.toARGB32();
|
|
final int fallbackIconCodePoint =
|
|
initialIconCodePoint ?? iconOptions.first.codePoint;
|
|
|
|
try {
|
|
final _CategoryDraft? result = await showDialog<_CategoryDraft>(
|
|
context: context,
|
|
builder: (BuildContext dialogContext) {
|
|
final AppPalette palette = _paletteOf(dialogContext);
|
|
final List<Color> dialogColorOptions = CategoryStyle.colorsOf(
|
|
dialogContext,
|
|
);
|
|
final List<IconData> dialogIconOptions = CategoryStyle.icons;
|
|
|
|
int selectedColorValue = fallbackColorValue;
|
|
int selectedIconCodePoint = fallbackIconCodePoint;
|
|
|
|
return StatefulBuilder(
|
|
builder: (BuildContext context, StateSetter setDialogState) {
|
|
final Color previewColor = Color(selectedColorValue);
|
|
|
|
return AlertDialog(
|
|
backgroundColor: palette.surfaceElevated,
|
|
title: Text(title),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
textInputAction: TextInputAction.done,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Nombre de la categoría',
|
|
),
|
|
onSubmitted: (String value) {
|
|
final String name = value.trim();
|
|
if (name.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
Navigator.of(dialogContext).pop(
|
|
_CategoryDraft(
|
|
name: name,
|
|
colorValue: selectedColorValue,
|
|
iconCodePoint: selectedIconCodePoint,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
'Color',
|
|
style: TextStyle(
|
|
color: palette.textSecondary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Wrap(
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: [
|
|
for (final Color color in dialogColorOptions)
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(999),
|
|
onTap: () {
|
|
setDialogState(() {
|
|
selectedColorValue = color.toARGB32();
|
|
});
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 160),
|
|
width: 42,
|
|
height: 42,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: color,
|
|
border: Border.all(
|
|
color:
|
|
selectedColorValue == color.toARGB32()
|
|
? palette.textPrimary
|
|
: palette.border,
|
|
width:
|
|
selectedColorValue == color.toARGB32()
|
|
? 2.5
|
|
: 1,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: color.withOpacity(0.18),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: selectedColorValue == color.toARGB32()
|
|
? Icon(
|
|
Icons.check,
|
|
size: 18,
|
|
color: color.computeLuminance() > 0.5
|
|
? Colors.black
|
|
: Colors.white,
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
'Icono',
|
|
style: TextStyle(
|
|
color: palette.textSecondary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
for (final IconData icon in dialogIconOptions)
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(14),
|
|
onTap: () {
|
|
setDialogState(() {
|
|
selectedIconCodePoint = icon.codePoint;
|
|
});
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 160),
|
|
width: 50,
|
|
height: 50,
|
|
decoration: BoxDecoration(
|
|
color: selectedIconCodePoint == icon.codePoint
|
|
? previewColor.withOpacity(0.14)
|
|
: palette.fill,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(
|
|
color:
|
|
selectedIconCodePoint == icon.codePoint
|
|
? previewColor
|
|
: palette.border,
|
|
width:
|
|
selectedIconCodePoint == icon.codePoint
|
|
? 2
|
|
: 1,
|
|
),
|
|
),
|
|
child: Icon(
|
|
icon,
|
|
color: selectedIconCodePoint == icon.codePoint
|
|
? previewColor
|
|
: palette.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final String name = controller.text.trim();
|
|
if (name.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
Navigator.of(dialogContext).pop(
|
|
_CategoryDraft(
|
|
name: name,
|
|
colorValue: selectedColorValue,
|
|
iconCodePoint: selectedIconCodePoint,
|
|
),
|
|
);
|
|
},
|
|
child: Text(confirmLabel),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
if (result == null || result.name.trim().isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
return result;
|
|
} finally {
|
|
controller.dispose();
|
|
}
|
|
}
|
|
|
|
Future<Category?> _saveCategory({Category? existingCategory}) async {
|
|
final _CategoryDraft? categoryDraft = await _promptCategoryDetails(
|
|
title: existingCategory == null ? 'Crear categoría' : 'Editar categoría',
|
|
confirmLabel: existingCategory == null ? 'Crear' : 'Guardar',
|
|
initialName: existingCategory?.name,
|
|
initialColorValue:
|
|
existingCategory?.colorValue ??
|
|
CategoryStyle.colorsOf(context).first.toARGB32(),
|
|
initialIconCodePoint:
|
|
existingCategory?.iconCodePoint ??
|
|
CategoryStyle.icons.first.codePoint,
|
|
);
|
|
|
|
if (categoryDraft == null) {
|
|
return null;
|
|
}
|
|
|
|
final DateTime now = DateTime.now();
|
|
final Category category = existingCategory == null
|
|
? Category(
|
|
name: categoryDraft.name,
|
|
updatedAt: now,
|
|
colorValue: categoryDraft.colorValue,
|
|
iconCodePoint: categoryDraft.iconCodePoint,
|
|
)
|
|
: existingCategory.copyWith(
|
|
name: categoryDraft.name,
|
|
updatedAt: now,
|
|
isDirty: true,
|
|
colorValue: categoryDraft.colorValue,
|
|
iconCodePoint: categoryDraft.iconCodePoint,
|
|
);
|
|
|
|
await widget.repository.createCategory(category);
|
|
|
|
if (!mounted) {
|
|
return null;
|
|
}
|
|
|
|
setState(() {
|
|
_categories = <Category>[
|
|
for (final Category item in _categories)
|
|
if (item.id == category.id) category else item,
|
|
];
|
|
|
|
if (!_categories.any((Category item) => item.id == category.id)) {
|
|
_categories = <Category>[..._categories, category];
|
|
}
|
|
});
|
|
|
|
return category;
|
|
}
|
|
|
|
Future<void> _updateNoteCategory(Note note, String? categoryId) async {
|
|
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> _deleteNoteAfterConfirmation(Note note) async {
|
|
final bool? confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (BuildContext dialogContext) {
|
|
final AppPalette palette = _paletteOf(dialogContext);
|
|
|
|
return AlertDialog(
|
|
backgroundColor: palette.surfaceElevated,
|
|
title: const Text('Eliminar nota'),
|
|
content: const Text(
|
|
'Esta acción eliminará la nota. ¿Quieres continuar?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.red,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Confirmar'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
if (confirmed != true) {
|
|
return;
|
|
}
|
|
|
|
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')),
|
|
);
|
|
}
|
|
}
|
|
|
|
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)),
|
|
);
|
|
}
|
|
|
|
Widget _buildCategoryMenuItem({
|
|
required BuildContext context,
|
|
required String label,
|
|
required IconData icon,
|
|
required Color color,
|
|
required bool selected,
|
|
VoidCallback? onEditPressed,
|
|
}) {
|
|
final AppPalette palette = _paletteOf(context);
|
|
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 160),
|
|
padding: const EdgeInsets.all(2),
|
|
decoration: BoxDecoration(
|
|
color: selected ? color.withOpacity(0.05) : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: selected ? color.withOpacity(0.42) : Colors.transparent,
|
|
width: selected ? 1 : 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, color: color, size: 20),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: selected ? palette.textPrimary : palette.textSecondary,
|
|
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
if (onEditPressed != null)
|
|
IconButton(
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
|
icon: Icon(
|
|
Icons.more_vert,
|
|
size: 18,
|
|
color: palette.textSecondary,
|
|
),
|
|
onPressed: onEditPressed,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openCategoryFilter(BuildContext anchorContext) async {
|
|
final String? selectedCategoryId = await _showAnchoredCategoryMenu<String?>(
|
|
anchorContext: anchorContext,
|
|
items: <PopupMenuEntry<String?>>[
|
|
PopupMenuItem<String?>(
|
|
value: '',
|
|
child: _buildCategoryMenuItem(
|
|
context: anchorContext,
|
|
label: 'Todas las categorías',
|
|
icon: Icons.filter_alt_outlined,
|
|
color: _paletteOf(anchorContext).textSecondary,
|
|
selected: _selectedCategoryId == null,
|
|
),
|
|
),
|
|
const PopupMenuDivider(),
|
|
for (final Category category in _categories)
|
|
PopupMenuItem<String?>(
|
|
value: category.id,
|
|
child: Builder(
|
|
builder: (BuildContext menuContext) {
|
|
final Color categoryColor = Color(
|
|
category.colorValue ??
|
|
_paletteOf(menuContext).accent.toARGB32(),
|
|
);
|
|
return _buildCategoryMenuItem(
|
|
context: menuContext,
|
|
label: category.name,
|
|
icon: CategoryStyle.iconForCodePoint(category.iconCodePoint),
|
|
color: categoryColor,
|
|
selected: _selectedCategoryId == category.id,
|
|
onEditPressed: () {
|
|
Navigator.of(menuContext).pop();
|
|
unawaited(_saveCategory(existingCategory: category));
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
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 String? selectedCategoryId = await _showAnchoredCategoryMenu<String?>(
|
|
anchorContext: anchorContext,
|
|
items: <PopupMenuEntry<String?>>[
|
|
PopupMenuItem<String?>(
|
|
value: '',
|
|
child: _buildCategoryMenuItem(
|
|
context: anchorContext,
|
|
label: 'Sin categoría',
|
|
icon: Icons.folder_outlined,
|
|
color: _paletteOf(anchorContext).textSecondary,
|
|
selected: note.categoryId == null,
|
|
),
|
|
),
|
|
const PopupMenuDivider(),
|
|
for (final Category category in _categories)
|
|
PopupMenuItem<String?>(
|
|
value: category.id,
|
|
child: Builder(
|
|
builder: (BuildContext menuContext) {
|
|
final Color categoryColor = Color(
|
|
category.colorValue ??
|
|
_paletteOf(menuContext).accent.toARGB32(),
|
|
);
|
|
return _buildCategoryMenuItem(
|
|
context: menuContext,
|
|
label: category.name,
|
|
icon: CategoryStyle.iconForCodePoint(category.iconCodePoint),
|
|
color: categoryColor,
|
|
selected: note.categoryId == category.id,
|
|
onEditPressed: () {
|
|
Navigator.of(menuContext).pop();
|
|
unawaited(_saveCategory(existingCategory: category));
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const PopupMenuDivider(),
|
|
PopupMenuItem<String?>(
|
|
value: _createCategoryMenuValue,
|
|
child: _buildCategoryMenuItem(
|
|
context: anchorContext,
|
|
label: 'Crear categoría',
|
|
icon: Icons.add_circle_outline,
|
|
color: _paletteOf(anchorContext).textSecondary,
|
|
selected: false,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
if (!mounted || selectedCategoryId == null) {
|
|
return;
|
|
}
|
|
|
|
if (selectedCategoryId == _createCategoryMenuValue) {
|
|
final Category? createdCategory = await _saveCategory();
|
|
if (createdCategory == null || !mounted) {
|
|
return;
|
|
}
|
|
|
|
await _updateNoteCategory(note, createdCategory.id);
|
|
return;
|
|
}
|
|
|
|
final String? categoryId = selectedCategoryId.isEmpty
|
|
? null
|
|
: selectedCategoryId;
|
|
if (categoryId == note.categoryId) {
|
|
return;
|
|
}
|
|
|
|
await _updateNoteCategory(note, categoryId);
|
|
}
|
|
|
|
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,
|
|
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),
|
|
tooltip: 'Filtrar por categorías',
|
|
iconSize: 24,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: _selectedCategoryId == null
|
|
? Colors.transparent
|
|
: palette.accent.withOpacity(0.08),
|
|
shape: const CircleBorder(),
|
|
),
|
|
icon: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Icon(
|
|
Icons.filter_alt_outlined,
|
|
color: palette.textSecondary,
|
|
),
|
|
if (_selectedCategoryId != null)
|
|
Positioned(
|
|
right: -1,
|
|
top: -1,
|
|
child: Container(
|
|
width: 7,
|
|
height: 7,
|
|
decoration: BoxDecoration(
|
|
color: palette.accent,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
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(10, 10, 10, 14),
|
|
buildDefaultDragHandles: false,
|
|
itemCount: visibleNotes.length,
|
|
onReorder: _handleReorder,
|
|
footer: Padding(
|
|
padding: const EdgeInsets.only(top: 4, bottom: 72),
|
|
child: Center(
|
|
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: 6),
|
|
child: ReorderableDelayedDragStartListener(
|
|
index: index,
|
|
child: NoteCard(
|
|
note: note,
|
|
category: _categoryById(note.categoryId),
|
|
isSelected: note.id == _selectedNoteId,
|
|
showSelectionBorder: isDesktop,
|
|
onTap: () => _handleNoteTap(note, isDesktop),
|
|
onDelete: () => _deleteNoteAfterConfirmation(note),
|
|
onChangeCategory: (BuildContext buttonContext) =>
|
|
_changeNoteCategory(buttonContext, note),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyDetailPane(BuildContext context) {
|
|
final AppPalette palette = _paletteOf(context);
|
|
|
|
return Container(
|
|
child: Center(
|
|
child: Text(
|
|
'Selecciona una nota o\ncrea 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,
|
|
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);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|