Files
notas/lib/screens/home_screen.dart
T

1202 lines
39 KiB
Dart

import 'dart:async';
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/models/category.dart';
import 'package:notas/widgets/menu_drawer.dart';
import 'package:notas/widgets/category_style.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';
import 'package:notas/theme/app_colors.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;
bool _showDeletedNotes = false;
PointerDeviceKind _lastPointerKind = PointerDeviceKind.mouse;
List<Category> _categories = <Category>[];
String? _selectedCategoryId;
void _openMenu() {
if (_isMenuOpen) {
return;
}
setState(() {
_isMenuOpen = true;
});
}
void _closeMenu() {
if (!_isMenuOpen) {
return;
}
setState(() {
_isMenuOpen = false;
});
}
bool _requiresLongPressToDrag(PointerDeviceKind kind) {
return kind == PointerDeviceKind.touch ||
kind == PointerDeviceKind.stylus ||
kind == PointerDeviceKind.invertedStylus;
}
@override
void initState() {
super.initState();
_loadNotesAndCategories();
}
Future<void> _loadNotesAndCategories({
bool showLoadingIndicator = true,
}) async {
if (showLoadingIndicator) {
setState(() {
_isLoading = true;
});
}
final Future<List<Note>> notesFuture = _showDeletedNotes
? widget.repository.loadDeletedNotes()
: widget.repository.loadNotes();
final Future<List<Category>> categoriesFuture = widget.repository
.loadCategories();
List<Note> notesResult = <Note>[];
List<Category> categoriesResult = <Category>[];
try {
notesResult = await notesFuture;
} catch (e, st) {
debugPrint('Failed to load notes: $e\n$st');
if (widget.onVaultInvalid != null) {
await widget.onVaultInvalid!();
}
return;
}
try {
categoriesResult = await categoriesFuture;
} catch (e) {
debugPrint('Failed to load categories: $e');
categoriesResult = <Category>[];
}
if (!mounted) return;
setState(() {
_notes = notesResult;
_categories = categoriesResult;
if (showLoadingIndicator) {
_isLoading = false;
}
});
}
Future<void> _loadCategories() async {
try {
final List<Category> cats = await widget.repository.loadCategories();
if (!mounted) return;
setState(() {
_categories = cats;
});
} catch (e) {
debugPrint('Failed to load categories: $e');
}
}
Category? _currentCategory() {
final String? selectedCategoryId = _selectedCategoryId;
if (selectedCategoryId == null) {
return null;
}
for (final Category category in _categories) {
if (category.id == selectedCategoryId) {
return category;
}
}
return null;
}
@override
void didUpdateWidget(covariant HomeScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.refreshToken != widget.refreshToken) {
// Refresh in background without showing the full-screen loader
_loadNotesAndCategories(showLoadingIndicator: false);
}
}
Future<void> _loadNotes() async {
try {
final List<Note> storedNotes = _showDeletedNotes
? await widget.repository.loadDeletedNotes()
: await widget.repository.loadNotes();
if (!mounted) return;
setState(() {
_notes = storedNotes;
_isLoading = false;
});
} catch (e, st) {
// Log the error so we can inspect the root cause before resetting the vault.
debugPrint('Failed to load notes: $e\n$st');
// 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,
categoryId: _showDeletedNotes ? null : _selectedCategoryId,
categories: _categories,
);
if (result == null) {
return;
}
if (result is Note) {
await widget.repository.createNote(result);
await _loadNotes();
// Trigger sync after creating a note.
try {
await widget.onRequestSync();
} catch (_) {}
}
}
Future<void> _deleteNote(Note note) async {
await widget.repository.deleteNote(note);
await _loadNotes();
// Trigger sync after deleting a note.
try {
await widget.onRequestSync();
} catch (_) {}
}
Future<void> _reorderNote(int oldIndex, int newIndex) async {
if (oldIndex == newIndex) {
return;
}
final Note movedNote = _notes[oldIndex];
try {
await widget.repository.moveNote(movedNote, newIndex);
await _loadNotes();
} catch (e, st) {
// Don't let DB errors cause the app to reset the vault automatically.
debugPrint('Failed to move note: $e\n$st');
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error al reordenar la nota: $e')));
}
}
Future<void> _openNoteEditor(Note note) async {
final dynamic result = await NoteEditorScreen.showDialog(
context,
note: note,
categories: _categories,
);
if (result == null) {
return;
}
if (result == 'delete') {
await _deleteNote(note);
return;
}
if (result is Note) {
if (_notes.any((Note item) => item == note)) {
await widget.repository.updateNote(result);
await _loadNotes();
// Trigger sync after editing a note.
try {
await widget.onRequestSync();
} catch (_) {}
}
}
}
List<Note> _getFilteredNotes() {
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) ||
note.body.toLowerCase().contains(query),
)
.toList();
}
Future<void> _handleMenuItemTapped(String item) async {
_closeMenu();
if (item == 'create_category') {
await _showCreateCategoryDialog();
return;
}
if (item.startsWith('category_')) {
final String id = item.substring('category_'.length);
setState(() {
_selectedCategoryId = id;
_showDeletedNotes = false;
_searchQuery = '';
_isLoading = true;
});
await _loadNotes();
return;
}
if (item == 'settings') {
widget.onOpenSettings();
return;
}
if (item == 'deleted_notes') {
setState(() {
_showDeletedNotes = true;
_selectedCategoryId = null;
_searchQuery = '';
_isLoading = true;
});
await _loadNotes();
return;
}
if (item == 'all_notes') {
setState(() {
_showDeletedNotes = false;
_selectedCategoryId = null;
_searchQuery = '';
_isLoading = true;
});
await _loadNotes();
}
}
Future<void> _showCreateCategoryDialog([Category? category]) async {
await showDialog<void>(
context: context,
builder: (BuildContext context) {
return _CategoryDialog(
category: category,
repository: widget.repository,
onCategoriesChanged: _loadCategories,
onRequestSync: widget.onRequestSync,
onCategoryDeleted: () => _handleMenuItemTapped('all_notes'),
);
},
);
}
@override
Widget build(BuildContext context) {
final double width = MediaQuery.of(context).size.width;
final int crossAxisCount = math.max((width / 250).floor().round(), 2);
final List<Note> visibleNotes = _getFilteredNotes();
final Category? currentCategory = _currentCategory();
final Map<String, Color> categoryBorderColors = <String, Color>{
for (final Category category in _categories)
if (category.colorValue != null)
category.id: Color(category.colorValue!),
};
final Widget body = _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: () async {
await widget.onRequestSync();
await _loadNotesAndCategories(showLoadingIndicator: false);
},
child: MouseRegion(
cursor: _isDragging
? SystemMouseCursors.grabbing
: SystemMouseCursors.basic,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: visibleNotes.isEmpty
? [
SliverFillRemaining(
hasScrollBody: false,
child: _EmptyState(
showDeletedNotes: _showDeletedNotes,
categoryName: currentCategory?.name,
searchQuery: _searchQuery,
),
),
]
: [
SliverPadding(
padding: const EdgeInsets.only(bottom: 8),
sliver: SliverMasonryGrid.count(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childCount: visibleNotes.length,
itemBuilder: (BuildContext context, int index) {
final List<Note> filteredNotes = visibleNotes;
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 = _DraggableNote(
note: filteredNotes[index],
borderColor:
categoryBorderColors[filteredNotes[index]
.categoryId],
dataIndex: _notes.indexOf(
filteredNotes[index],
),
cellWidth: cellWidth,
requiresLongPressToDrag:
requiresLongPressToDrag,
isDragging: _isDragging,
isDragTargetActive:
candidateData.isNotEmpty,
onTap: () => _openNoteEditor(
filteredNotes[index],
),
onDragStarted: () {
if (!mounted) return;
setState(() {
_isDragging = true;
});
},
onDragEnd: (_) {
if (!mounted) return;
setState(() {
_isDragging = false;
});
},
onDraggableCanceled: () {
if (!mounted) return;
setState(() {
_isDragging = false;
});
},
);
return Listener(
onPointerDown:
(PointerDownEvent event) {
if (_lastPointerKind ==
event.kind) {
return;
}
setState(() {
_lastPointerKind = event.kind;
});
},
child: draggableNote,
);
},
);
},
);
},
),
),
],
),
),
);
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.backdropGradient),
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: Listener(
onPointerDown: (PointerDownEvent event) {
if (_lastPointerKind == event.kind) {
return;
}
setState(() {
_lastPointerKind = event.kind;
});
},
child: Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
child: body,
),
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 28,
child: IgnorePointer(
ignoring:
_isMenuOpen ||
!_requiresLongPressToDrag(_lastPointerKind),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragUpdate:
(DragUpdateDetails details) {
if ((details.primaryDelta ?? 0) > 6) {
_openMenu();
}
},
child: const SizedBox.expand(),
),
),
),
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: _closeMenu,
child: Container(color: AppColors.overlay),
),
),
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: _isMenuOpen ? 0 : -280,
top: 0,
bottom: 0,
width: 280,
child: Material(
color: AppColors.cardBackground,
elevation: 8,
child: MenuDrawer(
onMenuItemTapped: _handleMenuItemTapped,
selectedItem: _selectedCategoryId != null
? 'category_$_selectedCategoryId'
: (_showDeletedNotes
? 'deleted_notes'
: 'all_notes'),
categories: _categories,
onEditCategory: (Category c) =>
_showCreateCategoryDialog(c),
onCreateCategory: _showCreateCategoryDialog,
),
),
),
],
),
),
),
],
),
),
),
floatingActionButton: _showDeletedNotes
? null
: FloatingActionButton(
onPressed: _openNoteComposer,
child: const MouseRegion(
cursor: SystemMouseCursors.click,
child: Icon(Icons.add),
),
),
);
}
}
class _DraggableNote extends StatelessWidget {
const _DraggableNote({
required this.note,
this.borderColor,
required this.dataIndex,
required this.cellWidth,
required this.requiresLongPressToDrag,
required this.isDragging,
required this.isDragTargetActive,
required this.onTap,
required this.onDragStarted,
required this.onDragEnd,
required this.onDraggableCanceled,
});
final Note note;
final Color? borderColor;
final int dataIndex;
final double cellWidth;
final bool requiresLongPressToDrag;
final bool isDragging;
final bool isDragTargetActive;
final VoidCallback onTap;
final void Function(DraggableDetails) onDragEnd;
final VoidCallback onDraggableCanceled;
final VoidCallback onDragStarted;
Widget _buildFeedback() {
return MouseRegion(
cursor: SystemMouseCursors.grabbing,
child: Material(
color: AppColors.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: note,
onTap: () {},
isDragging: true,
borderColor: borderColor,
),
),
),
),
),
);
}
Widget _buildChildWhenDragging() {
return MouseRegion(
cursor: SystemMouseCursors.grabbing,
child: Opacity(
opacity: 0.3,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: borderColor ?? AppColors.textDisabled,
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
note.title,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
note.body,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
),
maxLines: 20,
overflow: TextOverflow.clip,
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final Widget content = Container(
decoration: BoxDecoration(
border: isDragTargetActive
? Border.all(color: AppColors.dragTargetBorder, width: 2)
: null,
borderRadius: BorderRadius.circular(12),
),
child: NoteCard(
key: ValueKey<String>(note.id),
note: note,
onTap: onTap,
isDragging: isDragging,
borderColor: borderColor,
),
);
if (requiresLongPressToDrag) {
return LongPressDraggable<int>(
data: dataIndex,
delay: const Duration(milliseconds: 280),
onDragStarted: onDragStarted,
onDragEnd: onDragEnd,
onDraggableCanceled: (Velocity _v, Offset _o) => onDraggableCanceled(),
feedback: _buildFeedback(),
childWhenDragging: _buildChildWhenDragging(),
child: content,
);
}
return Draggable<int>(
data: dataIndex,
onDragStarted: onDragStarted,
onDragEnd: onDragEnd,
onDraggableCanceled: (Velocity _v, Offset _o) => onDraggableCanceled(),
feedback: _buildFeedback(),
childWhenDragging: _buildChildWhenDragging(),
child: content,
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState({
required this.showDeletedNotes,
this.categoryName,
this.searchQuery,
});
final bool showDeletedNotes;
final String? categoryName;
final String? searchQuery;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.note_add_outlined,
color: AppColors.textMuted,
size: 48,
),
const SizedBox(height: 12),
Text(
searchQuery != null && searchQuery!.isNotEmpty
? 'No hay resultados'
: showDeletedNotes
? 'No hay notas borradas'
: categoryName != null
? 'No hay notas en esta categoría'
: 'Aún no hay notas',
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
searchQuery != null && searchQuery!.isNotEmpty
? 'Prueba a buscar con otro término o crea una nota nueva.'
: showDeletedNotes
? 'Las notas borradas aparecerán aquí para poder restaurarlas.'
: categoryName != null
? 'Pulsa el botón + para crear una nota en “$categoryName”.'
: 'Pulsa el botón + para crear la primera.',
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textSecondary),
),
],
),
);
}
}
class _CategoryDialog extends StatefulWidget {
const _CategoryDialog({
required this.category,
required this.repository,
required this.onCategoriesChanged,
required this.onRequestSync,
required this.onCategoryDeleted,
});
final Category? category;
final NoteRepository repository;
final Future<void> Function() onCategoriesChanged;
final Future<void> Function() onRequestSync;
final Future<void> Function() onCategoryDeleted;
@override
State<_CategoryDialog> createState() => _CategoryDialogState();
}
class _CategoryDialogState extends State<_CategoryDialog> {
late final TextEditingController _controller;
Color? _selectedColor;
IconData? _selectedIcon;
int _selectedSection = 0;
bool _nameHasError = false;
bool _isSaving = false;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.category?.name ?? '');
_selectedColor = widget.category == null
? CategoryStyle.colors.first
: widget.category!.colorValue != null
? Color(widget.category!.colorValue!)
: null;
if (widget.category != null && widget.category!.iconCodePoint != null) {
_selectedIcon = CategoryStyle.icons.firstWhere(
(IconData icon) => icon.codePoint == widget.category!.iconCodePoint,
orElse: () => CategoryStyle.icons.first,
);
} else if (widget.category == null) {
_selectedIcon = CategoryStyle.icons.first;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _saveCategory() async {
if (_isSaving) {
return;
}
final String name = _controller.text.trim();
if (name.isEmpty) {
setState(() {
_nameHasError = true;
});
return;
}
try {
setState(() {
_nameHasError = false;
_isSaving = true;
});
final Category newCategory = Category(
id: widget.category?.id,
name: name,
serverVersion: widget.category?.serverVersion ?? 0,
updatedAt: DateTime.now(),
colorValue: _selectedColor?.toARGB32(),
iconCodePoint: _selectedIcon?.codePoint,
);
if (widget.category == null) {
debugPrint(
'Creating category: ${newCategory.name}, color: ${newCategory.colorValue}, icon: ${newCategory.iconCodePoint}',
);
} else {
debugPrint(
'Updating category: ${newCategory.name}, color: ${newCategory.colorValue}, icon: ${newCategory.iconCodePoint}',
);
}
await widget.repository.createCategory(newCategory);
await widget.onCategoriesChanged();
if (mounted) {
Navigator.pop(context);
}
widget.onRequestSync().catchError((_) {});
} catch (e) {
debugPrint('ERROR creating category: $e');
if (mounted) {
setState(() {
_isSaving = false;
});
}
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error al crear categoría: $e')));
}
}
}
Future<void> _deleteCategory() async {
final bool? confirm = await showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
backgroundColor: AppColors.cardBackground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: AppColors.border),
),
title: const Text('Borrar categoría'),
content: const Text('¿Seguro que quieres borrar esta categoría?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancelar'),
),
TextButton(
style: TextButton.styleFrom(foregroundColor: AppColors.destructive),
onPressed: () => Navigator.pop(context, true),
child: const Text('Borrar'),
),
],
),
);
if (confirm != true) {
return;
}
try {
await widget.repository.deleteCategory(widget.category!.id);
await widget.onCategoriesChanged();
// Notify parent to switch view to all notes when a category is deleted.
await widget.onCategoryDeleted();
if (mounted) {
Navigator.pop(context);
}
try {
await widget.onRequestSync();
} catch (_) {}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error al borrar categoría: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: AppColors.cardBackground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: AppColors.border),
),
title: Text(
widget.category == null ? 'Crear categoría' : 'Editar categoría',
),
content: SizedBox(
width: 380,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _controller,
onChanged: (_) {
if (_nameHasError) {
setState(() {
_nameHasError = false;
});
}
},
decoration:
const InputDecoration(
hintText: 'Nombre de la categoría',
).copyWith(
errorText: _nameHasError
? 'El nombre es obligatorio'
: null,
),
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: AppColors.fill,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: _PickerTabButton(
label: 'Color',
selected: _selectedSection == 0,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(13),
),
onTap: () => setState(() => _selectedSection = 0),
),
),
Expanded(
child: _PickerTabButton(
label: 'Icono',
selected: _selectedSection == 1,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(13),
),
onTap: () => setState(() => _selectedSection = 1),
),
),
],
),
const Divider(height: 1, color: AppColors.border),
Padding(
padding: const EdgeInsets.all(12),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: _selectedSection == 0
? Wrap(
key: const ValueKey<String>('colors'),
spacing: 10,
runSpacing: 10,
children: CategoryStyle.colors.map((Color color) {
final bool isSelected =
_selectedColor?.value == color.value;
return GestureDetector(
onTap: () => setState(() {
_selectedColor = color;
_selectedSection = 0;
}),
child: Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(10),
border: isSelected
? Border.all(
color: AppColors.textPrimary,
width: 2,
)
: Border.all(
color: AppColors.border,
width: 1,
),
),
),
);
}).toList(),
)
: Wrap(
key: const ValueKey<String>('icons'),
spacing: 10,
runSpacing: 10,
children: CategoryStyle.icons.map((
IconData icon,
) {
final bool isSelected = _selectedIcon == icon;
return GestureDetector(
onTap: () => setState(() {
_selectedIcon = icon;
_selectedSection = 1;
}),
child: Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: isSelected
? AppColors.hover
: AppColors.transparent,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected
? AppColors.textPrimary
: AppColors.border,
width: 1,
),
),
child: Icon(
icon,
color: isSelected
? AppColors.textPrimary
: AppColors.textSecondary,
),
),
);
}).toList(),
),
),
),
],
),
),
],
),
),
actions: [
if (widget.category != null)
TextButton(
onPressed: _deleteCategory,
child: const Text(
'Borrar',
style: TextStyle(color: AppColors.destructive),
),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancelar'),
),
TextButton(
onPressed: _isSaving ? null : _saveCategory,
child: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(widget.category == null ? 'Crear' : 'Guardar'),
),
],
);
}
}
class _PickerTabButton extends StatelessWidget {
const _PickerTabButton({
required this.label,
required this.selected,
required this.borderRadius,
required this.onTap,
});
final String label;
final bool selected;
final BorderRadius borderRadius;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
color: selected ? AppColors.hover : AppColors.transparent,
child: InkWell(
borderRadius: borderRadius,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Center(
child: Text(
label,
style: TextStyle(
color: selected ? AppColors.textPrimary : AppColors.textMuted,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
),
);
}
}