diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index c9adf4c..d74d307 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -3,7 +3,6 @@ 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 'package:flutter/foundation.dart' show kIsWeb; import '../../core/auth/auth_state.dart'; import '../../widgets/write_guard.dart'; @@ -18,9 +17,59 @@ 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, + /// StatefulShellRoute 注入的导航壳:各栏目分支 Navigator 的 IndexedStack, /// 跨栏目切换时各分支页面 State 常驻(保住半填表单 / 内部 tab / 滚动位置)。 final StatefulNavigationShell navigationShell; const AppShell({super.key, required this.navigationShell}); @@ -30,7 +79,11 @@ class AppShell extends ConsumerStatefulWidget { } class _AppShellState extends ConsumerState { - bool _sidebarExpanded = true; + final String _loginTime = + DateFormat('HH:mm:ss').format(AppStatusBar.clock()); + bool _forceDialogShown = false; + bool _licenseDialogShown = false; + final GlobalKey _scaffoldKey = GlobalKey(); /// 切换到第 index 个分支;再次点当前栏目则回到该分支初始页。 void _goBranch(int index) { @@ -40,10 +93,433 @@ class _AppShellState extends ConsumerState { ); } - final String _loginTime = DateFormat('HH:mm:ss').format(DateTime.now()); - bool _forceDialogShown = false; - bool _licenseDialogShown = false; - final GlobalKey _scaffoldKey = GlobalKey(); + 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(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( + 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 _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)), + ], + ), + ); + } + + /// 窄屏(手机)侧滑抽屉导航:与侧栏同分组结构。 + 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 _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; @@ -74,498 +550,6 @@ class _AppShellState extends ConsumerState { ); } - 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.devices, label: '设备管理', path: '/devices'), - _NavItem(icon: Icons.settings, label: '系统设置', path: '/settings'), - _NavItem(icon: Icons.info_outline, label: '关于我们', path: '/about'), - ]; - - /// 窄屏(手机)侧滑抽屉导航,容纳全部菜单项。 - Widget _buildDrawer(BuildContext context, AuthUser? user) { - final shopAsync = ref.watch(shopInfoProvider); - final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? ''; - final logoUrl = shopAsync.valueOrNull?.logoUrl ?? ''; - return Drawer( - child: Container( - color: context.tokens.primaryDark, - child: SafeArea( - child: Column( - children: [ - // 抽屉头部:门店 Logo + 名称 + 编号 - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), - color: context.tokens.primary, - child: Row( - children: [ - _ShopLogo(logoUrl: logoUrl, shopName: shopName, size: 40), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - shopName.isEmpty ? '—' : shopName, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600), - overflow: TextOverflow.ellipsis, - ), - if (user != null) ...[ - const SizedBox(height: 2), - Text( - '${user.shopNo} · ${user.username}', - style: const TextStyle( - color: Colors.white70, fontSize: 12), - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - ], - ), - ), - Expanded( - child: ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: _navItems.asMap().entries.map((e) { - final isActive = - widget.navigationShell.currentIndex == e.key; - return _SidebarItem( - item: e.value, - isActive: isActive, - expanded: true, - onTap: () { - Navigator.pop(context); // 关闭抽屉 - _goBranch(e.key); - }, - ); - }).toList(), - ), - ), - // 退出登录(抽屉底部常驻,窄屏顶栏菜单不便时也能退出) - const Divider(height: 1, color: Colors.white24), - ListTile( - leading: const Icon(Icons.logout, color: Colors.white70), - title: const Text('退出登录', - style: TextStyle( - color: Colors.white, fontWeight: FontWeight.w500)), - onTap: () { - Navigator.pop(context); // 关闭抽屉 - 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(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; - // iOS/刘海屏状态栏高度:顶栏需下移此距离,避免菜单按钮被状态栏遮挡 - final topInset = MediaQuery.of(context).padding.top; - 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'; - final licenseInfo = ref.watch(licenseProvider).valueOrNull; - - return SelectionArea( - child: Scaffold( - key: _scaffoldKey, - drawer: isMobile ? _buildDrawer(context, user) : null, - drawerEnableOpenDragGesture: !kIsWeb && isMobile, - body: Column( - children: [ - // Top Bar(高度含状态栏内边距,蓝色铺满到屏幕顶,内容下移避开状态栏) - Container( - height: 56 + topInset, - color: context.tokens.primary, - padding: EdgeInsets.only(top: topInset, left: 8, right: 8), - child: Row( - children: [ - IconButton( - icon: Icon( - isMobile - ? Icons.menu - : (_sidebarExpanded ? Icons.menu_open : Icons.menu), - color: Colors.white), - onPressed: () { - if (isMobile) { - _scaffoldKey.currentState?.openDrawer(); - } else { - setState(() => _sidebarExpanded = !_sidebarExpanded); - } - }, - tooltip: isMobile - ? '菜单' - : (_sidebarExpanded ? '收起侧边栏' : '展开侧边栏'), - ), - const SizedBox(width: 4), - _ShopButton(user: user, version: appVersion), - if (WriteGuard.isReadonly(ref)) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.white24, - borderRadius: BorderRadius.circular(4), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.visibility_outlined, - size: 13, color: Colors.white), - SizedBox(width: 4), - Text('只读', - style: TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600)), - ], - ), - ), - ], - const Spacer(), - if (user != null) ...[ - // 门店号已移除;用户名移到左侧栏底部。顶栏仅保留「个人设置」下拉。 - PopupMenuButton( - 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( - value: 'profile', - padding: EdgeInsets.zero, - child: _HoverMenuItem( - icon: Icons.manage_accounts_outlined, - label: '个人设置', - ), - ), - ], - ), - const SizedBox(width: 8), - ], - ], - ), - ), - // Main area - Expanded( - child: Row( - children: [ - // Sidebar(仅宽屏;窄屏改用 Drawer) - if (!isMobile) - AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - width: sidebarWidth, - color: context.tokens.primaryDark, - child: Column( - children: [ - Expanded( - child: ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: _navItems.asMap().entries.map((e) { - final isActive = - widget.navigationShell.currentIndex == - e.key; - return _SidebarItem( - item: e.value, - isActive: isActive, - expanded: _sidebarExpanded, - onTap: () => _goBranch(e.key), - ); - }).toList(), - ), - ), - // 当前登录账号:点击弹下拉菜单(退出登录) - if (user != null) ...[ - const Divider(height: 1, color: Colors.white24), - PopupMenuButton( - tooltip: '账号菜单', - position: PopupMenuPosition.over, - offset: const Offset(0, -8), - 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( - value: 'logout', - padding: EdgeInsets.zero, - child: _HoverMenuItem( - icon: Icons.logout, - label: '退出登录', - ), - ), - ], - child: SizedBox( - height: 48, - child: Row( - children: [ - SizedBox(width: _sidebarExpanded ? 16 : 3), - Expanded( - child: Row( - mainAxisAlignment: _sidebarExpanded - ? MainAxisAlignment.start - : MainAxisAlignment.center, - children: [ - const Icon(Icons.person_outline, - color: Colors.white60, size: 20), - if (_sidebarExpanded) ...[ - const SizedBox(width: 12), - Expanded( - child: Text( - user.username, - style: const TextStyle( - color: Colors.white70, - fontSize: 14), - overflow: TextOverflow.ellipsis, - ), - ), - const Icon(Icons.keyboard_arrow_up, - color: Colors.white38, - size: 18), - const SizedBox(width: 12), - ], - ], - ), - ), - ], - ), - ), - ), - ], - const SizedBox(height: 8), - ], - ), - ), - // 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},' - '建议立即更新以获得最新功能与修复', - style: const TextStyle( - color: Color(0xFF5D4037), fontSize: 13), - overflow: TextOverflow.ellipsis, - ), - ), - TextButton( - onPressed: () => - startInAppUpdate(context, updateInfo), - 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(); - }), - // License expiry banner - if (licenseInfo != null && licenseInfo.needsAttention) - _buildLicenseBanner(licenseInfo), - // License expiry dialog (once per session) - if (licenseInfo != null && - licenseInfo.needsAttention && - !_licenseDialogShown) - Builder(builder: (ctx) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_licenseDialogShown && mounted) { - _licenseDialogShown = true; - _showLicenseExpiryDialog(ctx, licenseInfo); - } - }); - return const SizedBox.shrink(); - }), - // Offline banner - if (!isOnline) - Container( - width: double.infinity, - color: context.tokens.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(), - ], - ), - ), - Expanded(child: widget.navigationShell), - // Status bar(仅宽屏;窄屏隐藏,离线已有顶部横幅提示) - if (!isMobile) - 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) - : context.tokens.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), - _LicenseStatusItem( - lic: licenseInfo, iconOnly: iconOnly), - ], - ), - ); - }, - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - Widget _buildLicenseBanner(LicenseInfo lic) { final bg = LicenseCopy.bannerColor(lic.phase); final msg = LicenseCopy.banner(lic); @@ -575,8 +559,7 @@ class _AppShellState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row( children: [ - const Icon(Icons.warning_amber_rounded, - size: 16, color: Colors.white), + const Icon(Icons.warning_amber_rounded, size: 16, color: Colors.white), const SizedBox(width: 8), Expanded( child: Text(msg, @@ -626,74 +609,207 @@ class _AppShellState extends ConsumerState { } } -class _NavItem { - final IconData icon; - final String label; - final String path; - const _NavItem({required this.icon, required this.label, required this.path}); +// ── 软件品牌(岩美酒库):线描山形 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)), + ], + ); + } } -class _SidebarItem extends StatelessWidget { - final _NavItem item; - final bool isActive; - final bool expanded; - final VoidCallback onTap; +/// 软件 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), + ); + } +} - const _SidebarItem({ - required this.item, - required this.isActive, - required this.expanded, - required this.onTap, - }); +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) { - return SizedBox( - height: 48, - child: Material( - color: isActive ? context.tokens.primary.withAlpha(76) : Colors.transparent, - child: InkWell( - onTap: onTap, - hoverColor: Colors.white.withAlpha(13), - splashColor: Colors.white.withAlpha(26), - highlightColor: Colors.white.withAlpha(13), + 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: [ - // 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, - ), - ), - ], - ], + 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, ), ), ], @@ -704,111 +820,7 @@ class _SidebarItem extends StatelessWidget { } } -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)), - ], - ], - ); - } -} - -/// 状态栏「授权到期」项:按到期后时长分三级,用颜色 + 文案区分。 -/// 没到期 → 正常(绿);过期 ≤7 天(宽限期)→ 警告(橙);过期 >7 天 → error(红)。 -/// 对应后端 phase:normal / grace / readonly|locked。 -class _LicenseStatusItem extends StatelessWidget { - final LicenseInfo? lic; - final bool iconOnly; - const _LicenseStatusItem({required this.lic, this.iconOnly = false}); - - @override - Widget build(BuildContext context) { - final lic = this.lic; - if (lic == null) return const SizedBox.shrink(); - - final color = LicenseCopy.phaseColor(lic.phase); - final icon = LicenseCopy.phaseIcon(lic.phase); - - final String date = lic.expiresAt == null - ? '' - : DateFormat('yyyy-MM-dd').format(lic.expiresAt!); - final String text = LicenseCopy.statusBar(lic, date); - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - const _StatusDivider(), - Icon(icon, size: 11, color: color), - if (!iconOnly) ...[ - const SizedBox(width: 4), - Text(text, - style: TextStyle( - color: color, fontSize: 11, fontWeight: FontWeight.w600)), - ], - ], - ); - } -} - -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( @@ -821,12 +833,12 @@ void _showShopPanel(BuildContext context, AuthUser u, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Header Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), decoration: BoxDecoration( - color: context.tokens.primary, - borderRadius: const BorderRadius.vertical(top: Radius.circular(10)), + color: ctx.tokens.primary, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(10)), ), child: Row( children: [ @@ -848,7 +860,6 @@ void _showShopPanel(BuildContext context, AuthUser u, ], ), ), - // Info rows Padding( padding: const EdgeInsets.all(20), child: Column( @@ -875,94 +886,6 @@ void _showShopPanel(BuildContext context, AuthUser u, ); } -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; @@ -979,8 +902,7 @@ class _InfoRow extends StatelessWidget { SizedBox( width: 72, child: Text(label, - style: - TextStyle(fontSize: 13, color: context.tokens.muted)), + style: TextStyle(fontSize: 13, color: context.tokens.muted)), ), Expanded( child: Text(value, @@ -993,55 +915,3 @@ class _InfoRow extends StatelessWidget { ); } } - -class _HoverMenuItem extends StatefulWidget { - final IconData icon; - final String label; - - const _HoverMenuItem({ - required this.icon, - required this.label, - }); - - @override - State<_HoverMenuItem> createState() => _HoverMenuItemState(); -} - -class _HoverMenuItemState extends State<_HoverMenuItem> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final color = context.tokens.text; - final hoverBg = context.tokens.rowHover; - - 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, - ), - ), - ], - ), - ), - ); - } -} diff --git a/client/lib/widgets/app_status_bar.dart b/client/lib/widgets/app_status_bar.dart new file mode 100644 index 0000000..9ffc78b --- /dev/null +++ b/client/lib/widgets/app_status_bar.dart @@ -0,0 +1,164 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../core/auth/auth_state.dart'; +import '../core/config/license_copy.dart'; +import '../core/theme/app_chrome.g.dart'; +import '../providers/connectivity_provider.dart'; +import '../providers/license_provider.dart'; +import '../providers/update_provider.dart'; + +/// 底部状态栏(原型 .statusbar,主题无关 chrome 条)。 +/// 左:门店编号 / 操作员 / 登录时间 / 当前时间(每秒走字) 右:连接 / 版本 / 授权。 +class AppStatusBar extends ConsumerStatefulWidget { + final String loginTime; + const AppStatusBar({super.key, required this.loginTime}); + + /// 时钟种子(可测试缝):golden 测试覆盖为固定时间以确定化;走字默认关。 + static DateTime Function() clock = DateTime.now; + + /// 是否每秒走字(golden 测试关掉,避免时间变动造成像素抖动 / pending timer)。 + static bool ticking = true; + + @override + ConsumerState createState() => _AppStatusBarState(); +} + +class _AppStatusBarState extends ConsumerState { + Timer? _timer; + late String _now; + + @override + void initState() { + super.initState(); + _now = DateFormat('HH:mm:ss').format(AppStatusBar.clock()); + if (AppStatusBar.ticking) { + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) { + setState(() => + _now = DateFormat('HH:mm:ss').format(AppStatusBar.clock())); + } + }); + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final user = ref.watch(authStateProvider).user; + final isOnline = ref.watch(connectivityProvider); + final version = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0'; + final lic = ref.watch(licenseProvider).valueOrNull; + + return Container( + height: 34, + color: AppChrome.statusbarBg, + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Row( + children: [ + // 左组 + _item(icon: Icons.storefront_outlined, strong: user?.shopNo ?? '—'), + _div(), + _item( + icon: Icons.person_outline, + text: (user == null || user.realName.isEmpty) + ? (user?.username ?? '—') + : user.realName), + _div(), + _item(icon: Icons.login, text: '登录 ', strong: widget.loginTime), + _div(), + _item(icon: Icons.schedule, text: '当前 ', strong: _now), + const Spacer(), + // 右组 + _item( + icon: isOnline ? Icons.cloud_done_outlined : Icons.cloud_off_outlined, + text: isOnline ? '已连接' : '连接已断开', + ok: isOnline, + ), + _div(), + _item(icon: Icons.sell_outlined, strong: version, ok: true), + _div(), + _licenseItem(lic), + ], + ), + ); + } + + Widget _div() => Container( + width: 1, + height: 14, + color: AppChrome.statusbarDiv, + margin: const EdgeInsets.symmetric(horizontal: 0), + ); + + // 单项:图标 + 普通文字 + 强调文字(mono 强调色)。ok=true 用 statusbar-ok 绿。 + Widget _item({ + required IconData icon, + String? text, + String? strong, + bool ok = false, + Color? color, + }) { + final c = color ?? (ok ? AppChrome.statusbarOk : AppChrome.statusbarFg); + final iconC = ok ? AppChrome.statusbarOk : AppChrome.statusbarMuted; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 13), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: iconC), + const SizedBox(width: 6), + if (text != null) + Text(text, style: TextStyle(fontSize: 11, color: c)), + if (strong != null) + Text( + strong, + style: TextStyle( + fontSize: 11, + color: ok ? AppChrome.statusbarOk : AppChrome.statusbarStrong, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + Widget _licenseItem(LicenseInfo? lic) { + if (lic == null) { + return _item(icon: Icons.verified_user_outlined, text: '授权', ok: true); + } + final color = LicenseCopy.phaseColor(lic.phase); + final icon = LicenseCopy.phaseIcon(lic.phase); + final date = lic.expiresAt == null + ? '' + : DateFormat('yyyy-MM-dd').format(lic.expiresAt!); + final text = LicenseCopy.statusBar(lic, date); + final normal = lic.phase == 'normal'; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 13), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, + size: 13, color: normal ? AppChrome.statusbarOk : color), + const SizedBox(width: 6), + Text( + text, + style: TextStyle( + fontSize: 11, + color: normal ? AppChrome.statusbarOk : color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/client/lib/widgets/notification_bell.dart b/client/lib/widgets/notification_bell.dart new file mode 100644 index 0000000..ea8d845 --- /dev/null +++ b/client/lib/widgets/notification_bell.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import '../core/theme/context_tokens.dart'; + +/// 顶栏通知铃(原型 .icon-btn + #i-bell)。通知中心为二期,暂点开「暂无通知」面板。 +class NotificationBell extends StatelessWidget { + const NotificationBell({super.key}); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + return PopupMenuButton( + tooltip: '通知', + offset: const Offset(0, 40), + position: PopupMenuPosition.under, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + color: t.surface, + elevation: 8, + itemBuilder: (_) => [ + PopupMenuItem( + enabled: false, + child: SizedBox( + width: 200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('通知', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: t.heading)), + const SizedBox(height: 10), + Row( + children: [ + Icon(Icons.notifications_off_outlined, + size: 16, color: t.faint), + const SizedBox(width: 8), + Text('暂无通知', + style: TextStyle(fontSize: 13, color: t.muted)), + ], + ), + ], + ), + ), + ), + ], + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Icon(Icons.notifications_outlined, size: 19, color: t.topFg), + ), + ); + } +} diff --git a/client/lib/widgets/theme_picker_pill.dart b/client/lib/widgets/theme_picker_pill.dart new file mode 100644 index 0000000..08086d8 --- /dev/null +++ b/client/lib/widgets/theme_picker_pill.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/theme/context_tokens.dart'; +import '../core/theme/theme_controller.dart'; + +/// 顶栏主题选择器 pill(原型 .theme-pill):衬衫图标 + 当前主题名 + ▾。 +/// 点开 A/B/C 菜单切换主题(持久化),从「系统设置」迁来的入口。 +class ThemePickerPill extends ConsumerWidget { + const ThemePickerPill({super.key}); + + // 主题键 → 中文名(与原型 shell.js THEMES 一致)。 + static const _names = {'a': '经典蓝', 'b': '琥珀', 'c': '酒窖'}; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = context.tokens; + final key = ref.watch(themeControllerProvider); + final name = _names[key] ?? '经典蓝'; + + return PopupMenuButton( + tooltip: '切换主题', + offset: const Offset(0, 38), + position: PopupMenuPosition.under, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + color: t.surface, + elevation: 8, + onSelected: (v) => ref.read(themeControllerProvider.notifier).set(v), + itemBuilder: (_) => _names.entries.map((e) { + final sel = e.key == key; + return PopupMenuItem( + value: e.key, + height: 40, + child: Row( + children: [ + SizedBox( + width: 18, + child: sel + ? Icon(Icons.check, size: 16, color: t.primary) + : null, + ), + const SizedBox(width: 4), + Text( + '${e.key.toUpperCase()} · ${e.value}', + style: TextStyle( + fontSize: 13, + color: sel ? t.primary : t.text, + fontWeight: sel ? FontWeight.w600 : FontWeight.w400, + ), + ), + ], + ), + ); + }).toList(), + child: Container( + height: 30, + padding: const EdgeInsets.symmetric(horizontal: 11), + decoration: BoxDecoration( + color: t.topControl, + border: Border.all(color: t.topBorder), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.checkroom, size: 15, color: t.sideFg), + const SizedBox(width: 7), + Text(name, style: TextStyle(fontSize: 12, color: t.topFg)), + const SizedBox(width: 7), + Icon(Icons.keyboard_arrow_down, size: 14, color: t.topFg), + ], + ), + ), + ); + } +} diff --git a/client/test/golden/app_shell_golden_test.dart b/client/test/golden/app_shell_golden_test.dart index 71c8cbb..961150a 100644 --- a/client/test/golden/app_shell_golden_test.dart +++ b/client/test/golden/app_shell_golden_test.dart @@ -12,10 +12,13 @@ import 'package:jiu_client/providers/update_provider.dart'; import 'package:jiu_client/providers/connectivity_provider.dart'; import 'package:jiu_client/providers/session_heartbeat.dart'; import 'package:jiu_client/screens/shell/app_shell.dart'; +import 'package:jiu_client/widgets/app_status_bar.dart'; -/// design-distill 阶段4:app 外壳(顶栏 + 侧栏 + 内容区)golden × 三主题。 -/// 锁住导航 chrome 随主题换色——这是最显眼的主题面,之前各屏 golden 都不含外壳。 -/// 需 MaterialApp.router(外壳依赖 StatefulNavigationShell),故为自定义 golden。 +import '../support/golden_harness.dart'; + +/// design-distill 阶段4:app 外壳(顶栏两级品牌 + 主题器 + 通知铃 + 分组侧栏 + 状态栏) +/// golden × 三主题。照原型 shell.js/statusbar.js 重建后的回归基准 + 与原型保真比对基准。 +/// 与原型对齐:门店=鼎晟酒行/DSJH-001、用户=王经理(管理员)、版本 v1.0.72、库存页激活。 /// 更新基准:flutter test --update-goldens test/golden/app_shell_golden_test.dart class _FakeAuth extends AuthNotifier { @@ -23,7 +26,7 @@ class _FakeAuth extends AuthNotifier { state = const AuthState( user: AuthUser( accessToken: 't', refreshToken: 'r', id: 1, username: 'wang', - realName: '王经理', shopNo: 'DBZB-001', shopId: 1, role: 'admin'), + realName: '王经理', shopNo: 'DSJH-001', shopId: 1, role: 'admin'), ); } } @@ -39,7 +42,7 @@ class _FakeLicense extends LicenseNotifier { } const _shop = ShopInfo( - id: 1, code: 'DBZB-001', name: '鼎昌酒行', + id: 1, code: 'DSJH-001', name: '鼎晟酒行', address: '浙江省杭州市', phone: '0571-88886666', managerName: '王经理'); List _overrides() => [ @@ -55,17 +58,27 @@ List _overrides() => [ isReadonlyProvider.overrideWithValue(false), ]; +// 外壳 nav 引用 9 个分支序号(入/出库·库存·财务·往来·基础数据·设备·设置·关于)。 +// 全部空白内容,仅供外壳渲染;初始定位库存(index 2)以匹配原型激活态。 +StatefulShellBranch _branch(String path) => StatefulShellBranch(routes: [ + GoRoute(path: path, builder: (_, __) => const SizedBox.expand()), + ]); + GoRouter _router() => GoRouter( initialLocation: '/inventory', routes: [ StatefulShellRoute.indexedStack( builder: (ctx, state, shell) => AppShell(navigationShell: shell), branches: [ - StatefulShellBranch(routes: [ - GoRoute( - path: '/inventory', - builder: (_, __) => const SizedBox.expand()), - ]), + _branch('/stock-in'), + _branch('/stock-out'), + _branch('/inventory'), + _branch('/finance'), + _branch('/partners'), + _branch('/products'), + _branch('/devices'), + _branch('/settings'), + _branch('/about'), ], ), ], @@ -74,10 +87,14 @@ GoRouter _router() => GoRouter( void main() { TestWidgetsFlutterBinding.ensureInitialized(); SharedPreferences.setMockInitialValues({}); + // 冻结状态栏时钟,确定化 golden(关走字,避免像素抖动 / pending timer)。 + AppStatusBar.clock = () => DateTime(2026, 7, 13, 9, 30, 15); + AppStatusBar.ticking = false; for (final theme in const ['a', 'b', 'c']) { testWidgets('app shell · theme $theme', (tester) async { - tester.view.physicalSize = const Size(2560, 1600); + await ensureGoldenFonts(); + tester.view.physicalSize = const Size(2560, 1800); // 1280×900 @2x tester.view.devicePixelRatio = 2.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); @@ -86,7 +103,7 @@ void main() { overrides: _overrides(), child: MaterialApp.router( debugShowCheckedModeBanner: false, - theme: buildTheme(theme), + theme: withGoldenFont(buildTheme(theme)), routerConfig: _router(), ), )); diff --git a/client/test/golden/goldens/about_a.png b/client/test/golden/goldens/about_a.png index f7d199f..3a916e4 100644 Binary files a/client/test/golden/goldens/about_a.png and b/client/test/golden/goldens/about_a.png differ diff --git a/client/test/golden/goldens/about_b.png b/client/test/golden/goldens/about_b.png index f86e8da..c49bc11 100644 Binary files a/client/test/golden/goldens/about_b.png and b/client/test/golden/goldens/about_b.png differ diff --git a/client/test/golden/goldens/about_c.png b/client/test/golden/goldens/about_c.png index 82d2424..1d30acb 100644 Binary files a/client/test/golden/goldens/about_c.png and b/client/test/golden/goldens/about_c.png differ diff --git a/client/test/golden/goldens/about_mobile_a.png b/client/test/golden/goldens/about_mobile_a.png index d57e8df..b19ec70 100644 Binary files a/client/test/golden/goldens/about_mobile_a.png and b/client/test/golden/goldens/about_mobile_a.png differ diff --git a/client/test/golden/goldens/about_mobile_b.png b/client/test/golden/goldens/about_mobile_b.png index 32a7a05..0f6ae25 100644 Binary files a/client/test/golden/goldens/about_mobile_b.png and b/client/test/golden/goldens/about_mobile_b.png differ diff --git a/client/test/golden/goldens/about_mobile_c.png b/client/test/golden/goldens/about_mobile_c.png index a425620..61ea1f6 100644 Binary files a/client/test/golden/goldens/about_mobile_c.png and b/client/test/golden/goldens/about_mobile_c.png differ diff --git a/client/test/golden/goldens/app_shell_a.png b/client/test/golden/goldens/app_shell_a.png index 30d2d72..7fa3218 100644 Binary files a/client/test/golden/goldens/app_shell_a.png and b/client/test/golden/goldens/app_shell_a.png differ diff --git a/client/test/golden/goldens/app_shell_b.png b/client/test/golden/goldens/app_shell_b.png index dbd4e3c..4798375 100644 Binary files a/client/test/golden/goldens/app_shell_b.png and b/client/test/golden/goldens/app_shell_b.png differ diff --git a/client/test/golden/goldens/app_shell_c.png b/client/test/golden/goldens/app_shell_c.png index 6e359e7..82008c5 100644 Binary files a/client/test/golden/goldens/app_shell_c.png and b/client/test/golden/goldens/app_shell_c.png differ diff --git a/client/test/golden/goldens/device_management_a.png b/client/test/golden/goldens/device_management_a.png index bd81873..f74057e 100644 Binary files a/client/test/golden/goldens/device_management_a.png and b/client/test/golden/goldens/device_management_a.png differ diff --git a/client/test/golden/goldens/device_management_b.png b/client/test/golden/goldens/device_management_b.png index 56f6c8f..c58c67b 100644 Binary files a/client/test/golden/goldens/device_management_b.png and b/client/test/golden/goldens/device_management_b.png differ diff --git a/client/test/golden/goldens/device_management_c.png b/client/test/golden/goldens/device_management_c.png index aefd07d..0a94ab1 100644 Binary files a/client/test/golden/goldens/device_management_c.png and b/client/test/golden/goldens/device_management_c.png differ diff --git a/client/test/golden/goldens/device_management_mobile_a.png b/client/test/golden/goldens/device_management_mobile_a.png index 52aadbf..34d0543 100644 Binary files a/client/test/golden/goldens/device_management_mobile_a.png and b/client/test/golden/goldens/device_management_mobile_a.png differ diff --git a/client/test/golden/goldens/device_management_mobile_b.png b/client/test/golden/goldens/device_management_mobile_b.png index a948989..d3c3672 100644 Binary files a/client/test/golden/goldens/device_management_mobile_b.png and b/client/test/golden/goldens/device_management_mobile_b.png differ diff --git a/client/test/golden/goldens/device_management_mobile_c.png b/client/test/golden/goldens/device_management_mobile_c.png index 590ed3e..38ce4be 100644 Binary files a/client/test/golden/goldens/device_management_mobile_c.png and b/client/test/golden/goldens/device_management_mobile_c.png differ diff --git a/client/test/golden/goldens/finance_a.png b/client/test/golden/goldens/finance_a.png index cc2b1d3..2cd2cf8 100644 Binary files a/client/test/golden/goldens/finance_a.png and b/client/test/golden/goldens/finance_a.png differ diff --git a/client/test/golden/goldens/finance_b.png b/client/test/golden/goldens/finance_b.png index a356fc2..dfb5643 100644 Binary files a/client/test/golden/goldens/finance_b.png and b/client/test/golden/goldens/finance_b.png differ diff --git a/client/test/golden/goldens/finance_c.png b/client/test/golden/goldens/finance_c.png index b680fb6..df45007 100644 Binary files a/client/test/golden/goldens/finance_c.png and b/client/test/golden/goldens/finance_c.png differ diff --git a/client/test/golden/goldens/finance_mobile_a.png b/client/test/golden/goldens/finance_mobile_a.png index 0c9dfef..43e49ab 100644 Binary files a/client/test/golden/goldens/finance_mobile_a.png and b/client/test/golden/goldens/finance_mobile_a.png differ diff --git a/client/test/golden/goldens/finance_mobile_b.png b/client/test/golden/goldens/finance_mobile_b.png index d0dfead..79a0255 100644 Binary files a/client/test/golden/goldens/finance_mobile_b.png and b/client/test/golden/goldens/finance_mobile_b.png differ diff --git a/client/test/golden/goldens/finance_mobile_c.png b/client/test/golden/goldens/finance_mobile_c.png index d9f9564..2f9e686 100644 Binary files a/client/test/golden/goldens/finance_mobile_c.png and b/client/test/golden/goldens/finance_mobile_c.png differ diff --git a/client/test/golden/goldens/inventory_list_a.png b/client/test/golden/goldens/inventory_list_a.png index f642f35..ba4e53f 100644 Binary files a/client/test/golden/goldens/inventory_list_a.png and b/client/test/golden/goldens/inventory_list_a.png differ diff --git a/client/test/golden/goldens/inventory_list_b.png b/client/test/golden/goldens/inventory_list_b.png index 8447718..74c1f6d 100644 Binary files a/client/test/golden/goldens/inventory_list_b.png and b/client/test/golden/goldens/inventory_list_b.png differ diff --git a/client/test/golden/goldens/inventory_list_c.png b/client/test/golden/goldens/inventory_list_c.png index eefd218..3c3491c 100644 Binary files a/client/test/golden/goldens/inventory_list_c.png and b/client/test/golden/goldens/inventory_list_c.png differ diff --git a/client/test/golden/goldens/inventory_list_mobile_a.png b/client/test/golden/goldens/inventory_list_mobile_a.png index a1b7c3e..bf6f3a1 100644 Binary files a/client/test/golden/goldens/inventory_list_mobile_a.png and b/client/test/golden/goldens/inventory_list_mobile_a.png differ diff --git a/client/test/golden/goldens/inventory_list_mobile_b.png b/client/test/golden/goldens/inventory_list_mobile_b.png index 4664263..39490b9 100644 Binary files a/client/test/golden/goldens/inventory_list_mobile_b.png and b/client/test/golden/goldens/inventory_list_mobile_b.png differ diff --git a/client/test/golden/goldens/inventory_list_mobile_c.png b/client/test/golden/goldens/inventory_list_mobile_c.png index 2e51ef5..15e8219 100644 Binary files a/client/test/golden/goldens/inventory_list_mobile_c.png and b/client/test/golden/goldens/inventory_list_mobile_c.png differ diff --git a/client/test/golden/goldens/login_a.png b/client/test/golden/goldens/login_a.png index af064b0..8ac5c11 100644 Binary files a/client/test/golden/goldens/login_a.png and b/client/test/golden/goldens/login_a.png differ diff --git a/client/test/golden/goldens/login_b.png b/client/test/golden/goldens/login_b.png index 141a23f..2d45fd6 100644 Binary files a/client/test/golden/goldens/login_b.png and b/client/test/golden/goldens/login_b.png differ diff --git a/client/test/golden/goldens/login_c.png b/client/test/golden/goldens/login_c.png index 8a1d461..3110c24 100644 Binary files a/client/test/golden/goldens/login_c.png and b/client/test/golden/goldens/login_c.png differ diff --git a/client/test/golden/goldens/partners_a.png b/client/test/golden/goldens/partners_a.png index 5e0e619..8a326ae 100644 Binary files a/client/test/golden/goldens/partners_a.png and b/client/test/golden/goldens/partners_a.png differ diff --git a/client/test/golden/goldens/partners_b.png b/client/test/golden/goldens/partners_b.png index 1656108..ba8e28b 100644 Binary files a/client/test/golden/goldens/partners_b.png and b/client/test/golden/goldens/partners_b.png differ diff --git a/client/test/golden/goldens/partners_c.png b/client/test/golden/goldens/partners_c.png index 4dc2634..97b9f23 100644 Binary files a/client/test/golden/goldens/partners_c.png and b/client/test/golden/goldens/partners_c.png differ diff --git a/client/test/golden/goldens/partners_mobile_a.png b/client/test/golden/goldens/partners_mobile_a.png index d15f558..e58563f 100644 Binary files a/client/test/golden/goldens/partners_mobile_a.png and b/client/test/golden/goldens/partners_mobile_a.png differ diff --git a/client/test/golden/goldens/partners_mobile_b.png b/client/test/golden/goldens/partners_mobile_b.png index c088801..19b73a0 100644 Binary files a/client/test/golden/goldens/partners_mobile_b.png and b/client/test/golden/goldens/partners_mobile_b.png differ diff --git a/client/test/golden/goldens/partners_mobile_c.png b/client/test/golden/goldens/partners_mobile_c.png index d44d68b..a8f22bb 100644 Binary files a/client/test/golden/goldens/partners_mobile_c.png and b/client/test/golden/goldens/partners_mobile_c.png differ diff --git a/client/test/golden/goldens/product_detail_a.png b/client/test/golden/goldens/product_detail_a.png index f059465..016908c 100644 Binary files a/client/test/golden/goldens/product_detail_a.png and b/client/test/golden/goldens/product_detail_a.png differ diff --git a/client/test/golden/goldens/product_detail_b.png b/client/test/golden/goldens/product_detail_b.png index e10cd1f..ec5615a 100644 Binary files a/client/test/golden/goldens/product_detail_b.png and b/client/test/golden/goldens/product_detail_b.png differ diff --git a/client/test/golden/goldens/product_detail_c.png b/client/test/golden/goldens/product_detail_c.png index fa58997..64ccee3 100644 Binary files a/client/test/golden/goldens/product_detail_c.png and b/client/test/golden/goldens/product_detail_c.png differ diff --git a/client/test/golden/goldens/products_archive_a.png b/client/test/golden/goldens/products_archive_a.png index ee99163..a583698 100644 Binary files a/client/test/golden/goldens/products_archive_a.png and b/client/test/golden/goldens/products_archive_a.png differ diff --git a/client/test/golden/goldens/products_archive_b.png b/client/test/golden/goldens/products_archive_b.png index a2afac2..f6700f1 100644 Binary files a/client/test/golden/goldens/products_archive_b.png and b/client/test/golden/goldens/products_archive_b.png differ diff --git a/client/test/golden/goldens/products_archive_c.png b/client/test/golden/goldens/products_archive_c.png index a72660c..24e2df0 100644 Binary files a/client/test/golden/goldens/products_archive_c.png and b/client/test/golden/goldens/products_archive_c.png differ diff --git a/client/test/golden/goldens/register_a.png b/client/test/golden/goldens/register_a.png index 5a6f93f..ab7a435 100644 Binary files a/client/test/golden/goldens/register_a.png and b/client/test/golden/goldens/register_a.png differ diff --git a/client/test/golden/goldens/register_b.png b/client/test/golden/goldens/register_b.png index f2a9da3..292664e 100644 Binary files a/client/test/golden/goldens/register_b.png and b/client/test/golden/goldens/register_b.png differ diff --git a/client/test/golden/goldens/register_c.png b/client/test/golden/goldens/register_c.png index 131f649..23e0284 100644 Binary files a/client/test/golden/goldens/register_c.png and b/client/test/golden/goldens/register_c.png differ diff --git a/client/test/golden/goldens/settings_params_a.png b/client/test/golden/goldens/settings_params_a.png index 6e45ba9..2f0ba0c 100644 Binary files a/client/test/golden/goldens/settings_params_a.png and b/client/test/golden/goldens/settings_params_a.png differ diff --git a/client/test/golden/goldens/settings_params_b.png b/client/test/golden/goldens/settings_params_b.png index 9c72437..088274c 100644 Binary files a/client/test/golden/goldens/settings_params_b.png and b/client/test/golden/goldens/settings_params_b.png differ diff --git a/client/test/golden/goldens/settings_params_c.png b/client/test/golden/goldens/settings_params_c.png index 1adcf7b..bde71f8 100644 Binary files a/client/test/golden/goldens/settings_params_c.png and b/client/test/golden/goldens/settings_params_c.png differ diff --git a/client/test/golden/goldens/stock_in_form_a.png b/client/test/golden/goldens/stock_in_form_a.png index 9b80a1a..5d97383 100644 Binary files a/client/test/golden/goldens/stock_in_form_a.png and b/client/test/golden/goldens/stock_in_form_a.png differ diff --git a/client/test/golden/goldens/stock_in_form_b.png b/client/test/golden/goldens/stock_in_form_b.png index 8020d38..bfa33ca 100644 Binary files a/client/test/golden/goldens/stock_in_form_b.png and b/client/test/golden/goldens/stock_in_form_b.png differ diff --git a/client/test/golden/goldens/stock_in_form_c.png b/client/test/golden/goldens/stock_in_form_c.png index b113b8a..0d3b3dd 100644 Binary files a/client/test/golden/goldens/stock_in_form_c.png and b/client/test/golden/goldens/stock_in_form_c.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_a.png b/client/test/golden/goldens/stock_in_form_mobile_a.png index 5282ef4..ab7faf1 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_a.png and b/client/test/golden/goldens/stock_in_form_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_b.png b/client/test/golden/goldens/stock_in_form_mobile_b.png index 3d09519..e2fac58 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_b.png and b/client/test/golden/goldens/stock_in_form_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_c.png b/client/test/golden/goldens/stock_in_form_mobile_c.png index 3b4581f..e46dd5c 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_c.png and b/client/test/golden/goldens/stock_in_form_mobile_c.png differ diff --git a/client/test/golden/goldens/stock_in_list_a.png b/client/test/golden/goldens/stock_in_list_a.png index 1c224b6..c2ecd3d 100644 Binary files a/client/test/golden/goldens/stock_in_list_a.png and b/client/test/golden/goldens/stock_in_list_a.png differ diff --git a/client/test/golden/goldens/stock_in_list_b.png b/client/test/golden/goldens/stock_in_list_b.png index 9fe2b9f..7c583e5 100644 Binary files a/client/test/golden/goldens/stock_in_list_b.png and b/client/test/golden/goldens/stock_in_list_b.png differ diff --git a/client/test/golden/goldens/stock_in_list_c.png b/client/test/golden/goldens/stock_in_list_c.png index 1f3efb3..16fe1b4 100644 Binary files a/client/test/golden/goldens/stock_in_list_c.png and b/client/test/golden/goldens/stock_in_list_c.png differ diff --git a/client/test/golden/goldens/stock_in_list_mobile_a.png b/client/test/golden/goldens/stock_in_list_mobile_a.png index 47c0096..7e352ae 100644 Binary files a/client/test/golden/goldens/stock_in_list_mobile_a.png and b/client/test/golden/goldens/stock_in_list_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_in_list_mobile_b.png b/client/test/golden/goldens/stock_in_list_mobile_b.png index c14e8c3..ba6d786 100644 Binary files a/client/test/golden/goldens/stock_in_list_mobile_b.png and b/client/test/golden/goldens/stock_in_list_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_in_list_mobile_c.png b/client/test/golden/goldens/stock_in_list_mobile_c.png index 7d71962..60999fb 100644 Binary files a/client/test/golden/goldens/stock_in_list_mobile_c.png and b/client/test/golden/goldens/stock_in_list_mobile_c.png differ diff --git a/client/test/golden/goldens/stock_out_form_a.png b/client/test/golden/goldens/stock_out_form_a.png index 70c1a99..bc90142 100644 Binary files a/client/test/golden/goldens/stock_out_form_a.png and b/client/test/golden/goldens/stock_out_form_a.png differ diff --git a/client/test/golden/goldens/stock_out_form_b.png b/client/test/golden/goldens/stock_out_form_b.png index d33e68a..b068752 100644 Binary files a/client/test/golden/goldens/stock_out_form_b.png and b/client/test/golden/goldens/stock_out_form_b.png differ diff --git a/client/test/golden/goldens/stock_out_form_c.png b/client/test/golden/goldens/stock_out_form_c.png index 333c96a..2dc71ef 100644 Binary files a/client/test/golden/goldens/stock_out_form_c.png and b/client/test/golden/goldens/stock_out_form_c.png differ diff --git a/client/test/golden/goldens/stock_out_form_mobile_a.png b/client/test/golden/goldens/stock_out_form_mobile_a.png index b0af25b..036918f 100644 Binary files a/client/test/golden/goldens/stock_out_form_mobile_a.png and b/client/test/golden/goldens/stock_out_form_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_out_form_mobile_b.png b/client/test/golden/goldens/stock_out_form_mobile_b.png index ade5865..0215f4b 100644 Binary files a/client/test/golden/goldens/stock_out_form_mobile_b.png and b/client/test/golden/goldens/stock_out_form_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_out_form_mobile_c.png b/client/test/golden/goldens/stock_out_form_mobile_c.png index 41cd007..4878990 100644 Binary files a/client/test/golden/goldens/stock_out_form_mobile_c.png and b/client/test/golden/goldens/stock_out_form_mobile_c.png differ diff --git a/client/test/golden/goldens/stock_out_list_a.png b/client/test/golden/goldens/stock_out_list_a.png index da6dd0d..88f64f2 100644 Binary files a/client/test/golden/goldens/stock_out_list_a.png and b/client/test/golden/goldens/stock_out_list_a.png differ diff --git a/client/test/golden/goldens/stock_out_list_b.png b/client/test/golden/goldens/stock_out_list_b.png index dc32df3..0fdab20 100644 Binary files a/client/test/golden/goldens/stock_out_list_b.png and b/client/test/golden/goldens/stock_out_list_b.png differ diff --git a/client/test/golden/goldens/stock_out_list_c.png b/client/test/golden/goldens/stock_out_list_c.png index d0bfb52..ade7de2 100644 Binary files a/client/test/golden/goldens/stock_out_list_c.png and b/client/test/golden/goldens/stock_out_list_c.png differ diff --git a/client/test/golden/goldens/stock_out_list_mobile_a.png b/client/test/golden/goldens/stock_out_list_mobile_a.png index c6a2da6..d1098ce 100644 Binary files a/client/test/golden/goldens/stock_out_list_mobile_a.png and b/client/test/golden/goldens/stock_out_list_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_out_list_mobile_b.png b/client/test/golden/goldens/stock_out_list_mobile_b.png index 272346a..6886cff 100644 Binary files a/client/test/golden/goldens/stock_out_list_mobile_b.png and b/client/test/golden/goldens/stock_out_list_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_out_list_mobile_c.png b/client/test/golden/goldens/stock_out_list_mobile_c.png index 6e54d79..015d052 100644 Binary files a/client/test/golden/goldens/stock_out_list_mobile_c.png and b/client/test/golden/goldens/stock_out_list_mobile_c.png differ diff --git a/client/test/golden/proto/app_shell_a.png b/client/test/golden/proto/app_shell_a.png new file mode 100644 index 0000000..8b2d952 Binary files /dev/null and b/client/test/golden/proto/app_shell_a.png differ diff --git a/client/test/support/golden_harness.dart b/client/test/support/golden_harness.dart index e4a457b..2a44101 100644 --- a/client/test/support/golden_harness.dart +++ b/client/test/support/golden_harness.dart @@ -12,6 +12,17 @@ Future ensureGoldenFonts() async { final loader = FontLoader('NotoSansSC') ..addFont(rootBundle.load('assets/fonts/NotoSansSC.ttf')); await loader.load(); + // 真实 Material 图标字体(否则 golden 里图标渲染成豆腐块 □)。 + for (final key in const [ + 'fonts/MaterialIcons-Regular.otf', + 'packages/flutter/fonts/MaterialIcons-Regular.otf', + ]) { + try { + final f = FontLoader('MaterialIcons')..addFont(rootBundle.load(key)); + await f.load(); + break; + } catch (_) {/* 换下一个 asset key */} + } _fontsLoaded = true; }