817851cec3
- Added debounce functionality for search input in SearchAppBar to improve performance. - Introduced a controller parameter to allow external control of the search input. - Made padding customizable via a new padding parameter. - Improved leading button handling with an option to show/hide it. feat: Implement Category Editor Dialog - Created a new CategoryEditorDialog widget for editing category details. - Allows users to set category name, color, and icon with a preview. - Supports creating new categories with a confirmation dialog. feat: Add Category Menu Button - Introduced CategoryMenuButton for selecting categories with an option to create new ones. - Displays a menu with existing categories and allows editing. - Supports dynamic background color based on active state. feat: Create Note List View - Added NoteListView widget for displaying a list of notes with reordering capability. - Integrates refresh functionality and displays last sync time. - Supports selection and deletion of notes with visual feedback.
244 lines
7.5 KiB
Dart
244 lines
7.5 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:notas/models/category.dart';
|
|
import 'package:notas/theme/app_palette.dart';
|
|
import 'package:notas/widgets/category_style.dart';
|
|
|
|
const String kCreateCategoryMenuValue = '__create_category__';
|
|
|
|
class CategoryMenuButton extends StatelessWidget {
|
|
const CategoryMenuButton({
|
|
super.key,
|
|
required this.categories,
|
|
required this.selectedCategoryId,
|
|
required this.onCategorySelected,
|
|
this.onCreateCategory,
|
|
this.onEditCategory,
|
|
this.icon = Icons.filter_alt_outlined,
|
|
this.tooltip = 'Filtrar por categorías',
|
|
this.isActive = false,
|
|
this.allowCreateCategory = false,
|
|
this.emptyLabel = 'Todas las categorías',
|
|
this.createLabel = 'Crear categoría',
|
|
this.backgroundColor,
|
|
});
|
|
|
|
final List<Category> categories;
|
|
final String? selectedCategoryId;
|
|
final ValueChanged<String?> onCategorySelected;
|
|
final FutureOr<void> Function()? onCreateCategory;
|
|
final FutureOr<void> Function(Category category)? onEditCategory;
|
|
final IconData icon;
|
|
final String tooltip;
|
|
final bool isActive;
|
|
final bool allowCreateCategory;
|
|
final String emptyLabel;
|
|
final String createLabel;
|
|
final Color? backgroundColor;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final AppPalette palette = Theme.of(context).extension<AppPalette>() ??
|
|
AppPalette.fromBrightness(Theme.of(context).brightness);
|
|
|
|
return IconButton(
|
|
onPressed: () async {
|
|
final String? selectedValue = await showCategorySelectionMenu(
|
|
anchorContext: context,
|
|
categories: categories,
|
|
selectedCategoryId: selectedCategoryId,
|
|
allowCreateCategory: allowCreateCategory,
|
|
emptyLabel: emptyLabel,
|
|
createLabel: createLabel,
|
|
onEditCategory: onEditCategory,
|
|
);
|
|
|
|
if (selectedValue == null) {
|
|
return;
|
|
}
|
|
|
|
if (selectedValue == kCreateCategoryMenuValue) {
|
|
await onCreateCategory?.call();
|
|
return;
|
|
}
|
|
|
|
onCategorySelected(selectedValue.isEmpty ? null : selectedValue);
|
|
},
|
|
tooltip: tooltip,
|
|
iconSize: 24,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: backgroundColor ??
|
|
(isActive ? palette.accent.withValues(alpha: 0.08) : Colors.transparent),
|
|
shape: const CircleBorder(),
|
|
),
|
|
icon: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Icon(icon, color: palette.textSecondary),
|
|
if (isActive)
|
|
Positioned(
|
|
right: -1,
|
|
top: -1,
|
|
child: Container(
|
|
width: 7,
|
|
height: 7,
|
|
decoration: BoxDecoration(
|
|
color: palette.accent,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<String?> showCategorySelectionMenu({
|
|
required BuildContext anchorContext,
|
|
required List<Category> categories,
|
|
required String? selectedCategoryId,
|
|
required bool allowCreateCategory,
|
|
required String emptyLabel,
|
|
required String createLabel,
|
|
FutureOr<void> Function(Category category)? onEditCategory,
|
|
}) {
|
|
return showMenu<String?>(
|
|
context: anchorContext,
|
|
position: _menuRectFromContext(anchorContext),
|
|
elevation: 10,
|
|
color: _paletteOf(anchorContext).surfaceElevated,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
|
items: <PopupMenuEntry<String?>>[
|
|
PopupMenuItem<String?>(
|
|
value: '',
|
|
child: _buildCategoryMenuItem(
|
|
context: anchorContext,
|
|
label: emptyLabel,
|
|
icon: allowCreateCategory
|
|
? Icons.folder_outlined
|
|
: 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: onEditCategory == null
|
|
? null
|
|
: () {
|
|
Navigator.of(menuContext).pop();
|
|
final FutureOr<void> result = onEditCategory(category);
|
|
if (result is Future<void>) {
|
|
unawaited(result);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
if (allowCreateCategory) ...[
|
|
const PopupMenuDivider(),
|
|
PopupMenuItem<String?>(
|
|
value: kCreateCategoryMenuValue,
|
|
child: _buildCategoryMenuItem(
|
|
context: anchorContext,
|
|
label: createLabel,
|
|
icon: Icons.add_circle_outline,
|
|
color: _paletteOf(anchorContext).textSecondary,
|
|
selected: false,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
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.withValues(alpha: 0.05) : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: selected ? color.withValues(alpha: 0.42) : Colors.transparent,
|
|
width: 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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
AppPalette _paletteOf(BuildContext context) {
|
|
return Theme.of(context).extension<AppPalette>() ??
|
|
AppPalette.fromBrightness(Theme.of(context).brightness);
|
|
} |