Files
jiu/client/lib/screens/shell/app_shell.dart
T
wangjia 11413df3db fix(client): 底部 sheet 打开时横滑改为关 sheet,不再切 tab
- showMSheet 登记「打开中 sheet」关闭器栈(任何方式关闭都注销)
- 壳层横滑手势先查栈:有 sheet 开着 → 从左往右滑关顶层 sheet,
  任何方向都不切 tab / 不退二级屏;无 sheet 才走原 tab 切换/返回逻辑

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:55:31 +08:00

1164 lines
40 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import '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),
_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') 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<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell>
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 = <PopupMenuEntry<String>>[
_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<String>(
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<String?>(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 / 表格横滚)在手势竞技场中优先,互不冲突。
// 切页时按方向播放一次性滑入过渡(_slideCtrldidUpdateWidget 触发)。
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<String> _userMenuItem(
String value, IconData icon, String label, dynamic t) {
return PopupMenuItem<String>(
value: value,
height: 40,
child: Row(
children: [
Icon(icon, size: 16, color: t.menuUserFg),
const SizedBox(width: 9),
Text(label, style: TextStyle(fontSize: 13, color: t.menuUserFg)),
],
),
);
}
// ── 顶部横幅(更新 / 强制更新 / 授权 / 离线):保持原有行为 ───────────────────
List<Widget> _buildBanners(
BuildContext context,
bool isOnline,
AppUpdateInfo? updateInfo,
UpdateNotifier updateNotifier,
LicenseInfo? licenseInfo,
) {
final t = context.tokens;
return [
if (updateInfo != null &&
updateInfo.hasUpdate &&
!updateInfo.forceUpdate &&
!updateNotifier.isDismissed)
Container(
width: double.infinity,
color: t.warnBg,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
Icon(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(
// 窄屏授权入口是独立屏(我的→授权管理),宽屏保持设置授权 tab
onPressed: () => context.go(
context.isMobile ? '/me/license' : '/settings?tab=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' : '/settings?tab=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)),
),
],
);
}
}