import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../core/responsive/responsive.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../core/auth/auth_state.dart'; import '../../core/config/app_config.dart'; import '../../core/config/app_constants.dart'; import '../../core/theme/app_theme.dart'; import '../../core/storage/login_history.dart'; import '../../providers/connectivity_provider.dart'; import '../../repositories/auth_repository.dart'; import '../../widgets/network_retry_button.dart'; class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @override ConsumerState createState() => _LoginScreenState(); } class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); final _shopCodeCtrl = TextEditingController(); final _usernameCtrl = TextEditingController(); final _passwordCtrl = TextEditingController(); final _hotelCodeFocus = FocusNode(); final _usernameFocus = FocusNode(); final _hotelLayerLink = LayerLink(); final _usernameLayerLink = LayerLink(); bool _loading = false; bool _obscure = true; String? _errorMessage; // 客服邮箱(授权续费 / 锁定协助)。与官网 web/_data/site.json 的 support.email 保持一致。 static const _supportEmail = 'yammy2023@163.com'; /// 当前错误是否为「授权锁定」——后端返回固定文案 /// `license locked, please renew or contact support`(见 service.ErrLicenseLocked)。 bool get _isLicenseLocked { final m = _errorMessage?.toLowerCase() ?? ''; return m.contains('license') && (m.contains('lock') || m.contains('expire')); } List _hotelCodeHistory = []; List _usernameHistory = []; OverlayEntry? _hotelEntry; OverlayEntry? _usernameEntry; Timer? _closeHotelTimer; Timer? _closeUsernameTimer; bool get _hotelShowing => _hotelEntry != null; bool get _usernameShowing => _usernameEntry != null; @override void initState() { super.initState(); _loadHistory(); _hotelCodeFocus.addListener(() { if (_hotelCodeFocus.hasFocus) { _closeHotel(); _loadHistory().then((_) { if (!mounted || !_hotelCodeFocus.hasFocus) return; if (_hotelCodeHistory.isNotEmpty) { _openDropdown(_hotelLayerLink, _hotelCodeHistory, _shopCodeCtrl, isHotel: true); } }); } else { // 延迟关闭:让候选词的 onTap(pointer-up)先执行,再关闭下拉框 _closeHotelTimer?.cancel(); _closeHotelTimer = Timer(AppConstants.dropdownCloseDelay, _closeHotel); } }); _usernameFocus.addListener(() { if (_usernameFocus.hasFocus) { _closeUsername(); _loadHistory().then((_) { if (!mounted || !_usernameFocus.hasFocus) return; if (_usernameHistory.isNotEmpty) { _openDropdown(_usernameLayerLink, _usernameHistory, _usernameCtrl, isHotel: false); } }); } else { _closeUsernameTimer?.cancel(); _closeUsernameTimer = Timer(AppConstants.dropdownCloseDelay, _closeUsername); } }); // 处理「进入登录页之前」就已置入的会话失效提示:登出在 login_screen 挂载前就 // 设好了 sessionEndedMessageProvider,而 build 里的 ref.listen 只捕获注册之后的 // 变化,会漏掉这个既有值 → 之前「被顶下线无提示」的根因。这里在首帧主动读一次。 WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final msg = ref.read(sessionEndedMessageProvider); if (msg != null && msg.isNotEmpty) _showSessionEndedDialog(msg); }); } /// 弹出「登录已失效 / 被强制下线」提示弹窗,并清空 provider 避免重复弹。 void _showSessionEndedDialog(String msg) { if (!mounted) return; ref.read(sessionEndedMessageProvider.notifier).state = null; showDialog( context: context, builder: (ctx) => AlertDialog( icon: const Icon(Icons.lock_outline, color: AppTheme.danger), title: const Text('登录已失效'), content: Text(msg), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: const Text('我知道了'), ), ], ), ); } Future _loadHistory() async { final hotels = await LoginHistoryStorage.getHotelCodes(); final users = await LoginHistoryStorage.getUsernames(); if (!mounted) return; setState(() { _hotelCodeHistory = hotels; _usernameHistory = users; }); } void _openDropdown( LayerLink link, List items, TextEditingController ctrl, { required bool isHotel, }) { if (isHotel) { if (_hotelEntry != null) return; _closeUsername(); } else { if (_usernameEntry != null) return; _closeHotel(); } final entry = OverlayEntry( builder: (_) => Stack( children: [ // Transparent backdrop — tap outside closes dropdown Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: isHotel ? _closeHotel : _closeUsername, ), ), // Dropdown positioned below the field CompositedTransformFollower( link: link, targetAnchor: Alignment.bottomLeft, followerAnchor: Alignment.topLeft, showWhenUnlinked: false, child: Material( elevation: 6, borderRadius: const BorderRadius.vertical(bottom: Radius.circular(4)), child: SizedBox( width: context.dialogWidth(320), child: ListView.separated( padding: EdgeInsets.zero, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: items.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (_, i) => _HistoryMenuItem( value: items[i], onSelected: () => _selectHistoryItem(items[i], ctrl, isHotel: isHotel), ), ), ), ), ), ], ), ); if (isHotel) { _hotelEntry = entry; } else { _usernameEntry = entry; } Overlay.of(context).insert(entry); setState(() {}); // refresh arrow icon } void _selectHistoryItem( String value, TextEditingController ctrl, { required bool isHotel, }) { ctrl.value = TextEditingValue( text: value, selection: TextSelection.collapsed(offset: value.length), ); if (isHotel) { _closeHotel(); _hotelCodeFocus.unfocus(); } else { _closeUsername(); _usernameFocus.unfocus(); } setState(() {}); } void _closeHotel() { _hotelEntry?.remove(); _hotelEntry = null; if (mounted) setState(() {}); } void _closeUsername() { _usernameEntry?.remove(); _usernameEntry = null; if (mounted) setState(() {}); } @override void dispose() { _closeHotelTimer?.cancel(); _closeUsernameTimer?.cancel(); _hotelEntry?.remove(); _usernameEntry?.remove(); _shopCodeCtrl.dispose(); _usernameCtrl.dispose(); _passwordCtrl.dispose(); _hotelCodeFocus.dispose(); _usernameFocus.dispose(); super.dispose(); } Future _login() async { if (!_formKey.currentState!.validate()) return; // 登录前先检查连通性 final isOnline = ref.read(connectivityProvider); if (!isOnline) { setState(() => _errorMessage = '服务器不可达,请检查网络连接后重试'); return; } setState(() { _loading = true; _errorMessage = null; }); try { debugPrint('[Login] calling AuthRepository.login...'); final user = await AuthRepository.login( shopCode: _shopCodeCtrl.text.trim(), username: _usernameCtrl.text.trim(), password: _passwordCtrl.text, ); debugPrint('[Login] API success, username=${user.username}'); await LoginHistoryStorage.record( _shopCodeCtrl.text.trim(), _usernameCtrl.text.trim(), ); await ref.read(authStateProvider.notifier).login(user); debugPrint('[Login] notifier.login done, mounted=$mounted, going to /stock-in'); if (mounted) context.go('/stock-in'); debugPrint('[Login] context.go called'); } on AuthException catch (e) { setState(() => _errorMessage = e.message); } catch (e) { setState(() => _errorMessage = '登录失败:$e'); } finally { if (mounted) setState(() => _loading = false); } } Future _openRenewSite() async { await launchUrl( Uri.parse(AppConfig.publicBaseUrl), mode: LaunchMode.externalApplication, ); } Future _copySupportEmail() async { await Clipboard.setData(const ClipboardData(text: _supportEmail)); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('已复制客服邮箱:$_supportEmail')), ); } /// 授权锁定时的可操作提示:告诉用户「去哪里解决」——前往官网续费 / 联系客服。 Widget _buildLicenseLockedNotice() { return Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: AppTheme.danger.withAlpha(15), borderRadius: BorderRadius.circular(6), border: Border.all(color: AppTheme.danger.withAlpha(80)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Row( children: [ Icon(Icons.lock_clock_outlined, color: AppTheme.danger, size: 18), SizedBox(width: 8), Expanded( child: Text( '授权已过期,账号已锁定', style: TextStyle( color: AppTheme.danger, fontSize: 14, fontWeight: FontWeight.w600), ), ), ], ), const SizedBox(height: 6), const Text( '当前门店授权已到期,暂时无法登录。请续费授权后重试,或联系客服协助开通。', style: TextStyle( color: AppTheme.textSecondary, fontSize: 13, height: 1.4), ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ ElevatedButton.icon( onPressed: _openRenewSite, icon: const Icon(Icons.open_in_new, size: 16), label: const Text('前往官网续费'), style: ElevatedButton.styleFrom( backgroundColor: AppTheme.danger, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 10), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4)), ), ), OutlinedButton.icon( onPressed: _copySupportEmail, icon: const Icon(Icons.support_agent_outlined, size: 16), label: const Text('复制客服邮箱'), style: OutlinedButton.styleFrom( foregroundColor: AppTheme.danger, side: BorderSide(color: AppTheme.danger.withAlpha(120)), padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 10), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4)), ), ), ], ), ], ), ); } @override Widget build(BuildContext context) { // 已在登录页时会话再失效(例如停留在登录页期间后台请求被吊销):弹窗提示。 // 进入登录页之前就置入的既有值由 initState 的首帧读取处理。 ref.listen(sessionEndedMessageProvider, (prev, next) { if (next != null && next.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { _showSessionEndedDialog(next); }); } }); return Scaffold( backgroundColor: AppTheme.primaryDark, body: Stack( children: [ Positioned.fill( child: CustomPaint(painter: _BackgroundPainter()), ), SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints( minHeight: MediaQuery.of(context).size.height, ), child: Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 40), child: Card( elevation: 12, shadowColor: Colors.black38, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), child: Container( width: context.dialogWidth(400), padding: const EdgeInsets.all(40), child: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, children: [ // Logo(岩美酒库品牌 mark,与 App 图标一致) Container( width: 80, height: 80, decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), boxShadow: [ BoxShadow( color: AppTheme.primary.withAlpha(102), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(15), child: Image.asset( 'assets/brand/logo_mark.png', width: 80, height: 80, fit: BoxFit.cover, ), ), ), const SizedBox(height: 20), const Text( '岩美酒库', style: TextStyle( fontSize: 24, fontWeight: FontWeight.w700, color: AppTheme.primaryDark, letterSpacing: 1, ), ), const SizedBox(height: 6), const Text( '专业酒水仓储 · 进销存一体化管理', style: TextStyle( fontSize: 13, color: AppTheme.textSecondary), ), const SizedBox(height: 32), // Shop code field CompositedTransformTarget( link: _hotelLayerLink, child: TextFormField( controller: _shopCodeCtrl, focusNode: _hotelCodeFocus, decoration: InputDecoration( labelText: '门店编号', hintText: '请输入门店编号', prefixIcon: const Icon( Icons.store_outlined, size: 20), suffixIcon: _hotelCodeHistory.isNotEmpty ? Icon( _hotelShowing ? Icons.arrow_drop_up : Icons.arrow_drop_down, color: AppTheme.textSecondary, ) : null, ), validator: (v) => (v == null || v.isEmpty) ? '请输入门店编号' : null, textInputAction: TextInputAction.next, ), ), const SizedBox(height: 14), // Username field CompositedTransformTarget( link: _usernameLayerLink, child: TextFormField( controller: _usernameCtrl, focusNode: _usernameFocus, decoration: InputDecoration( labelText: '用户名', hintText: '请输入用户名', prefixIcon: const Icon( Icons.person_outline, size: 20), suffixIcon: _usernameHistory.isNotEmpty ? Icon( _usernameShowing ? Icons.arrow_drop_up : Icons.arrow_drop_down, color: AppTheme.textSecondary, ) : null, ), validator: (v) => (v == null || v.isEmpty) ? '请输入用户名' : null, textInputAction: TextInputAction.next, ), ), const SizedBox(height: 14), // Password TextFormField( controller: _passwordCtrl, obscureText: _obscure, decoration: InputDecoration( labelText: '密码', hintText: '请输入密码', prefixIcon: const Icon(Icons.lock_outline, size: 20), suffixIcon: IconButton( icon: Icon( _obscure ? Icons.visibility_off : Icons.visibility, size: 20), onPressed: () => setState(() => _obscure = !_obscure), ), ), validator: (v) => (v == null || v.isEmpty) ? '请输入密码' : null, onFieldSubmitted: (_) => _login(), ), // 离线提示(connectivityProvider 在 _AppBootstrap 中已预热) if (!ref.watch(connectivityProvider)) ...[ const SizedBox(height: 12), Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), decoration: BoxDecoration( color: const Color(0xFFFFF8E1), borderRadius: BorderRadius.circular(4), border: Border.all( color: const Color(0xFFF57F17) .withAlpha(120)), ), child: const Row( children: [ Icon(Icons.wifi_off, color: Color(0xFFF57F17), size: 16), SizedBox(width: 8), Expanded( child: Text( '服务器不可达,请检查网络连接', style: TextStyle( color: Color(0xFF5D4037), fontSize: 13), ), ), NetworkRetryButton( foreground: Color(0xFF5D4037)), ], ), ), ], // Inline error if (_errorMessage != null) ...[ const SizedBox(height: 12), if (_isLicenseLocked) _buildLicenseLockedNotice() else Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), decoration: BoxDecoration( color: AppTheme.danger.withAlpha(15), borderRadius: BorderRadius.circular(4), border: Border.all( color: AppTheme.danger.withAlpha(80)), ), child: Row( children: [ const Icon(Icons.error_outline, color: AppTheme.danger, size: 16), const SizedBox(width: 8), Expanded( child: Text( _errorMessage!, style: const TextStyle( color: AppTheme.danger, fontSize: 13), ), ), ], ), ), ], const SizedBox(height: 24), SizedBox( width: double.infinity, height: 46, child: ElevatedButton( onPressed: _loading ? null : _login, style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4)), ), child: _loading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white)) : const Text('登 录', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, letterSpacing: 2)), ), ), const SizedBox(height: 12), TextButton( onPressed: () => launchUrl( Uri.parse('${AppConfig.publicBaseUrl}/register/'), mode: LaunchMode.externalApplication, ), child: const Text( '还没有门店账号?前往官网注册', style: TextStyle( fontSize: 15, color: AppTheme.textSecondary, ), ), ), ], ), ), ), ), ), ), ), ), Positioned( bottom: 16, left: 0, right: 0, child: Center( child: Text( '© 2026 岩美酒库 v1.0.0', style: TextStyle( color: Colors.white.withAlpha(102), fontSize: 12), ), ), ), ], ), ); } } class _BackgroundPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = const Color(0xFF1976D2).withAlpha(38) ..style = PaintingStyle.fill; canvas.drawCircle( Offset(size.width * 0.1, size.height * 0.2), 180, paint); canvas.drawCircle( Offset(size.width * 0.9, size.height * 0.8), 220, paint); paint.color = const Color(0xFF0D47A1).withAlpha(26); canvas.drawCircle( Offset(size.width * 0.8, size.height * 0.1), 140, paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } class _HistoryMenuItem extends StatefulWidget { final String value; final VoidCallback onSelected; const _HistoryMenuItem({ required this.value, required this.onSelected, }); @override State<_HistoryMenuItem> createState() => _HistoryMenuItemState(); } class _HistoryMenuItemState extends State<_HistoryMenuItem> { bool _hovered = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), cursor: SystemMouseCursors.click, child: Material( color: _hovered ? const Color(0xFFF0F4FF) : Colors.white, child: InkWell( onTap: widget.onSelected, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ const Icon(Icons.history, size: 14, color: AppTheme.textSecondary), const SizedBox(width: 8), Text( widget.value, style: const TextStyle( fontSize: 14, color: AppTheme.textPrimary, ), ), ], ), ), ), ), ); } }