import 'package:lucide_icons_flutter/lucide_icons.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 '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 '../../core/theme/app_chrome.g.dart'; import '../../core/theme/app_dims.g.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'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_toast.dart'; import '../../widgets/ds/m_sheet.dart'; import '../../widgets/ds/m_tab_bar.dart'; import '../../widgets/theme_sheet.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(LucideIcons.download, '入库管理', 0), _NavItem(LucideIcons.upload, '出库管理', 1), ]), _NavGroup('经营', [ _NavItem(LucideIcons.box, '库存管理', 2), _NavItem(LucideIcons.receiptText, '财务管理', 3), _NavItem(LucideIcons.users, '往来单位', 4), _NavItem(LucideIcons.layoutGrid, '基础数据', 5), ]), _NavGroup('系统', [ _NavItem(LucideIcons.monitorSmartphone, '设备管理', 6), // branch 10(追加在末尾,不打乱既有分支序号),显示位次按原型 shell.js // NAVS 排在「设备管理」与「系统设置」之间。 _NavItem(LucideIcons.shield, '授权管理', 10), _NavItem(LucideIcons.settings, '系统设置', 7), _NavItem(LucideIcons.info, '关于我们', 8), ]), ]; // ── 窄屏底部 tab(对齐原型 mobile-shell.js TABS:库存/入库/出库/财务/我的)── // (图标, 文案, branch index);branch index 对应 router StatefulShellRoute 分支序号。 const _mobileTabs = <(IconData, String, int)>[ (LucideIcons.box, '库存', 2), (LucideIcons.download, '入库', 0), (LucideIcons.upload, '出库', 1), (LucideIcons.receiptText, '财务', 3), (LucideIcons.user, '我的', 9), ]; /// 窄屏 tab 根路由白名单:命中显示底部 tabbar;未命中即二级屏(隐藏 tabbar + 返回箭头)。 const _tabRoots = {'/inventory', '/stock-in', '/stock-out', '/finance', '/me'}; /// branch index → 底部 tab 高亮位;4-9(往来/基础数据/设备/设置/关于/我的)归「我的域」。 int _tabForBranch(int branch) => switch (branch) { 2 => 0, 0 => 1, 1 => 2, 3 => 3, _ => 4, }; /// 窄屏顶栏标题(对齐原型 m-top data-title)。 String _mobileTitle(String loc) { if (loc.startsWith('/stock-in')) return '入库管理'; if (loc.startsWith('/stock-out')) return '出库管理'; if (loc == '/inventory/check') return '库存盘点'; if (loc.startsWith('/inventory')) return '库存管理'; if (loc.startsWith('/finance')) return '财务管理'; if (loc.startsWith('/partners')) return '往来单位'; if (loc.startsWith('/products/')) return '商品详情'; if (loc.startsWith('/products')) return '基础数据'; if (loc.startsWith('/devices')) return '设备管理'; if (loc == '/settings/users') return '用户管理'; if (loc.startsWith('/settings')) return '系统设置'; if (loc.startsWith('/about')) return '关于我们'; if (loc == '/me/license' || loc.startsWith('/license')) return '授权管理'; if (loc.startsWith('/me')) return '我的'; return ''; } 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; /// 当前匹配路由(router 透传):窄屏据此判定 tab 根 / 二级屏。 final String location; const AppShell( {super.key, required this.navigationShell, required this.location}); @override ConsumerState createState() => _AppShellState(); } class _AppShellState extends ConsumerState with SingleTickerProviderStateMixin { final String _loginTime = DateFormat('HH:mm:ss').format(AppStatusBar.clock()); bool _forceDialogShown = false; bool _licenseDialogShown = false; // ── 窄屏切页滑动过渡:方向感知的一次性滑入(tab 按左右序、进二级从右、返回从左)── // 注意:必须在 initState 创建(惰性 late 若到 dispose 才首次求值,会在已卸载 // 的树上找 TickerMode 而崩溃——桌面路径全程不触碰该控制器)。 late final AnimationController _slideCtrl; double _slideFrom = 1; // 1=从右滑入,-1=从左滑入 String _prevLocation = ''; int _prevBranch = 0; @override void initState() { super.initState(); _slideCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 240), value: 1); _prevLocation = widget.location; _prevBranch = widget.navigationShell.currentIndex; } @override void didUpdateWidget(AppShell old) { super.didUpdateWidget(old); if (widget.location == _prevLocation) return; final oldRoot = _tabRoots.contains(_prevLocation); final newRoot = _tabRoots.contains(widget.location); if (oldRoot && newRoot) { // tab 间切换:按 tab 左右次序定方向 final oldPos = _tabForBranch(_prevBranch); final newPos = _tabForBranch(widget.navigationShell.currentIndex); _slideFrom = newPos >= oldPos ? 1 : -1; } else if (newRoot) { _slideFrom = -1; // 退出二级屏 → 从左滑入 } else { _slideFrom = 1; // 进入二级屏/更深页 → 从右滑入 } _prevLocation = widget.location; _prevBranch = widget.navigationShell.currentIndex; if (MediaQuery.sizeOf(context).width < 600) { _slideCtrl.forward(from: 0); } } @override void dispose() { _slideCtrl.dispose(); super.dispose(); } /// 打开账号菜单(个人设置 / 退出登录)。[anchorContext] = 用户区组件的 context, /// 据其实测宽度让菜单等宽(不溢出内容区)、据其位置把菜单**完整弹到按钮正上方** /// (留 8px 间隙,不遮挡触发器,对齐原型)。 void _openUserMenu(BuildContext anchorContext) { final t = anchorContext.tokens; final rb = anchorContext.findRenderObject() as RenderBox?; final overlay = Overlay.of(anchorContext).context.findRenderObject() as RenderBox?; if (rb == null || overlay == null) return; final topLeft = rb.localToGlobal(Offset.zero, ancestor: overlay); final items = >[ _userMenuItem('profile', LucideIcons.userCog, '个人设置', t), _userMenuItem('logout', LucideIcons.logOut, '退出登录', t), ]; // 菜单弹到按钮上方:top = 按钮顶 − 菜单估高 − 间隙(菜单项 40 + 上下 padding 8*2) const itemH = 40.0; final menuH = items.length * itemH + 16; final top = topLeft.dy - menuH - 8; showMenu( context: anchorContext, position: RelativeRect.fromLTRB( topLeft.dx, top, overlay.size.width - topLeft.dx - rb.size.width, overlay.size.height - top, ), constraints: BoxConstraints.tightFor(width: rb.size.width), color: t.menuUserBg, elevation: 8, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), items: items, ).then((v) { if (v == 'logout') _logout(); }); } /// 切换到第 index 个分支;再次点当前栏目则回到该分支初始页。 void _goBranch(int index) { widget.navigationShell.goBranch( index, initialLocation: index == widget.navigationShell.currentIndex, ); } void _logout() { ref.read(authStateProvider.notifier).logout(); context.go('/login'); } /// 窄屏二级屏返回:分支内有栈先 pop;分支根按来源兜底(商品详情→列表,其余→我的)。 void _backFromSecondary() { if (context.canPop()) { context.pop(); return; } final loc = widget.location; if (loc.startsWith('/products/')) { context.go('/products'); } else if (loc.startsWith('/stock-in/')) { context.go('/stock-in'); } else if (loc.startsWith('/stock-out/')) { context.go('/stock-out'); } else { context.go('/me'); } } @override Widget build(BuildContext context) { final user = ref.watch(authStateProvider).user; // 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线 ref.watch(sessionHeartbeatProvider); // 全局轻提示(写请求被后端 403 拒绝等):统一在此弹出并清空。 ref.listen(apiMessageProvider, (prev, next) { if (next == null || next.isEmpty) return; showDsToast(context, 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; // 窄屏导航形态(对齐原型移动壳):tab 根显示底部 tabbar,二级屏隐藏 tabbar // 并在顶栏出返回箭头(_buildTopBar 内)。 final isTabRoot = _tabRoots.contains(widget.location); // 窄屏横滑手势(2026-07-04 拍板):tab 根左右滑切换相邻 tab(不循环); // 二级屏从左往右滑退出(iOS 返回习惯,回来源/我的),向左滑无响应。 // 内容区里的横向滚动(分段 tab / 表格横滚)在手势竞技场中优先,互不冲突。 // 切页时按方向播放一次性滑入过渡(_slideCtrl,didUpdateWidget 触发)。 Widget shellContent = widget.navigationShell; if (isMobile) { shellContent = GestureDetector( behavior: HitTestBehavior.translucent, onHorizontalDragEnd: (d) { final v = d.primaryVelocity ?? 0; if (v.abs() < 200) return; // 轻微拖动不触发 // 有底部 sheet(详情/筛选等「抽屉」)开着:从左往右滑关掉顶层 // sheet,任何方向都不切 tab / 不退屏。 if (hasOpenMSheet) { if (v > 0) closeTopMSheet(); return; } if (isTabRoot) { final pos = _tabForBranch(widget.navigationShell.currentIndex); final next = v < 0 ? pos + 1 : pos - 1; // 向左滑→下一个 tab if (next < 0 || next >= _mobileTabs.length) return; _goBranch(_mobileTabs[next].$3); } else if (v > 0) { _backFromSecondary(); // 从左往右滑=退出;向左滑无响应 } }, child: AnimatedBuilder( animation: _slideCtrl, builder: (ctx, child) { final p = Curves.easeOutCubic.transform(_slideCtrl.value); // 0→1 final w = MediaQuery.sizeOf(ctx).width; return Opacity( opacity: 0.4 + 0.6 * p, child: Transform.translate( offset: Offset(_slideFrom * 0.18 * w * (1 - p), 0), child: child, ), ); }, child: widget.navigationShell, ), ); } final Widget shell = SelectionArea( child: Scaffold( body: Column( children: [ _buildTopBar(context, user, isMobile, isTabRoot, topInset), Expanded( child: Row( children: [ if (!isMobile) _buildSidebar(context, user), Expanded( child: Column( children: [ ..._buildBanners(context, isOnline, updateInfo, updateNotifier, licenseInfo), Expanded(child: shellContent), if (!isMobile) AppStatusBar(loginTime: _loginTime), ], ), ), ], ), ), ], ), bottomNavigationBar: isMobile && isTabRoot ? MTabBar( items: [for (final t in _mobileTabs) MTabItem(t.$1, t.$2)], currentIndex: _tabForBranch(widget.navigationShell.currentIndex), onTap: (i) => _goBranch(_mobileTabs[i].$3), ) : null, ), ); // 窄屏二级屏拦截系统返回键:分支根直接返回会退出 app,改走 _backFromSecondary // 回「我的」。Web 不拦截(浏览器历史由 go_router 管理)。 if (!kIsWeb && isMobile && !isTabRoot) { return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) _backFromSecondary(); }, child: shell, ); } return shell; } // ── 顶栏 ───────────────────────────────────────────────────────────────── // 桌面(原型 .top, 56h):左两级品牌;右 主题器 + 通知铃。 // 窄屏(原型 .m-top, 52h):左 返回箭头(二级屏)/门店品牌(tab 根) + 屏标题; // 右 主题 + 通知铃 + 头像(→我的)。 Widget _buildTopBar(BuildContext context, AuthUser? user, bool isMobile, bool isTabRoot, double topInset) { final t = context.tokens; if (isMobile) { return Container( height: 52 + topInset, decoration: BoxDecoration( color: t.topBg, border: Border(bottom: BorderSide(color: t.topBorder)), ), padding: EdgeInsets.only( top: topInset, left: isTabRoot ? 14 : 6, right: 14), child: Row( children: [ if (!isTabRoot) IconButton( icon: Icon(LucideIcons.arrowLeft, color: t.topFg, size: 22), tooltip: '返回', onPressed: _backFromSecondary, ), Expanded( child: Row(children: [ Flexible( child: Text( _mobileTitle(widget.location), overflow: TextOverflow.ellipsis, style: TextStyle( color: t.topFg, fontSize: AppDims.fsTitle, fontWeight: FontWeight.w700), ), ), if (WriteGuard.isReadonly(ref)) ...[ const SizedBox(width: 10), _readonlyBadge(t.topControl), ], ]), ), const SizedBox(width: 10), IconButton( icon: Icon(LucideIcons.shirt, color: t.topFg, size: 19), tooltip: '主题外观', onPressed: () => showThemeSheet(context), ), const NotificationBell(), const SizedBox(width: 8), // 头像(.m-av 28px 圆)→ 我的 InkWell( onTap: () => _goBranch(9), borderRadius: BorderRadius.circular(999), child: Container( width: 28, height: 28, decoration: BoxDecoration(color: t.primary, shape: BoxShape.circle), alignment: Alignment.center, child: Text( _avatarInitial(user), style: TextStyle( color: t.onPrimary, fontSize: AppDims.fsSm, fontWeight: FontWeight.w700), ), ), ), ], ), ); } return Container( height: 56 + topInset, decoration: BoxDecoration( color: t.topBg, border: Border(bottom: BorderSide(color: t.topBorder)), ), padding: EdgeInsets.only(top: topInset, left: 18, right: 14), child: Row( children: [ // 左组(占满剩余空间,靠左):把右组(主题器+铃)挤到最右 Expanded( child: Row( children: [ // 软件品牌(岩美酒库) 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 SizedBox(width: 12), const ThemePickerPill(), const SizedBox(width: 14), const NotificationBell(), ], ), ); } String _avatarInitial(AuthUser? user) { final name = user == null ? '' : (user.realName.isNotEmpty ? user.realName : user.username); return name.isNotEmpty ? name.characters.first : '用'; } 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(LucideIcons.eye, size: 13, color: AppChrome.onAccent), SizedBox(width: 4), Text('只读', style: TextStyle( color: AppChrome.onAccent, 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 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: _SideUserTile( user: user, shopName: shopName, roleLabel: _roleLabel(user.role), onTap: _openUserMenu, ), ), ], ); } PopupMenuItem _userMenuItem( String value, IconData icon, String label, dynamic t) { return PopupMenuItem( 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)), ], ), ); } // ── 顶部横幅(更新 / 强制更新 / 授权 / 离线):保持原有行为 ─────────────────── List _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(LucideIcons.hardDriveDownload, 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(LucideIcons.wifiOff, size: 16, color: AppChrome.onAccent), SizedBox(width: 8), Expanded( child: Text( '网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新', style: TextStyle( color: AppChrome.onAccent, 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(LucideIcons.hardDriveDownload, color: context.tokens.primary), const SizedBox(width: 8), const Text('发现新版本'), ], ), content: Text('发现新版本 v${info.latestVersion},需更新后才能继续使用,' '更新以获得最新功能与修复。'), actions: [ DsButton('立即更新', variant: DsBtnVariant.primary, onPressed: () => startInAppUpdate(context, info)), ], ), ), ); } 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(LucideIcons.triangleAlert, size: 16, color: AppChrome.onAccent), const SizedBox(width: 8), Expanded( child: Text(msg, style: const TextStyle(color: AppChrome.onAccent, fontSize: 13), overflow: TextOverflow.ellipsis), ), TextButton( // 授权管理已提升为独立一级屏:窄屏 我的→授权管理,宽屏侧边栏→授权管理 onPressed: () => context.go(context.isMobile ? '/me/license' : '/license'), style: TextButton.styleFrom(foregroundColor: AppChrome.onAccentFaint), child: const Text('去激活'), ), ], ), ); } void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) { final (title, body) = LicenseCopy.dialog(lic); final Color titleColor = lic.phase == 'grace' ? ctx.tokens.warn : ctx.tokens.danger; showDialog( context: ctx, barrierDismissible: true, builder: (_) => AlertDialog( title: Row(children: [ Icon(LucideIcons.triangleAlert, 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: [ DsButton('稍后处理', onPressed: () => Navigator.pop(_)), DsButton('立即前往', variant: lic.phase == 'grace' ? DsBtnVariant.primary : DsBtnVariant.danger, onPressed: () { Navigator.pop(_); ctx.go(ctx.isMobile ? '/me/license' : '/license'); }), ], ), ); } } // ── 软件品牌(岩美酒库):线描山形 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 = AppChrome.brandDot); } @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)), ); } } // ── 侧栏底部用户区(原型 .side-user):hover 变色(同 nav 的 side-hover),点开账号菜单 ── class _SideUserTile extends StatefulWidget { final AuthUser user; final String shopName; final String roleLabel; final void Function(BuildContext anchorContext) onTap; const _SideUserTile({ required this.user, required this.shopName, required this.roleLabel, required this.onTap, }); @override State<_SideUserTile> createState() => _SideUserTileState(); } class _SideUserTileState extends State<_SideUserTile> { bool _hover = false; @override Widget build(BuildContext context) { final t = context.tokens; final u = widget.user; final initial = u.realName.isNotEmpty ? u.realName : u.username; final pic = initial.isNotEmpty ? initial.characters.first : '用'; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hover = true), onExit: (_) => setState(() => _hover = false), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => widget.onTap(context), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), decoration: BoxDecoration( color: _hover ? t.sideHover : Colors.transparent, 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( u.realName.isNotEmpty ? u.realName : u.username, style: TextStyle( color: t.sideActiveFg, fontSize: 13, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis, ), Text( '${widget.shopName} · ${widget.roleLabel}', style: TextStyle(color: t.sideMuted, fontSize: 11), overflow: TextOverflow.ellipsis, ), ], ), ), Icon(LucideIcons.chevronDown, size: 14, color: t.sideMuted), ], ), ), ), ); } } // ── 侧栏导航项(原型 .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(LucideIcons.store, color: AppChrome.onAccent, size: 20), const SizedBox(width: 10), const Text('门店信息', style: TextStyle( color: AppChrome.onAccent, fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), IconButton( onPressed: () => Navigator.pop(ctx), icon: const Icon(LucideIcons.x, color: AppChrome.onAccentFaint, size: 18), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), ), Padding( padding: const EdgeInsets.all(20), child: Column( children: [ _InfoRow( icon: LucideIcons.tag, label: '门店编号', value: u.shopNo), const SizedBox(height: 14), _InfoRow( icon: LucideIcons.user, label: '登录账号', value: u.username), const SizedBox(height: 14), _InfoRow( icon: LucideIcons.idCard, label: '姓名', value: u.realName), const SizedBox(height: 14), _InfoRow( icon: LucideIcons.info, 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)), ), ], ); } }