import '../../core/utils/dialog_util.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'dart:async'; import 'package:flutter/foundation.dart' show kIsWeb; import '../../core/auth/auth_state.dart'; import '../../core/config/app_config.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_theme.dart'; import '../../providers/connectivity_provider.dart'; import '../../providers/shop_provider.dart'; import '../../providers/update_provider.dart'; import '../../core/update/app_updater.dart'; import '../../providers/license_provider.dart'; class AppShell extends ConsumerStatefulWidget { final Widget child; const AppShell({super.key, required this.child}); @override ConsumerState createState() => _AppShellState(); } class _AppShellState extends ConsumerState { bool _sidebarExpanded = true; final String _loginTime = DateFormat('HH:mm:ss').format(DateTime.now()); bool _forceDialogShown = false; bool _licenseDialogShown = false; final GlobalKey _scaffoldKey = GlobalKey(); void _showForceUpdateDialog( BuildContext context, AppUpdateInfo info) { if (_forceDialogShown) return; _forceDialogShown = true; showAppDialog( context: context, barrierDismissible: false, builder: (ctx) => PopScope( canPop: false, child: AlertDialog( title: const Row( children: [ Icon(Icons.system_update, color: AppTheme.primary), SizedBox(width: 8), Text('发现新版本'), ], ), content: Text('发现新版本 v${info.latestVersion},需更新后才能继续使用,' '更新以获得最新功能与修复。'), actions: [ ElevatedButton( onPressed: () => startInAppUpdate(context, info), child: const Text('立即更新'), ), ], ), ), ); } final List<_NavItem> _navItems = const [ _NavItem(icon: Icons.input, label: '入库管理', path: '/stock-in'), _NavItem(icon: Icons.output, label: '出库管理', path: '/stock-out'), _NavItem(icon: Icons.inventory_2, label: '库存管理', path: '/inventory'), _NavItem( icon: Icons.account_balance_wallet, label: '财务管理', path: '/finance'), _NavItem(icon: Icons.people, label: '往来单位', path: '/partners'), _NavItem(icon: Icons.category, label: '基础数据', path: '/products'), _NavItem(icon: Icons.settings, label: '系统设置', path: '/settings'), _NavItem(icon: Icons.info_outline, label: '关于我们', path: '/about'), ]; /// 窄屏(手机)侧滑抽屉导航,容纳全部菜单项。 Widget _buildDrawer( BuildContext context, AuthUser? user, String location) { final shopAsync = ref.watch(shopInfoProvider); final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? ''; final logoUrl = shopAsync.valueOrNull?.logoUrl ?? ''; return Drawer( child: Container( color: AppTheme.primaryDark, child: SafeArea( child: Column( children: [ // 抽屉头部:门店 Logo + 名称 + 编号 Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), color: AppTheme.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.map((item) { final isActive = location.startsWith(item.path); return _SidebarItem( item: item, isActive: isActive, expanded: true, onTap: () { Navigator.pop(context); // 关闭抽屉 context.go(item.path); }, ); }).toList(), ), ), ], ), ), ), ); } @override Widget build(BuildContext context) { final user = ref.watch(authStateProvider).user; final isOnline = ref.watch(connectivityProvider); final location = GoRouterState.of(context).matchedLocation; final isMobile = context.isMobile; 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, location) : null, drawerEnableOpenDragGesture: !kIsWeb && isMobile, body: Column( children: [ // Top Bar Container( height: 56, color: AppTheme.primary, padding: const EdgeInsets.symmetric(horizontal: 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), const Spacer(), if (user != null) ...[ // 窄屏隐藏门店号/用户名文字块,仅保留用户菜单,避免顶栏拥挤 if (!isMobile) ...[ MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () => _showShopPanel(context, user, version: appVersion), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.business, color: Colors.white70, size: 14), const SizedBox(width: 4), Text(user.shopNo, style: const TextStyle( color: Colors.white70, fontSize: 13)), ], ), ), ), const SizedBox(width: 20), const Icon(Icons.person_outline, color: Colors.white70, size: 14), const SizedBox(width: 4), Text(user.username, style: const TextStyle( color: Colors.white70, fontSize: 13)), const SizedBox(width: 8), ], PopupMenuButton( 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 PopupMenuDivider(height: 1), const PopupMenuItem( value: 'logout', padding: EdgeInsets.zero, child: _HoverMenuItem( icon: Icons.logout_outlined, label: '退出登录', danger: true, ), ), ], ), const SizedBox(width: 8), ], ], ), ), // Main area Expanded( child: Row( children: [ // Sidebar(仅宽屏;窄屏改用 Drawer) if (!isMobile) AnimatedContainer( duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, width: sidebarWidth, color: AppTheme.primaryDark, child: Column( children: [ Expanded( child: ListView( padding: const EdgeInsets.symmetric(vertical: 8), children: _navItems.map((item) { final isActive = location.startsWith(item.path); return _SidebarItem( item: item, isActive: isActive, expanded: _sidebarExpanded, onTap: () => context.go(item.path), ); }).toList(), ), ), ], ), ), // Content area Expanded( child: Column( children: [ // Update banner(非强制更新) if (updateInfo != null && updateInfo.hasUpdate && !updateInfo.forceUpdate && !updateNotifier.isDismissed) Container( width: double.infinity, color: const Color(0xFFFFF8E1), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 6), child: Row( children: [ const Icon(Icons.system_update, size: 16, color: Color(0xFFF57F17)), const SizedBox(width: 8), Expanded( child: Text( '发现新版本 v${updateInfo.latestVersion},' '建议立即更新以获得最新功能与修复', 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: AppTheme.danger, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 6), child: const Row( children: [ Icon(Icons.wifi_off, size: 16, color: Colors.white), SizedBox(width: 8), Text( '网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新', style: TextStyle( color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500), ), ], ), ), Expanded(child: widget.child), // Status bar(仅宽屏;窄屏隐藏,离线已有顶部横幅提示) 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) : AppTheme.danger, padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ if (!isOnline) ...[ const Icon(Icons.wifi_off, size: 11, color: Colors.white70), if (!iconOnly) ...[ const SizedBox(width: 4), const Text('离线', style: TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600)), ], const _StatusDivider(), ], if (isOnline && user != null) ...[ _StatusItem( icon: Icons.store, text: user.shopNo, iconOnly: iconOnly), const _StatusDivider(), _StatusItem( icon: Icons.person, text: user.username, iconOnly: iconOnly), if (wide) ...[ const _StatusDivider(), _StatusItem( icon: Icons.login, text: '登录时间:$_loginTime'), const _StatusDivider(), const _ClockWidget(), ] else const _StatusDivider(), ], const Spacer(), _StatusItem( icon: isOnline ? Icons.cloud_done_outlined : Icons.cloud_off_outlined, text: isOnline ? '已连接' : '连接已断开', iconOnly: iconOnly, ), const _StatusDivider(), _StatusItem( icon: Icons.info_outline, text: ref .watch(appVersionProvider) .valueOrNull ?? 'v1.0.0', iconOnly: iconOnly), ], ), ); }, ), ], ), ), ], ), ), ], ), ), ); } Widget _buildLicenseBanner(LicenseInfo lic) { final Color bg; final String msg; switch (lic.phase) { case 'locked': bg = AppTheme.danger; msg = '授权已锁定,所有写操作已停用 — 请立即续费或激活新授权码'; case 'readonly': bg = const Color(0xFFB71C1C); msg = '授权已过期,当前为只读模式(剩余宽限期 ${lic.daysRemaining ?? 0} 天后彻底锁定)'; default: // grace bg = const Color(0xFFE65100); msg = '授权将于 ${lic.daysRemaining ?? 0} 天后到期,请及时续费'; } return Container( width: double.infinity, color: bg, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row( children: [ const Icon(Icons.warning_amber_rounded, size: 16, color: Colors.white), const SizedBox(width: 8), Expanded( child: Text(msg, style: const TextStyle(color: Colors.white, fontSize: 13), overflow: TextOverflow.ellipsis), ), TextButton( onPressed: () => context.go('/settings'), style: TextButton.styleFrom(foregroundColor: Colors.white70), child: const Text('去激活'), ), ], ), ); } void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) { final String title; final String body; final Color titleColor; switch (lic.phase) { case 'locked': title = '授权已锁定'; body = '您的授权已到期超过 15 天,所有写操作已停用。\n请前往「设置 → 授权」激活新的授权码,或联系客服续费。'; titleColor = AppTheme.danger; case 'readonly': title = '授权已过期 · 只读模式'; body = '您的授权已过期,系统进入只读模式,无法执行任何写操作。\n到期 15 天后将彻底锁定登录,请尽快续费。'; titleColor = AppTheme.danger; default: // grace title = '授权即将到期'; body = '您的授权将在 ${lic.daysRemaining ?? 0} 天后到期,到期后系统进入只读模式。\n请提前联系客服续费,避免影响正常使用。'; titleColor = Colors.orange[800]!; } showDialog( context: ctx, barrierDismissible: true, builder: (_) => AlertDialog( title: Row(children: [ Icon(Icons.warning_amber_rounded, color: titleColor, size: 20), const SizedBox(width: 8), Text(title, style: TextStyle(color: titleColor, fontSize: 16)), ]), content: Text(body, style: const TextStyle(fontSize: 14)), actions: [ TextButton( onPressed: () => Navigator.pop(_), child: const Text('稍后处理'), ), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: titleColor), onPressed: () { Navigator.pop(_); ctx.go('/settings'); }, child: const Text('立即前往', style: TextStyle(color: Colors.white)), ), ], ), ); } } class _NavItem { final IconData icon; final String label; final String path; const _NavItem( {required this.icon, required this.label, required this.path}); } class _SidebarItem extends StatelessWidget { final _NavItem item; final bool isActive; final bool expanded; final VoidCallback onTap; const _SidebarItem({ required this.item, required this.isActive, required this.expanded, required this.onTap, }); @override Widget build(BuildContext context) { return SizedBox( height: 48, child: Material( color: isActive ? AppTheme.primary.withAlpha(76) : Colors.transparent, child: InkWell( onTap: onTap, hoverColor: Colors.white.withAlpha(13), splashColor: Colors.white.withAlpha(26), highlightColor: Colors.white.withAlpha(13), child: Row( children: [ // Active indicator bar Container( width: 3, height: isActive ? 28 : 0, color: Colors.white, margin: EdgeInsets.only(right: expanded ? 13 : 0), ), if (!isActive) SizedBox(width: expanded ? 16 : 3), Expanded( child: Row( mainAxisAlignment: expanded ? MainAxisAlignment.start : MainAxisAlignment.center, children: [ Icon( item.icon, color: isActive ? Colors.white : Colors.white60, size: 20, ), if (expanded) ...[ const SizedBox(width: 12), Flexible( child: Text( item.label, style: TextStyle( color: isActive ? Colors.white : Colors.white70, fontSize: 14, fontWeight: isActive ? FontWeight.w500 : FontWeight.normal, ), overflow: TextOverflow.ellipsis, ), ), ], ], ), ), ], ), ), ), ); } } class _StatusItem extends StatelessWidget { final IconData icon; final String text; final bool iconOnly; const _StatusItem( {required this.icon, required this.text, this.iconOnly = false}); @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 11, color: Colors.white54), if (!iconOnly) ...[ const SizedBox(width: 4), Text(text, style: const TextStyle(color: Colors.white54, fontSize: 11)), ], ], ); } } class _StatusDivider extends StatelessWidget { const _StatusDivider(); @override Widget build(BuildContext context) { return Container( width: 1, height: 12, color: Colors.white24, margin: const EdgeInsets.symmetric(horizontal: 10), ); } } class _ClockWidget extends StatefulWidget { const _ClockWidget(); @override State<_ClockWidget> createState() => _ClockWidgetState(); } class _ClockWidgetState extends State<_ClockWidget> { late Timer _timer; late String _time; @override void initState() { super.initState(); _time = DateFormat('HH:mm:ss').format(DateTime.now()); _timer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() => _time = DateFormat('HH:mm:ss').format(DateTime.now())); }); } @override void dispose() { _timer.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return _StatusItem(icon: Icons.access_time, text: '当前时间:$_time'); } } void _showShopPanel(BuildContext context, AuthUser u, {String version = 'v1.0.0'}) { showAppDialog( context: context, builder: (ctx) => Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), child: SizedBox( width: ctx.dialogWidth(360), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Header Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), decoration: const BoxDecoration( color: AppTheme.primary, borderRadius: BorderRadius.vertical(top: Radius.circular(10)), ), child: Row( children: [ const Icon(Icons.store, color: Colors.white, size: 20), const SizedBox(width: 10), const Text('门店信息', style: TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), IconButton( onPressed: () => Navigator.pop(ctx), icon: const Icon(Icons.close, color: Colors.white70, size: 18), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), ), // Info rows Padding( padding: const EdgeInsets.all(20), child: Column( children: [ _InfoRow(icon: Icons.tag, label: '门店编号', value: u.shopNo), const SizedBox(height: 14), _InfoRow(icon: Icons.person, label: '登录账号', value: u.username), const SizedBox(height: 14), _InfoRow(icon: Icons.badge_outlined, label: '姓名', value: u.realName), const SizedBox(height: 14), _InfoRow(icon: Icons.info_outline, label: '系统版本', value: version), ], ), ), ], ), ), ), ); } class _ShopButton extends ConsumerWidget { final AuthUser? user; final String version; const _ShopButton({this.user, this.version = 'v1.0.0'}); @override Widget build(BuildContext context, WidgetRef ref) { final shopAsync = ref.watch(shopInfoProvider); final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? ''; final logoUrl = shopAsync.valueOrNull?.logoUrl ?? ''; return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () { if (user != null) _showShopPanel(context, user!, version: version); }, child: Row( mainAxisSize: MainAxisSize.min, children: [ _ShopLogo(logoUrl: logoUrl, shopName: shopName, size: 28), const SizedBox(width: 10), Text( shopName.isEmpty ? '—' : shopName, style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600, letterSpacing: 0.5), ), ], ), ), ); } } /// 门店 Logo:有图片显示网络图片,无图片显示店名首字文字头像。 class _ShopLogo extends StatelessWidget { final String logoUrl; final String shopName; final double size; const _ShopLogo({required this.logoUrl, required this.shopName, this.size = 32}); @override Widget build(BuildContext context) { final radius = BorderRadius.circular(size * 0.19); if (logoUrl.isNotEmpty) { final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl'; return ClipRRect( borderRadius: radius, child: Image.network( fullUrl, width: size, height: size, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _initial(radius), ), ); } return _initial(radius); } Widget _initial(BorderRadius radius) { final initial = shopName.isNotEmpty ? shopName.characters.first : '店'; return Container( width: size, height: size, decoration: BoxDecoration( color: const Color(0xFF0F3057), borderRadius: radius, ), alignment: Alignment.center, child: Text( initial, style: TextStyle( color: Colors.white, fontSize: size * 0.5, fontWeight: FontWeight.w600, ), ), ); } } class _InfoRow extends StatelessWidget { final IconData icon; final String label; final String value; const _InfoRow({required this.icon, required this.label, required this.value}); @override Widget build(BuildContext context) { return Row( children: [ Icon(icon, size: 16, color: AppTheme.textSecondary), const SizedBox(width: 10), SizedBox( width: 72, child: Text(label, style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary)), ), Expanded( child: Text(value, style: const TextStyle( fontSize: 13, color: AppTheme.textPrimary, fontWeight: FontWeight.w500)), ), ], ); } } class _HoverMenuItem extends StatefulWidget { final IconData icon; final String label; final bool danger; const _HoverMenuItem({ required this.icon, required this.label, this.danger = false, }); @override State<_HoverMenuItem> createState() => _HoverMenuItemState(); } class _HoverMenuItemState extends State<_HoverMenuItem> { bool _hovered = false; @override Widget build(BuildContext context) { final color = widget.danger ? const Color(0xFFE53935) : const Color(0xFF333333); final hoverBg = widget.danger ? const Color(0xFFFFF0F0) : const Color(0xFFF0F4FF); return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), cursor: SystemMouseCursors.click, child: AnimatedContainer( duration: const Duration(milliseconds: 120), width: 152, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: _hovered ? hoverBg : Colors.transparent, borderRadius: BorderRadius.circular(4), ), child: Row( children: [ Icon(widget.icon, size: 16, color: color), const SizedBox(width: 10), Text( widget.label, style: TextStyle( fontSize: 14, color: color, fontWeight: FontWeight.w400, ), ), ], ), ), ); } }