a05f9bd4ec
Deploy / deploy (push) Failing after 8s
- shops 表新增 logo_url 字段,AutoMigrate 自动建列 - 后端新增 POST /api/v1/shop/logo 接口(管理员),图片裁剪为 256×256 JPEG 存储 - UpdateInfo 支持传入 logo_url - 侧边栏「岩美」替换为门店真实名称 + 自定义 logo - 无 logo 时显示店名首字文字头像(深蓝底白字) - 设置页「酒行信息」展示 logo 预览,编辑弹窗新增「更换 Logo」上传按钮 - 修复库存导入计数逻辑:total/imported/updated/errors 四项分别统计 - 库存导入去重改为双路索引(编号 + 名称|系列|规格),避免 key 不一致误判新增 - settings 页导入结果统一显示「重复 X 条」,兼容 skipped 和 updated 两个字段 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
752 lines
27 KiB
Dart
752 lines
27 KiB
Dart
import '../../core/utils/dialog_util.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:intl/intl.dart';
|
||
import 'dart:async';
|
||
import '../../core/auth/auth_state.dart';
|
||
import '../../core/config/app_config.dart';
|
||
import '../../core/theme/app_theme.dart';
|
||
import '../../providers/connectivity_provider.dart';
|
||
import '../../providers/shop_provider.dart';
|
||
import '../../providers/update_provider.dart';
|
||
|
||
class AppShell extends ConsumerStatefulWidget {
|
||
final Widget child;
|
||
const AppShell({super.key, required this.child});
|
||
|
||
@override
|
||
ConsumerState<AppShell> createState() => _AppShellState();
|
||
}
|
||
|
||
class _AppShellState extends ConsumerState<AppShell> {
|
||
bool _sidebarExpanded = true;
|
||
final String _loginTime = DateFormat('HH:mm:ss').format(DateTime.now());
|
||
bool _forceDialogShown = false;
|
||
|
||
void _showForceUpdateDialog(
|
||
BuildContext context, AppUpdateInfo info) {
|
||
if (_forceDialogShown) return;
|
||
_forceDialogShown = true;
|
||
showAppDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (ctx) => PopScope(
|
||
canPop: false,
|
||
child: AlertDialog(
|
||
title: const Row(
|
||
children: [
|
||
Icon(Icons.system_update, color: AppTheme.primary),
|
||
SizedBox(width: 8),
|
||
Text('发现新版本'),
|
||
],
|
||
),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('当前版本需要更新至 v${info.latestVersion} 才能继续使用。'),
|
||
if (info.releaseNotes.isNotEmpty) ...[
|
||
const SizedBox(height: 12),
|
||
Text(info.releaseNotes,
|
||
style: const TextStyle(
|
||
color: AppTheme.textSecondary, fontSize: 13)),
|
||
],
|
||
],
|
||
),
|
||
actions: [
|
||
ElevatedButton(
|
||
onPressed: () => launchUpdateUrl(info.downloadUrls),
|
||
child: const Text('立即更新'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
final List<_NavItem> _navItems = const [
|
||
_NavItem(icon: Icons.input, label: '入库管理', path: '/stock-in'),
|
||
_NavItem(icon: Icons.output, label: '出库管理', path: '/stock-out'),
|
||
_NavItem(icon: Icons.inventory_2, label: '库存管理', path: '/inventory'),
|
||
_NavItem(
|
||
icon: Icons.account_balance_wallet,
|
||
label: '财务管理',
|
||
path: '/finance'),
|
||
_NavItem(icon: Icons.people, label: '往来单位', path: '/partners'),
|
||
_NavItem(icon: Icons.category, label: '基础数据', path: '/products'),
|
||
_NavItem(icon: Icons.settings, label: '系统设置', path: '/settings'),
|
||
];
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final user = ref.watch(authStateProvider).user;
|
||
final isOnline = ref.watch(connectivityProvider);
|
||
final location = GoRouterState.of(context).matchedLocation;
|
||
final sidebarWidth = _sidebarExpanded ? 200.0 : 56.0;
|
||
final updateNotifier = ref.watch(updateProvider.notifier);
|
||
final updateInfo = ref.watch(updateProvider).valueOrNull;
|
||
final appVersion =
|
||
ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
|
||
|
||
return SelectionArea(
|
||
child: Scaffold(
|
||
body: Column(
|
||
children: [
|
||
// Top Bar
|
||
Container(
|
||
height: 56,
|
||
color: AppTheme.primary,
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
child: Row(
|
||
children: [
|
||
IconButton(
|
||
icon: Icon(
|
||
_sidebarExpanded ? Icons.menu_open : Icons.menu,
|
||
color: Colors.white),
|
||
onPressed: () =>
|
||
setState(() => _sidebarExpanded = !_sidebarExpanded),
|
||
tooltip: _sidebarExpanded ? '收起侧边栏' : '展开侧边栏',
|
||
),
|
||
const SizedBox(width: 4),
|
||
_ShopButton(user: user, version: appVersion),
|
||
const Spacer(),
|
||
if (user != null) ...[
|
||
MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
child: GestureDetector(
|
||
onTap: () => _showShopPanel(context, user, version: appVersion),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.business,
|
||
color: Colors.white70, size: 14),
|
||
const SizedBox(width: 4),
|
||
Text(user.shopNo,
|
||
style: const TextStyle(
|
||
color: Colors.white70, fontSize: 13)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 20),
|
||
const Icon(Icons.person_outline,
|
||
color: Colors.white70, size: 14),
|
||
const SizedBox(width: 4),
|
||
Text(user.username,
|
||
style: const TextStyle(
|
||
color: Colors.white70, fontSize: 13)),
|
||
const SizedBox(width: 8),
|
||
PopupMenuButton<String>(
|
||
icon: const Icon(Icons.keyboard_arrow_down,
|
||
color: Colors.white70),
|
||
offset: const Offset(0, 36),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(6)),
|
||
color: Colors.white,
|
||
elevation: 8,
|
||
onSelected: (v) {
|
||
if (v == 'logout') {
|
||
ref.read(authStateProvider.notifier).logout();
|
||
context.go('/login');
|
||
}
|
||
},
|
||
itemBuilder: (context) => [
|
||
const PopupMenuItem<String>(
|
||
value: 'profile',
|
||
padding: EdgeInsets.zero,
|
||
child: _HoverMenuItem(
|
||
icon: Icons.manage_accounts_outlined,
|
||
label: '个人设置',
|
||
),
|
||
),
|
||
const PopupMenuDivider(height: 1),
|
||
const PopupMenuItem<String>(
|
||
value: 'logout',
|
||
padding: EdgeInsets.zero,
|
||
child: _HoverMenuItem(
|
||
icon: Icons.logout_outlined,
|
||
label: '退出登录',
|
||
danger: true,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
// Main area
|
||
Expanded(
|
||
child: Row(
|
||
children: [
|
||
// Sidebar
|
||
AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeInOut,
|
||
width: sidebarWidth,
|
||
color: AppTheme.primaryDark,
|
||
child: Column(
|
||
children: [
|
||
Expanded(
|
||
child: ListView(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
children: _navItems.map((item) {
|
||
final isActive =
|
||
location.startsWith(item.path);
|
||
return _SidebarItem(
|
||
item: item,
|
||
isActive: isActive,
|
||
expanded: _sidebarExpanded,
|
||
onTap: () => context.go(item.path),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Content area
|
||
Expanded(
|
||
child: Column(
|
||
children: [
|
||
// Update banner(非强制更新)
|
||
if (updateInfo != null &&
|
||
updateInfo.hasUpdate &&
|
||
!updateInfo.forceUpdate &&
|
||
!updateNotifier.isDismissed)
|
||
Container(
|
||
width: double.infinity,
|
||
color: const Color(0xFFFFF8E1),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16, vertical: 6),
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.system_update,
|
||
size: 16, color: Color(0xFFF57F17)),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'发现新版本 v${updateInfo.latestVersion}'
|
||
'${updateInfo.releaseNotes.isNotEmpty ? " · ${updateInfo.releaseNotes}" : ""}',
|
||
style: const TextStyle(
|
||
color: Color(0xFF5D4037),
|
||
fontSize: 13),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: () => launchUpdateUrl(
|
||
updateInfo.downloadUrls),
|
||
style: TextButton.styleFrom(
|
||
foregroundColor:
|
||
const Color(0xFFF57F17)),
|
||
child: const Text('立即更新'),
|
||
),
|
||
TextButton(
|
||
onPressed: updateNotifier.dismiss,
|
||
style: TextButton.styleFrom(
|
||
foregroundColor:
|
||
const Color(0xFF9E9E9E)),
|
||
child: const Text('稍后再说'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// 强制更新 dialog(用 postFrameCallback 避免 build 中 showDialog)
|
||
if (updateInfo != null &&
|
||
updateInfo.hasUpdate &&
|
||
updateInfo.forceUpdate)
|
||
Builder(builder: (ctx) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_showForceUpdateDialog(ctx, updateInfo);
|
||
});
|
||
return const SizedBox.shrink();
|
||
}),
|
||
// Offline banner
|
||
if (!isOnline)
|
||
Container(
|
||
width: double.infinity,
|
||
color: AppTheme.danger,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16, vertical: 6),
|
||
child: const Row(
|
||
children: [
|
||
Icon(Icons.wifi_off,
|
||
size: 16, color: Colors.white),
|
||
SizedBox(width: 8),
|
||
Text(
|
||
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(child: widget.child),
|
||
// Status bar
|
||
LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final w = constraints.maxWidth;
|
||
// Three tiers: wide / medium / narrow
|
||
final wide = w >= 650;
|
||
final medium = w >= 190;
|
||
final iconOnly = !medium;
|
||
|
||
return Container(
|
||
height: 28,
|
||
color: isOnline
|
||
? const Color(0xFF37474F)
|
||
: AppTheme.danger,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
child: Row(
|
||
children: [
|
||
if (!isOnline) ...[
|
||
const Icon(Icons.wifi_off,
|
||
size: 11, color: Colors.white70),
|
||
if (!iconOnly) ...[
|
||
const SizedBox(width: 4),
|
||
const Text('离线',
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600)),
|
||
],
|
||
const _StatusDivider(),
|
||
],
|
||
if (isOnline && user != null) ...[
|
||
_StatusItem(
|
||
icon: Icons.store,
|
||
text: user.shopNo,
|
||
iconOnly: iconOnly),
|
||
const _StatusDivider(),
|
||
_StatusItem(
|
||
icon: Icons.person,
|
||
text: user.username,
|
||
iconOnly: iconOnly),
|
||
if (wide) ...[
|
||
const _StatusDivider(),
|
||
_StatusItem(
|
||
icon: Icons.login,
|
||
text: '登录时间:$_loginTime'),
|
||
const _StatusDivider(),
|
||
const _ClockWidget(),
|
||
] else
|
||
const _StatusDivider(),
|
||
],
|
||
const Spacer(),
|
||
_StatusItem(
|
||
icon: isOnline
|
||
? Icons.cloud_done_outlined
|
||
: Icons.cloud_off_outlined,
|
||
text: isOnline ? '已连接' : '连接已断开',
|
||
iconOnly: iconOnly,
|
||
),
|
||
const _StatusDivider(),
|
||
_StatusItem(
|
||
icon: Icons.info_outline,
|
||
text: ref
|
||
.watch(appVersionProvider)
|
||
.valueOrNull ??
|
||
'v1.0.0',
|
||
iconOnly: iconOnly),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _NavItem {
|
||
final IconData icon;
|
||
final String label;
|
||
final String path;
|
||
const _NavItem(
|
||
{required this.icon, required this.label, required this.path});
|
||
}
|
||
|
||
class _SidebarItem extends StatelessWidget {
|
||
final _NavItem item;
|
||
final bool isActive;
|
||
final bool expanded;
|
||
final VoidCallback onTap;
|
||
|
||
const _SidebarItem({
|
||
required this.item,
|
||
required this.isActive,
|
||
required this.expanded,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox(
|
||
height: 48,
|
||
child: Material(
|
||
color: isActive ? AppTheme.primary.withAlpha(76) : Colors.transparent,
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
hoverColor: Colors.white.withAlpha(13),
|
||
splashColor: Colors.white.withAlpha(26),
|
||
highlightColor: Colors.white.withAlpha(13),
|
||
child: Row(
|
||
children: [
|
||
// Active indicator bar
|
||
Container(
|
||
width: 3,
|
||
height: isActive ? 28 : 0,
|
||
color: Colors.white,
|
||
margin: EdgeInsets.only(right: expanded ? 13 : 0),
|
||
),
|
||
if (!isActive) SizedBox(width: expanded ? 16 : 3),
|
||
Expanded(
|
||
child: Row(
|
||
mainAxisAlignment: expanded
|
||
? MainAxisAlignment.start
|
||
: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
item.icon,
|
||
color: isActive ? Colors.white : Colors.white60,
|
||
size: 20,
|
||
),
|
||
if (expanded) ...[
|
||
const SizedBox(width: 12),
|
||
Flexible(
|
||
child: Text(
|
||
item.label,
|
||
style: TextStyle(
|
||
color: isActive ? Colors.white : Colors.white70,
|
||
fontSize: 14,
|
||
fontWeight: isActive
|
||
? FontWeight.w500
|
||
: FontWeight.normal,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StatusItem extends StatelessWidget {
|
||
final IconData icon;
|
||
final String text;
|
||
final bool iconOnly;
|
||
const _StatusItem(
|
||
{required this.icon, required this.text, this.iconOnly = false});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 11, color: Colors.white54),
|
||
if (!iconOnly) ...[
|
||
const SizedBox(width: 4),
|
||
Text(text,
|
||
style: const TextStyle(color: Colors.white54, fontSize: 11)),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StatusDivider extends StatelessWidget {
|
||
const _StatusDivider();
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
width: 1,
|
||
height: 12,
|
||
color: Colors.white24,
|
||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ClockWidget extends StatefulWidget {
|
||
const _ClockWidget();
|
||
@override
|
||
State<_ClockWidget> createState() => _ClockWidgetState();
|
||
}
|
||
|
||
class _ClockWidgetState extends State<_ClockWidget> {
|
||
late Timer _timer;
|
||
late String _time;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_time = DateFormat('HH:mm:ss').format(DateTime.now());
|
||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||
if (mounted) setState(() => _time = DateFormat('HH:mm:ss').format(DateTime.now()));
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_timer.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return _StatusItem(icon: Icons.access_time, text: '当前时间:$_time');
|
||
}
|
||
}
|
||
|
||
void _showShopPanel(BuildContext context, AuthUser u, {String version = 'v1.0.0'}) {
|
||
showAppDialog(
|
||
context: context,
|
||
builder: (ctx) => Dialog(
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||
child: SizedBox(
|
||
width: 360,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
// Header
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||
decoration: const BoxDecoration(
|
||
color: AppTheme.primary,
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(10)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.store, color: Colors.white, size: 20),
|
||
const SizedBox(width: 10),
|
||
const Text('门店信息',
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600)),
|
||
const Spacer(),
|
||
IconButton(
|
||
onPressed: () => Navigator.pop(ctx),
|
||
icon: const Icon(Icons.close, color: Colors.white70, size: 18),
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Info rows
|
||
Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
children: [
|
||
_InfoRow(icon: Icons.tag, label: '门店编号', value: u.shopNo),
|
||
const SizedBox(height: 14),
|
||
_InfoRow(icon: Icons.person, label: '登录账号', value: u.username),
|
||
const SizedBox(height: 14),
|
||
_InfoRow(icon: Icons.badge_outlined, label: '姓名', value: u.realName),
|
||
const SizedBox(height: 14),
|
||
_InfoRow(icon: Icons.info_outline, label: '系统版本', value: version),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
class _ShopButton extends ConsumerWidget {
|
||
final AuthUser? user;
|
||
final String version;
|
||
const _ShopButton({this.user, this.version = 'v1.0.0'});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final shopAsync = ref.watch(shopInfoProvider);
|
||
final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? '';
|
||
final logoUrl = shopAsync.valueOrNull?.logoUrl ?? '';
|
||
|
||
return MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
if (user != null) _showShopPanel(context, user!, version: version);
|
||
},
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_ShopLogo(logoUrl: logoUrl, shopName: shopName, size: 28),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
shopName.isEmpty ? '—' : shopName,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
letterSpacing: 0.5),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 门店 Logo:有图片显示网络图片,无图片显示店名首字文字头像。
|
||
class _ShopLogo extends StatelessWidget {
|
||
final String logoUrl;
|
||
final String shopName;
|
||
final double size;
|
||
const _ShopLogo({required this.logoUrl, required this.shopName, this.size = 32});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final radius = BorderRadius.circular(size * 0.19);
|
||
if (logoUrl.isNotEmpty) {
|
||
final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
|
||
return ClipRRect(
|
||
borderRadius: radius,
|
||
child: Image.network(
|
||
fullUrl,
|
||
width: size,
|
||
height: size,
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, __, ___) => _initial(radius),
|
||
),
|
||
);
|
||
}
|
||
return _initial(radius);
|
||
}
|
||
|
||
Widget _initial(BorderRadius radius) {
|
||
final initial = shopName.isNotEmpty ? shopName.characters.first : '店';
|
||
return Container(
|
||
width: size,
|
||
height: size,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF0F3057),
|
||
borderRadius: radius,
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
initial,
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: size * 0.5,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _InfoRow extends StatelessWidget {
|
||
final IconData icon;
|
||
final String label;
|
||
final String value;
|
||
const _InfoRow({required this.icon, required this.label, required this.value});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(
|
||
children: [
|
||
Icon(icon, size: 16, color: AppTheme.textSecondary),
|
||
const SizedBox(width: 10),
|
||
SizedBox(
|
||
width: 72,
|
||
child: Text(label,
|
||
style: const TextStyle(
|
||
fontSize: 13, color: AppTheme.textSecondary)),
|
||
),
|
||
Expanded(
|
||
child: Text(value,
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: AppTheme.textPrimary,
|
||
fontWeight: FontWeight.w500)),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _HoverMenuItem extends StatefulWidget {
|
||
final IconData icon;
|
||
final String label;
|
||
final bool danger;
|
||
|
||
const _HoverMenuItem({
|
||
required this.icon,
|
||
required this.label,
|
||
this.danger = false,
|
||
});
|
||
|
||
@override
|
||
State<_HoverMenuItem> createState() => _HoverMenuItemState();
|
||
}
|
||
|
||
class _HoverMenuItemState extends State<_HoverMenuItem> {
|
||
bool _hovered = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final color = widget.danger
|
||
? const Color(0xFFE53935)
|
||
: const Color(0xFF333333);
|
||
final hoverBg = widget.danger
|
||
? const Color(0xFFFFF0F0)
|
||
: const Color(0xFFF0F4FF);
|
||
|
||
return MouseRegion(
|
||
onEnter: (_) => setState(() => _hovered = true),
|
||
onExit: (_) => setState(() => _hovered = false),
|
||
cursor: SystemMouseCursors.click,
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 120),
|
||
width: 152,
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: _hovered ? hoverBg : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(widget.icon, size: 16, color: color),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
widget.label,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: color,
|
||
fontWeight: FontWeight.w400,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|