2026-03-18 13:35:25 +08:00
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
|
|
|
|
import '../../core/theme/design_tokens.dart';
|
|
|
|
|
|
|
|
|
|
class DetailHeaderActionItem<T> {
|
|
|
|
|
const DetailHeaderActionItem({
|
|
|
|
|
required this.value,
|
|
|
|
|
required this.label,
|
|
|
|
|
required this.icon,
|
|
|
|
|
this.isDestructive = false,
|
2026-03-20 01:30:34 +08:00
|
|
|
this.enabled = true,
|
2026-03-18 13:35:25 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final T value;
|
|
|
|
|
final String label;
|
|
|
|
|
final IconData icon;
|
|
|
|
|
final bool isDestructive;
|
2026-03-20 01:30:34 +08:00
|
|
|
final bool enabled;
|
2026-03-18 13:35:25 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class DetailHeaderActionMenu<T> extends StatefulWidget {
|
|
|
|
|
const DetailHeaderActionMenu({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.items,
|
|
|
|
|
required this.onSelected,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final List<DetailHeaderActionItem<T>> items;
|
|
|
|
|
final ValueChanged<T> onSelected;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<DetailHeaderActionMenu<T>> createState() =>
|
|
|
|
|
_DetailHeaderActionMenuState<T>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _DetailHeaderActionMenuState<T> extends State<DetailHeaderActionMenu<T>> {
|
|
|
|
|
static const double _buttonSize = AppSpacing.xl * 2;
|
|
|
|
|
static const double _menuWidth = AppSpacing.xxl * 8;
|
|
|
|
|
|
|
|
|
|
final LayerLink _layerLink = LayerLink();
|
|
|
|
|
OverlayEntry? _menuEntry;
|
|
|
|
|
|
|
|
|
|
bool get _isMenuOpen => _menuEntry != null;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_hideMenu();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _toggleMenu() {
|
|
|
|
|
if (_isMenuOpen) {
|
|
|
|
|
_hideMenu();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_showMenu();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _showMenu() {
|
|
|
|
|
final overlay = Overlay.of(context);
|
|
|
|
|
_menuEntry = OverlayEntry(
|
|
|
|
|
builder: (context) {
|
|
|
|
|
return Stack(
|
|
|
|
|
children: [
|
|
|
|
|
Positioned.fill(
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
onTap: _hideMenu,
|
|
|
|
|
child: const SizedBox.expand(),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
CompositedTransformFollower(
|
|
|
|
|
link: _layerLink,
|
|
|
|
|
showWhenUnlinked: false,
|
2026-03-27 19:07:39 +08:00
|
|
|
offset: Offset(
|
2026-03-18 13:35:25 +08:00
|
|
|
_buttonSize - _menuWidth,
|
|
|
|
|
_buttonSize + AppSpacing.sm,
|
|
|
|
|
),
|
|
|
|
|
child: _buildMenuCard(),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
overlay.insert(_menuEntry!);
|
|
|
|
|
setState(() {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _hideMenu() {
|
|
|
|
|
_menuEntry?.remove();
|
|
|
|
|
_menuEntry = null;
|
|
|
|
|
if (mounted) {
|
|
|
|
|
setState(() {});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleSelect(T value) {
|
|
|
|
|
_hideMenu();
|
|
|
|
|
widget.onSelected(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildMenuCard() {
|
2026-03-27 19:07:39 +08:00
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
2026-03-18 13:35:25 +08:00
|
|
|
return Material(
|
2026-03-27 19:07:39 +08:00
|
|
|
color: colorScheme.surface.withValues(alpha: 0),
|
2026-03-18 13:35:25 +08:00
|
|
|
child: Container(
|
|
|
|
|
width: _menuWidth,
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
|
|
|
|
decoration: BoxDecoration(
|
2026-03-27 19:07:39 +08:00
|
|
|
color: colorScheme.surface,
|
2026-03-18 13:35:25 +08:00
|
|
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
2026-03-27 19:07:39 +08:00
|
|
|
border: Border.all(color: colorScheme.outlineVariant),
|
2026-03-18 13:35:25 +08:00
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
2026-03-27 19:07:39 +08:00
|
|
|
color: colorScheme.shadow.withValues(alpha: 0.42),
|
2026-03-18 13:35:25 +08:00
|
|
|
blurRadius: AppSpacing.xl,
|
|
|
|
|
offset: const Offset(0, AppSpacing.sm),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
children: [
|
|
|
|
|
for (int i = 0; i < widget.items.length; i++) ...[
|
|
|
|
|
_buildMenuItem(widget.items[i]),
|
|
|
|
|
if (i < widget.items.length - 1)
|
2026-03-27 19:07:39 +08:00
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: AppSpacing.md,
|
|
|
|
|
),
|
2026-03-18 13:35:25 +08:00
|
|
|
child: Divider(
|
|
|
|
|
height: 1,
|
|
|
|
|
thickness: 1,
|
2026-03-27 19:07:39 +08:00
|
|
|
color: colorScheme.outlineVariant,
|
2026-03-18 13:35:25 +08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildMenuItem(DetailHeaderActionItem<T> item) {
|
2026-03-27 19:07:39 +08:00
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
2026-03-18 13:35:25 +08:00
|
|
|
final textColor = item.isDestructive
|
2026-03-27 19:07:39 +08:00
|
|
|
? colorScheme.error
|
|
|
|
|
: (item.enabled ? colorScheme.onSurface : colorScheme.onSurfaceVariant);
|
2026-03-18 13:35:25 +08:00
|
|
|
final pressedColor = item.isDestructive
|
2026-03-27 19:07:39 +08:00
|
|
|
? colorScheme.errorContainer
|
|
|
|
|
: colorScheme.primaryContainer;
|
2026-03-18 13:35:25 +08:00
|
|
|
|
|
|
|
|
return SizedBox(
|
|
|
|
|
height: AppSpacing.xxl * 2,
|
|
|
|
|
child: Material(
|
2026-03-27 19:07:39 +08:00
|
|
|
color: colorScheme.surface.withValues(alpha: 0),
|
2026-03-18 13:35:25 +08:00
|
|
|
child: InkWell(
|
|
|
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
2026-03-27 19:07:39 +08:00
|
|
|
splashColor: item.enabled
|
|
|
|
|
? pressedColor
|
|
|
|
|
: colorScheme.surface.withValues(alpha: 0),
|
|
|
|
|
highlightColor: item.enabled
|
|
|
|
|
? pressedColor
|
|
|
|
|
: colorScheme.surface.withValues(alpha: 0),
|
2026-03-20 01:30:34 +08:00
|
|
|
onTap: item.enabled ? () => _handleSelect(item.value) : null,
|
2026-03-18 13:35:25 +08:00
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
|
|
|
|
child: Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(
|
|
|
|
|
item.icon,
|
|
|
|
|
size: AppSpacing.lg + AppSpacing.xs,
|
|
|
|
|
color: textColor,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: AppSpacing.md),
|
|
|
|
|
Text(
|
|
|
|
|
item.label,
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
color: textColor,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
if (widget.items.isEmpty) {
|
|
|
|
|
return const SizedBox.shrink();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 19:07:39 +08:00
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
2026-03-18 13:35:25 +08:00
|
|
|
return CompositedTransformTarget(
|
|
|
|
|
link: _layerLink,
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
onTap: _toggleMenu,
|
|
|
|
|
child: AnimatedContainer(
|
|
|
|
|
duration: const Duration(milliseconds: 140),
|
|
|
|
|
width: _buttonSize,
|
|
|
|
|
height: _buttonSize,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: _isMenuOpen
|
2026-03-27 19:07:39 +08:00
|
|
|
? colorScheme.secondaryContainer
|
|
|
|
|
: colorScheme.tertiaryContainer,
|
2026-03-18 13:35:25 +08:00
|
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
|
|
|
border: Border.all(
|
|
|
|
|
color: _isMenuOpen
|
2026-03-27 19:07:39 +08:00
|
|
|
? colorScheme.outlineVariant
|
|
|
|
|
: colorScheme.outline,
|
2026-03-18 13:35:25 +08:00
|
|
|
),
|
|
|
|
|
),
|
2026-03-27 19:07:39 +08:00
|
|
|
child: Icon(
|
2026-03-18 13:35:25 +08:00
|
|
|
Icons.more_horiz,
|
|
|
|
|
size: AppSpacing.lg + AppSpacing.xs,
|
2026-03-27 19:07:39 +08:00
|
|
|
color: colorScheme.onSurfaceVariant,
|
2026-03-18 13:35:25 +08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|