Files
jiu/client/lib/screens/shell/app_shell.dart
T
wangjia 1b43dfc75d refactor(client): Phase 1 照原型重建 app 外壳(顶栏两级品牌 + 主题器 + 通知铃 + 分组侧栏 + 状态栏)
照 shell.js/statusbar.js/atoms.css 把外壳从「旧单品牌+换色」彻底重建为原型样式:
- 顶栏:岩美酒库(线描山形 mark CustomPaint,软件品牌) │分隔│ 鼎晟酒行(门店字标+店名);
  右侧 ThemePickerPill(主题器,从系统设置迁来,A/B/C 切换) + NotificationBell(通知)
- 侧栏(218w):分组 单据/经营/系统(nav-label) + nav(active 高亮) + 底部 side-user(头像/姓名/店·角色→菜单)
- 状态栏:AppStatusBar(原型 .statusbar,AppChrome 主题无关色):店号/操作员/登录/当前(走字) · 连接/版本/授权
- 保留全部既有行为:更新/强制更新/授权横幅与弹窗、离线横幅、心跳、apiMessage、窄屏 Drawer
- 新增 widgets/theme_picker_pill.dart · notification_bell.dart · app_status_bar.dart
- golden:加载真实 MaterialIcons 字体(消图标豆腐块,全 golden 受益);
  app_shell golden 用 9 分支+冻结时钟(可测试缝)确定化,对齐原型数据(鼎晟酒行/王经理/库存激活)
- fidelity:外壳 vs 原型 diff 3.5% ≤ 阈,montage 目检 chrome 与原型一致

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
2026-06-25 18:02:38 +08:00

918 lines
31 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 'package:flutter/foundation.dart' show kIsWeb;
import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart';
import '../../core/config/app_config.dart';
import '../../core/config/license_copy.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/context_tokens.dart';
import '../../providers/connectivity_provider.dart';
import '../../providers/session_heartbeat.dart';
import '../../providers/shop_provider.dart';
import '../../providers/update_provider.dart';
import '../../core/update/app_updater.dart';
import '../../providers/license_provider.dart';
import '../../widgets/network_retry_button.dart';
import '../../widgets/theme_picker_pill.dart';
import '../../widgets/notification_bell.dart';
import '../../widgets/app_status_bar.dart';
/// 侧栏导航项(含其 StatefulShellRoute 分支序号 = router 中的 branch index)。
class _NavItem {
final IconData icon;
final String label;
final int index;
const _NavItem(this.icon, this.label, this.index);
}
/// 侧栏分组(原型 nav-label:单据 / 经营 / 系统)。
class _NavGroup {
final String label;
final List<_NavItem> items;
const _NavGroup(this.label, this.items);
}
// 分支序号必须与 router StatefulShellRoute 的 branch 顺序一致(勿改顺序)。
const _navGroups = <_NavGroup>[
_NavGroup('单据', [
_NavItem(Icons.file_download_outlined, '入库管理', 0),
_NavItem(Icons.file_upload_outlined, '出库管理', 1),
]),
_NavGroup('经营', [
_NavItem(Icons.inventory_2_outlined, '库存管理', 2),
_NavItem(Icons.receipt_long_outlined, '财务管理', 3),
_NavItem(Icons.people_alt_outlined, '往来单位', 4),
_NavItem(Icons.grid_view_outlined, '基础数据', 5),
]),
_NavGroup('系统', [
_NavItem(Icons.devices_outlined, '设备管理', 6),
_NavItem(Icons.settings_outlined, '系统设置', 7),
_NavItem(Icons.info_outline, '关于我们', 8),
]),
];
String _roleLabel(String role) {
switch (role) {
case 'superadmin':
return '超级管理员';
case 'admin':
return '管理员';
case 'readonly':
return '只读';
default:
return '操作员';
}
}
class AppShell extends ConsumerStatefulWidget {
/// StatefulShellRoute 注入的导航壳:各栏目分支 Navigator 的 IndexedStack
/// 跨栏目切换时各分支页面 State 常驻(保住半填表单 / 内部 tab / 滚动位置)。
final StatefulNavigationShell navigationShell;
const AppShell({super.key, required this.navigationShell});
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
final String _loginTime =
DateFormat('HH:mm:ss').format(AppStatusBar.clock());
bool _forceDialogShown = false;
bool _licenseDialogShown = false;
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
/// 切换到第 index 个分支;再次点当前栏目则回到该分支初始页。
void _goBranch(int index) {
widget.navigationShell.goBranch(
index,
initialLocation: index == widget.navigationShell.currentIndex,
);
}
void _logout() {
ref.read(authStateProvider.notifier).logout();
context.go('/login');
}
@override
Widget build(BuildContext context) {
final user = ref.watch(authStateProvider).user;
// 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线
ref.watch(sessionHeartbeatProvider);
// 全局轻提示(写请求被后端 403 拒绝等):统一在此弹出并清空。
ref.listen<String?>(apiMessageProvider, (prev, next) {
if (next == null || next.isEmpty) return;
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(next)));
ref.read(apiMessageProvider.notifier).state = null;
});
final isOnline = ref.watch(connectivityProvider);
final isMobile = context.isMobile;
final topInset = MediaQuery.of(context).padding.top;
final updateNotifier = ref.watch(updateProvider.notifier);
final updateInfo = ref.watch(updateProvider).valueOrNull;
final licenseInfo = ref.watch(licenseProvider).valueOrNull;
return SelectionArea(
child: Scaffold(
key: _scaffoldKey,
drawer: isMobile ? _buildDrawer(context, user) : null,
drawerEnableOpenDragGesture: !kIsWeb && isMobile,
body: Column(
children: [
_buildTopBar(context, user, isMobile, topInset),
Expanded(
child: Row(
children: [
if (!isMobile) _buildSidebar(context, user),
Expanded(
child: Column(
children: [
..._buildBanners(
context, isOnline, updateInfo, updateNotifier, licenseInfo),
Expanded(child: widget.navigationShell),
if (!isMobile) AppStatusBar(loginTime: _loginTime),
],
),
),
],
),
),
],
),
),
);
}
// ── 顶栏(原型 .top, 56h):左两级品牌;右 主题器 + 通知铃 ───────────────────
Widget _buildTopBar(
BuildContext context, AuthUser? user, bool isMobile, double topInset) {
final t = context.tokens;
return Container(
height: 56 + topInset,
decoration: BoxDecoration(
color: t.topBg,
border: Border(bottom: BorderSide(color: t.topBorder)),
),
padding: EdgeInsets.only(top: topInset, left: isMobile ? 6 : 18, right: 14),
child: Row(
children: [
if (isMobile) ...[
IconButton(
icon: Icon(Icons.menu, color: t.topFg),
tooltip: '菜单',
onPressed: () => _scaffoldKey.currentState?.openDrawer(),
),
const SizedBox(width: 2),
],
// 软件品牌(岩美酒库)—— 桌面常显,窄屏让位给门店
if (!isMobile) ...[
const _SoftwareBrand(),
Container(
width: 1,
height: 20,
color: t.topBorder,
margin: const EdgeInsets.symmetric(horizontal: 8),
),
],
// 门店品牌(鼎晟酒行)——点开门店信息
Flexible(child: _ShopBrand(user: user)),
if (WriteGuard.isReadonly(ref)) ...[
const SizedBox(width: 10),
_readonlyBadge(t.topControl),
],
const Spacer(),
const ThemePickerPill(),
const SizedBox(width: 14),
const NotificationBell(),
],
),
);
}
Widget _readonlyBadge(Color bg) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.visibility_outlined, size: 13, color: Colors.white),
SizedBox(width: 4),
Text('只读',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600)),
],
),
);
}
// ── 侧栏(原型 .side, 218w):分组导航 + 底部 side-user ─────────────────────
Widget _buildSidebar(BuildContext context, AuthUser? user) {
final t = context.tokens;
return Container(
width: 218,
decoration: BoxDecoration(
color: t.sideBg,
border: Border(right: BorderSide(color: t.sideBorder)),
),
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Column(
children: [
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
for (final g in _navGroups) ...[
_navLabel(t, g.label),
for (final item in g.items)
_SidebarNav(
item: item,
active: widget.navigationShell.currentIndex == item.index,
onTap: () => _goBranch(item.index),
),
],
],
),
),
if (user != null) _buildSideUser(context, user),
],
),
);
}
Widget _navLabel(dynamic t, String label) => Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 6),
child: Text(
label,
style: TextStyle(
fontSize: 11,
color: t.sideMuted,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
),
),
);
Widget _buildSideUser(BuildContext context, AuthUser user) {
final t = context.tokens;
final initial = (user.realName.isNotEmpty ? user.realName : user.username);
final pic = initial.isNotEmpty ? initial.characters.first : '';
final shopName = ref.watch(shopInfoProvider).valueOrNull?.name ?? user.shopNo;
return Column(
children: [
Container(
height: 1,
color: t.sideBorder,
margin: const EdgeInsets.fromLTRB(4, 8, 4, 8),
),
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: PopupMenuButton<String>(
tooltip: '账号菜单',
position: PopupMenuPosition.over,
offset: const Offset(0, -8),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
color: t.menuUserBg,
elevation: 8,
onSelected: (v) {
if (v == 'logout') _logout();
},
itemBuilder: (_) => [
_userMenuItem('profile', Icons.manage_accounts_outlined, '个人设置', t),
_userMenuItem('logout', Icons.logout, '退出登录', t),
],
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: t.sideActiveBg,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(pic,
style: TextStyle(
color: t.sideActiveFg,
fontWeight: FontWeight.w700,
fontSize: 14)),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
user.realName.isNotEmpty
? user.realName
: user.username,
style: TextStyle(
color: t.sideActiveFg,
fontSize: 13,
fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
Text(
'$shopName · ${_roleLabel(user.role)}',
style: TextStyle(color: t.sideMuted, fontSize: 11),
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(Icons.keyboard_arrow_down, size: 14, color: t.sideMuted),
],
),
),
),
),
],
);
}
PopupMenuItem<String> _userMenuItem(
String value, IconData icon, String label, dynamic t) {
return PopupMenuItem<String>(
value: value,
height: 40,
child: Row(
children: [
Icon(icon, size: 16, color: t.menuUserFg),
const SizedBox(width: 9),
Text(label, style: TextStyle(fontSize: 13, color: t.menuUserFg)),
],
),
);
}
/// 窄屏(手机)侧滑抽屉导航:与侧栏同分组结构。
Widget _buildDrawer(BuildContext context, AuthUser? user) {
final t = context.tokens;
return Drawer(
child: Container(
color: t.sideBg,
child: SafeArea(
child: Column(
children: [
if (user != null)
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
color: t.topBg,
child: Row(
children: [
const _SoftwareBrandMark(size: 30),
const SizedBox(width: 8),
const Text('岩美酒库',
style: TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w700)),
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
for (final g in _navGroups) ...[
_navLabel(t, g.label),
for (final item in g.items)
_SidebarNav(
item: item,
active:
widget.navigationShell.currentIndex == item.index,
onTap: () {
Navigator.pop(context);
_goBranch(item.index);
},
),
],
],
),
),
if (user != null) ...[
Container(
height: 1,
color: t.sideBorder,
margin: const EdgeInsets.symmetric(horizontal: 16)),
ListTile(
leading: Icon(Icons.logout, color: t.sideFg),
title: Text('退出登录',
style: TextStyle(
color: t.sideActiveFg,
fontWeight: FontWeight.w500)),
onTap: () {
Navigator.pop(context);
_logout();
},
),
],
],
),
),
),
);
}
// ── 顶部横幅(更新 / 强制更新 / 授权 / 离线):保持原有行为 ───────────────────
List<Widget> _buildBanners(
BuildContext context,
bool isOnline,
AppUpdateInfo? updateInfo,
UpdateNotifier updateNotifier,
LicenseInfo? licenseInfo,
) {
final t = context.tokens;
return [
if (updateInfo != null &&
updateInfo.hasUpdate &&
!updateInfo.forceUpdate &&
!updateNotifier.isDismissed)
Container(
width: double.infinity,
color: t.warnBg,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
Icon(Icons.system_update, size: 16, color: t.warn),
const SizedBox(width: 8),
Expanded(
child: Text(
'发现新版本 v${updateInfo.latestVersion},建议立即更新以获得最新功能与修复',
style: TextStyle(color: t.text, fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: () => startInAppUpdate(context, updateInfo),
style: TextButton.styleFrom(foregroundColor: t.warn),
child: const Text('立即更新'),
),
TextButton(
onPressed: updateNotifier.dismiss,
style: TextButton.styleFrom(foregroundColor: t.muted),
child: const Text('稍后再说'),
),
],
),
),
if (updateInfo != null && updateInfo.hasUpdate && updateInfo.forceUpdate)
Builder(builder: (ctx) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_showForceUpdateDialog(ctx, updateInfo);
});
return const SizedBox.shrink();
}),
if (licenseInfo != null && licenseInfo.needsAttention)
_buildLicenseBanner(licenseInfo),
if (licenseInfo != null &&
licenseInfo.needsAttention &&
!_licenseDialogShown)
Builder(builder: (ctx) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_licenseDialogShown && mounted) {
_licenseDialogShown = true;
_showLicenseExpiryDialog(ctx, licenseInfo);
}
});
return const SizedBox.shrink();
}),
if (!isOnline)
Container(
width: double.infinity,
color: t.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),
Expanded(
child: Text(
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500),
),
),
NetworkRetryButton(),
],
),
),
];
}
void _showForceUpdateDialog(BuildContext context, AppUpdateInfo info) {
if (_forceDialogShown) return;
_forceDialogShown = true;
showAppDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => PopScope(
canPop: false,
child: AlertDialog(
title: Row(
children: [
Icon(Icons.system_update, color: context.tokens.primary),
const SizedBox(width: 8),
const Text('发现新版本'),
],
),
content: Text('发现新版本 v${info.latestVersion},需更新后才能继续使用,'
'更新以获得最新功能与修复。'),
actions: [
ElevatedButton(
onPressed: () => startInAppUpdate(context, info),
child: const Text('立即更新'),
),
],
),
),
);
}
Widget _buildLicenseBanner(LicenseInfo lic) {
final bg = LicenseCopy.bannerColor(lic.phase);
final msg = LicenseCopy.banner(lic);
return Container(
width: double.infinity,
color: bg,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, size: 16, color: Colors.white),
const SizedBox(width: 8),
Expanded(
child: Text(msg,
style: const TextStyle(color: Colors.white, fontSize: 13),
overflow: TextOverflow.ellipsis),
),
TextButton(
onPressed: () => context.go('/settings?tab=license'),
style: TextButton.styleFrom(foregroundColor: Colors.white70),
child: const Text('去激活'),
),
],
),
);
}
void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) {
final (title, body) = LicenseCopy.dialog(lic);
final Color titleColor =
lic.phase == 'grace' ? Colors.orange[800]! : ctx.tokens.danger;
showDialog(
context: ctx,
barrierDismissible: true,
builder: (_) => AlertDialog(
title: Row(children: [
Icon(Icons.warning_amber_rounded, color: titleColor, size: 20),
const SizedBox(width: 8),
Text(title, style: TextStyle(color: titleColor, fontSize: 16)),
]),
content: Text(body, style: const TextStyle(fontSize: 14)),
actions: [
TextButton(
onPressed: () => Navigator.pop(_),
child: const Text('稍后处理'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: titleColor),
onPressed: () {
Navigator.pop(_);
ctx.go('/settings?tab=license');
},
child: const Text('立即前往', style: TextStyle(color: Colors.white)),
),
],
),
);
}
}
// ── 软件品牌(岩美酒库):线描山形 mark + 名 ──────────────────────────────────
class _SoftwareBrand extends StatelessWidget {
const _SoftwareBrand();
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const _SoftwareBrandMark(size: 30),
const SizedBox(width: 7),
Text('岩美酒库',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 17,
color: t.topFg,
letterSpacing: 0.3)),
],
);
}
}
/// 软件 logo 线描山形(原型 prod-mk SVG):白色描边山峰 + 横线 + 酒色圆点。
class _SoftwareBrandMark extends StatelessWidget {
final double size;
const _SoftwareBrandMark({required this.size});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(size, size),
painter: _BrandMarkPainter(context.tokens.topFg),
);
}
}
class _BrandMarkPainter extends CustomPainter {
final Color stroke;
_BrandMarkPainter(this.stroke);
@override
void paint(Canvas canvas, Size size) {
final s = size.width / 64.0; // 原型 viewBox 64
canvas.scale(s);
final p = Paint()
..color = stroke
..style = PaintingStyle.stroke
..strokeWidth = 5
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final peak = Path()
..moveTo(14, 33)
..lineTo(23, 16)
..lineTo(32, 25)
..lineTo(41, 16)
..lineTo(50, 33);
canvas.drawPath(peak, p);
canvas.drawLine(const Offset(15, 41), const Offset(49, 41), p);
canvas.drawCircle(const Offset(32, 48), 3.4,
Paint()..color = const Color(0xFFC97B86));
}
@override
bool shouldRepaint(_BrandMarkPainter old) => old.stroke != stroke;
}
// ── 门店品牌(鼎晟酒行):字标 mark + 店名,点开门店信息 ───────────────────────
class _ShopBrand extends ConsumerWidget {
final AuthUser? user;
const _ShopBrand({this.user});
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = context.tokens;
final shopAsync = ref.watch(shopInfoProvider);
final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? '';
final logoUrl = shopAsync.valueOrNull?.logoUrl ?? '';
final version = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
if (user != null) _showShopPanel(context, user!, version: version);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ShopMark(
logoUrl: logoUrl,
shopName: shopName,
bg: t.topMarkBg,
fg: t.topMarkFg),
const SizedBox(width: 9),
Flexible(
child: Text(
shopName.isEmpty ? '' : shopName,
style: TextStyle(
color: t.topFg, fontSize: 13, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
/// 门店字标(原型 .logo .mk, 26×26 圆角):有 logo 显示图,否则店名首字。
class _ShopMark extends StatelessWidget {
final String logoUrl;
final String shopName;
final Color bg;
final Color fg;
const _ShopMark(
{required this.logoUrl,
required this.shopName,
required this.bg,
required this.fg});
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(8);
if (logoUrl.isNotEmpty) {
final fullUrl = logoUrl.startsWith('http')
? logoUrl
: '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
return ClipRRect(
borderRadius: radius,
child: Image.network(
fullUrl,
width: 26,
height: 26,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _initial(radius),
),
);
}
return _initial(radius);
}
Widget _initial(BorderRadius radius) {
final initial = shopName.isNotEmpty ? shopName.characters.first : '';
return Container(
width: 26,
height: 26,
decoration: BoxDecoration(color: bg, borderRadius: radius),
alignment: Alignment.center,
child: Text(initial,
style: TextStyle(
color: fg, fontSize: 13, fontWeight: FontWeight.w800)),
);
}
}
// ── 侧栏导航项(原型 .nav, 40h)───────────────────────────────────────────────
class _SidebarNav extends StatefulWidget {
final _NavItem item;
final bool active;
final VoidCallback onTap;
const _SidebarNav(
{required this.item, required this.active, required this.onTap});
@override
State<_SidebarNav> createState() => _SidebarNavState();
}
class _SidebarNavState extends State<_SidebarNav> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final t = context.tokens;
final active = widget.active;
final Color bg = active
? t.sideActiveBg
: (_hover ? t.sideHover : Colors.transparent);
final Color fg = active || _hover ? t.sideActiveFg : t.sideFg;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
height: 40,
margin: const EdgeInsets.only(bottom: 2),
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
Icon(widget.item.icon, size: 19, color: fg),
const SizedBox(width: 11),
Text(
widget.item.label,
style: TextStyle(
color: fg,
fontSize: 13,
fontWeight: active ? FontWeight.w600 : FontWeight.w500,
),
),
],
),
),
),
);
}
}
// ── 门店信息弹窗(点门店品牌触发,保留原行为)─────────────────────────────────
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: ctx.dialogWidth(360),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
color: ctx.tokens.primary,
borderRadius:
const 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(),
),
],
),
),
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 _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: context.tokens.muted),
const SizedBox(width: 10),
SizedBox(
width: 72,
child: Text(label,
style: TextStyle(fontSize: 13, color: context.tokens.muted)),
),
Expanded(
child: Text(value,
style: TextStyle(
fontSize: 13,
color: context.tokens.text,
fontWeight: FontWeight.w500)),
),
],
);
}
}