import 'dart:async'; import 'package:flutter/material.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/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; 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(const Duration(milliseconds: 150), _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(const Duration(milliseconds: 150), _closeUsername); } }); } 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); } } @override Widget build(BuildContext context) { // 被踢下线 / 会话失效:登出后跳回登录页,弹一次提示并清空 ref.listen(sessionEndedMessageProvider, (prev, next) { if (next != null && next.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(next), backgroundColor: AppTheme.danger)); ref.read(sessionEndedMessageProvider.notifier).state = null; }); } }); 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 Container( width: 80, height: 80, decoration: BoxDecoration( gradient: const LinearGradient( colors: [ AppTheme.primaryLight, AppTheme.primaryDark ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: AppTheme.primary.withAlpha(102), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: const Icon(Icons.wine_bar, color: Colors.white, size: 44), ), 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), 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, ), ), ], ), ), ), ), ); } }