232 lines
8.5 KiB
Dart
232 lines
8.5 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 工具网格项组件
|
||
/// 创建时间: 2026-04-26
|
||
/// 更新时间: 2026-05-30
|
||
/// 作用: 工具中心4列网格中的单个工具图标 + 长按菜单
|
||
/// 上次更新: 添加Semantics无障碍支持 + 版本角标 + 状态指示
|
||
/// ============================================================
|
||
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import 'package:xianyan/l10n/translations.dart';
|
||
import 'package:xianyan/core/theme/app_theme.dart';
|
||
import 'package:xianyan/core/theme/app_typography.dart';
|
||
import 'package:xianyan/core/theme/app_radius.dart';
|
||
import 'package:xianyan/shared/widgets/input/app_popup_menu.dart';
|
||
import '../../../models/tool_item.dart';
|
||
|
||
class ToolGridItem extends ConsumerStatefulWidget {
|
||
const ToolGridItem({
|
||
super.key,
|
||
required this.tool,
|
||
this.onTap,
|
||
this.onLongPress,
|
||
this.onFavorite,
|
||
this.onPin,
|
||
this.onShare,
|
||
this.showBadge = false,
|
||
this.size = 48.0,
|
||
});
|
||
|
||
final ToolItem tool;
|
||
final VoidCallback? onTap;
|
||
final VoidCallback? onLongPress;
|
||
final VoidCallback? onFavorite;
|
||
final VoidCallback? onPin;
|
||
final VoidCallback? onShare;
|
||
final bool showBadge;
|
||
final double size;
|
||
|
||
@override
|
||
ConsumerState<ToolGridItem> createState() => _ToolGridItemState();
|
||
}
|
||
|
||
class _ToolGridItemState extends ConsumerState<ToolGridItem>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _scaleController;
|
||
late final Animation<double> _scaleAnimation;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_scaleController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 100),
|
||
);
|
||
_scaleAnimation = Tween<double>(
|
||
begin: 1.0,
|
||
end: 0.92,
|
||
).animate(CurvedAnimation(parent: _scaleController, curve: Curves.easeOut));
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_scaleController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onTapDown(TapDownDetails _) => _scaleController.forward();
|
||
void _onTapUp(TapUpDetails _) => _scaleController.reverse();
|
||
void _onTapCancel() => _scaleController.reverse();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final ext = AppTheme.ext(context);
|
||
|
||
return AppPopupMenu(
|
||
items: [
|
||
PopupMenuItemConfig(
|
||
type: PopupMenuItemType.favorite,
|
||
title: widget.tool.isFavorited ? ref.watch(translationsProvider).discover.cancelFavorite : ref.watch(translationsProvider).discover.favoriteAction,
|
||
icon: widget.tool.isFavorited
|
||
? CupertinoIcons.heart_fill
|
||
: CupertinoIcons.heart,
|
||
onSelected: widget.onFavorite,
|
||
),
|
||
PopupMenuItemConfig(
|
||
type: PopupMenuItemType.pin,
|
||
title: widget.tool.isPinned ? ref.watch(translationsProvider).discover.cancelPin : ref.watch(translationsProvider).discover.pinToTop,
|
||
icon: widget.tool.isPinned
|
||
? CupertinoIcons.pin_slash
|
||
: CupertinoIcons.pin,
|
||
onSelected: widget.onPin,
|
||
),
|
||
PopupMenuItemConfig(
|
||
type: PopupMenuItemType.share,
|
||
title: ref.watch(translationsProvider).discover.shareAction,
|
||
onSelected: widget.onShare,
|
||
),
|
||
],
|
||
buttonBuilder: (context, showMenu) => Semantics(
|
||
label: '${widget.tool.name} ${ref.watch(translationsProvider).discover.toolCenter}',
|
||
hint: widget.tool.description ?? '${ref.watch(translationsProvider).discover.tapToOpen},${ref.watch(translationsProvider).discover.longPressForMore}',
|
||
button: true,
|
||
child: GestureDetector(
|
||
onTapDown: _onTapDown,
|
||
onTapUp: _onTapUp,
|
||
onTapCancel: _onTapCancel,
|
||
onTap: widget.onTap,
|
||
onLongPress: showMenu,
|
||
child: ScaleTransition(
|
||
scale: _scaleAnimation,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
Container(
|
||
width: widget.size,
|
||
height: widget.size,
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
colors: widget.tool.gradientColors,
|
||
),
|
||
borderRadius: BorderRadius.circular(widget.size * 0.29),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: widget.tool.gradientColors.first.withValues(
|
||
alpha: widget.tool.status == ToolStatus.available ? 0.3 : 0.1,
|
||
),
|
||
blurRadius: 8,
|
||
offset: const Offset(0, 3),
|
||
),
|
||
],
|
||
),
|
||
child: Opacity(
|
||
opacity: widget.tool.status == ToolStatus.available ? 1.0 : 0.5,
|
||
child: Center(
|
||
child: Text(
|
||
widget.tool.emoji,
|
||
style: TextStyle(fontSize: widget.size * 0.42),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (widget.showBadge || widget.tool.isNew)
|
||
Positioned(
|
||
top: -3,
|
||
right: -3,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 4,
|
||
vertical: 1,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: CupertinoColors.systemRed,
|
||
borderRadius: AppRadius.smBorder,
|
||
),
|
||
child: Text(
|
||
ref.watch(translationsProvider).discover.newBadge,
|
||
style: const TextStyle(
|
||
fontSize: 8,
|
||
color: CupertinoColors.white,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (widget.tool.isPinned)
|
||
Positioned(
|
||
bottom: -2,
|
||
right: -2,
|
||
child: Container(
|
||
width: 14,
|
||
height: 14,
|
||
decoration: const BoxDecoration(
|
||
color: CupertinoColors.activeOrange,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(
|
||
CupertinoIcons.pin_fill,
|
||
size: 8,
|
||
color: CupertinoColors.white,
|
||
),
|
||
),
|
||
),
|
||
if (widget.tool.status == ToolStatus.offline)
|
||
Positioned(
|
||
bottom: -2,
|
||
left: -2,
|
||
child: Container(
|
||
width: 10,
|
||
height: 10,
|
||
decoration: const BoxDecoration(
|
||
color: CupertinoColors.systemGrey,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(
|
||
CupertinoIcons.wifi_slash,
|
||
size: 6,
|
||
color: CupertinoColors.white,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
child: Text(
|
||
widget.tool.name,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: widget.tool.status == ToolStatus.available
|
||
? ext.textPrimary
|
||
: ext.textHint,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
maxLines: 1,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|