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 Function() onRequestSync; final Future Function()? onVaultInvalid; final SyncStatus syncStatus; final double? syncProgress; final String? syncDetailMessage; final String? syncErrorMessage; final int refreshToken; @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { static const double _desktopBreakpoint = 900; final TextEditingController _searchController = TextEditingController(); final GlobalKey _filterButtonKey = GlobalKey(); List _notes = []; List _categories = []; bool _isLoading = true; String _searchQuery = ''; String? _selectedCategoryId; String? _selectedNoteId; DateTime? _lastSyncAt; AppPalette _paletteOf(BuildContext context) { return Theme.of(context).extension() ?? 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 _loadData({bool keepSelection = false}) async { if (mounted) { setState(() { _isLoading = true; }); } try { final List notes = await widget.repository.loadNotes(); final List 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 _reloadNotes({bool keepSelection = true}) async { try { final List 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 _visibleNotes() { Iterable 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 _showAnchoredCategoryMenu({ required BuildContext anchorContext, required List> items, }) { return showMenu( context: anchorContext, position: _menuRectFromContext(anchorContext), items: items, elevation: 10, color: _paletteOf(anchorContext).surfaceElevated, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), ); } Future _openCategoryFilter(BuildContext anchorContext) async { final String? selectedCategoryId = await _showAnchoredCategoryMenu( anchorContext: anchorContext, items: >[ const PopupMenuItem( value: '', child: Text('Todas las categorías'), ), const PopupMenuDivider(), for (final Category category in _categories) PopupMenuItem( 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 _changeNoteCategory(BuildContext anchorContext, Note note) async { final Category? selected = await _showAnchoredCategoryMenu( anchorContext: anchorContext, items: >[ const PopupMenuItem( value: null, child: Text('Sin categoría'), ), const PopupMenuDivider(), for (final Category category in _categories) PopupMenuItem( 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 = [ 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 _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 _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 _openExistingNote(Note note, {required bool embedded}) async { setState(() { _selectedNoteId = note.id; }); if (embedded) { return; } await _openEditor(note, embedded: false); } Future _openEditor(Note note, {required bool embedded}) async { final Widget editor = NoteEditorScreen( key: ValueKey(note.id), repository: widget.repository, note: note, categories: _categories, embedded: embedded, onSaved: (Note saved) { if (!mounted) { return; } setState(() { _notes = [ for (final Note item in _notes) if (item.id == saved.id) saved else item, ]; }); }, ); if (embedded) { return; } await Navigator.of(context).push( MaterialPageRoute(builder: (_) => editor), ); } Future _handleNoteTap(Note note, bool isDesktop) async { if (isDesktop) { await _openExistingNote(note, embedded: true); return; } await _openEditor(note, embedded: false); } Future _handleReorder(int oldIndex, int newIndex) async { final List visibleNotes = _visibleNotes(); if (oldIndex < 0 || oldIndex >= visibleNotes.length) { return; } final Note movedNote = visibleNotes[oldIndex]; final List remainingVisible = [...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 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(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(selectedNote.id), repository: widget.repository, note: selectedNote, categories: _categories, embedded: true, onSaved: (Note saved) { if (!mounted) { return; } setState(() { _notes = [ 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); }, ), ), ); } }