import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:go_router/go_router.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'dart:async'; import 'dart:io' show Platform; import 'package:speech_to_text/speech_to_text.dart' as stt; import 'package:speech_to_text/speech_recognition_error.dart'; import 'package:permission_handler/permission_handler.dart'; import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; import '../models/message.dart'; import '../services/vosk_voice_service.dart'; import '../theme.dart'; import '../l10n/app_localizations.dart'; import '../widgets/chart_block.dart'; import '../widgets/markdown_message.dart'; class HomePage extends ConsumerStatefulWidget { const HomePage({super.key}); @override ConsumerState createState() => _HomePageState(); } class _HomePageState extends ConsumerState { final _messageController = TextEditingController(); final _scrollController = ScrollController(); final stt.SpeechToText _speech = stt.SpeechToText(); bool _speechAvailable = false; bool _isListening = false; bool _voskInitializing = false; String _voiceBaseText = ''; StreamSubscription? _voskPartialSub; StreamSubscription? _voskResultSub; StreamSubscription? _voskErrorSub; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToBottom(); if (Platform.isAndroid) { _speechAvailable = true; } }); } @override void dispose() { _speech.cancel(); _voskPartialSub?.cancel(); _voskResultSub?.cancel(); _voskErrorSub?.cancel(); if (Platform.isAndroid) { VoskVoiceService.instance.stop(); } _messageController.dispose(); _scrollController.dispose(); super.dispose(); } Future _initSpeech() async { try { final available = await _speech.initialize( onStatus: (status) { if (!mounted) return; if (status == 'notListening' || status == 'done') { setState(() => _isListening = false); } }, onError: (SpeechRecognitionError err) { if (!mounted) return; debugPrint('[iOS-STT] error: ${err.errorMsg} permanent=${err.permanent}'); setState(() => _isListening = false); }, debugLogging: true, finalTimeout: const Duration(seconds: 5), ); debugPrint('[iOS-STT] initialize -> $available'); if (mounted) setState(() => _speechAvailable = available); } catch (e) { debugPrint('[iOS-STT] initialize threw $e'); if (mounted) setState(() => _speechAvailable = false); } } String _resolveSpeechLocaleId() { final lang = Localizations.localeOf(context).languageCode; switch (lang) { case 'zh': return 'zh_CN'; case 'th': return 'th_TH'; default: return 'en_US'; } } Future _toggleListening() async { final l10n = AppLocalizations.of(context)!; if (Platform.isAndroid) { await _toggleListeningVosk(l10n); return; } await _toggleListeningIOS(l10n); } Future _toggleListeningVosk(AppLocalizations l10n) async { if (_isListening) { await VoskVoiceService.instance.stop(); if (mounted) setState(() => _isListening = false); return; } final micStatus = await Permission.microphone.status; var granted = micStatus.isGranted; if (!granted && !micStatus.isPermanentlyDenied) { final r = await Permission.microphone.request(); granted = r.isGranted; if (!granted && r.isPermanentlyDenied) { _showPermissionSettingsSnack(l10n); return; } } else if (micStatus.isPermanentlyDenied) { _showPermissionSettingsSnack(l10n); return; } if (!granted) { _showVoiceSnack(l10n.voicePermissionDenied); return; } if (_voskInitializing) return; if (!VoskVoiceService.instance.isReady) { setState(() => _voskInitializing = true); _showVoiceLoadingSnack(l10n); final ok = await VoskVoiceService.instance.ensureReady(); if (mounted) setState(() => _voskInitializing = false); if (!ok) { if (mounted) { ScaffoldMessenger.of(context).hideCurrentSnackBar(); _showVoiceSnack(l10n.voiceError); } return; } if (mounted) ScaffoldMessenger.of(context).hideCurrentSnackBar(); } _voiceBaseText = _messageController.text; _voskPartialSub ??= VoskVoiceService.instance.partialStream.listen(_onVoskText); _voskResultSub ??= VoskVoiceService.instance.resultStream.listen(_onVoskText); _voskErrorSub ??= VoskVoiceService.instance.errorStream.listen((_) { if (!mounted) return; setState(() => _isListening = false); _showVoiceSnack(l10n.voiceError); }); final started = await VoskVoiceService.instance.start(); if (!started) { if (mounted) _showVoiceSnack(l10n.voiceError); return; } if (mounted) setState(() => _isListening = true); } void _onVoskText(String text) { if (!mounted || text.isEmpty) return; final cleaned = text.replaceAll(' ', ''); final next = _voiceBaseText.isEmpty ? cleaned : '$_voiceBaseText$cleaned'; _messageController.value = TextEditingValue( text: next, selection: TextSelection.collapsed(offset: next.length), ); } Future _toggleListeningIOS(AppLocalizations l10n) async { if (_isListening) { await _speech.stop(); if (mounted) setState(() => _isListening = false); return; } final micStatus = await Permission.microphone.status; debugPrint('[iOS-STT] mic status before request: $micStatus'); var micGranted = micStatus.isGranted; if (!micGranted && !micStatus.isPermanentlyDenied) { final r = await Permission.microphone.request(); debugPrint('[iOS-STT] mic status after request: $r'); micGranted = r.isGranted; if (!micGranted && r.isPermanentlyDenied) { _showPermissionSettingsSnack(l10n); return; } } else if (micStatus.isPermanentlyDenied) { _showPermissionSettingsSnack(l10n); return; } if (!micGranted) { _showVoiceSnack(l10n.voicePermissionDenied); return; } if (!_speechAvailable) { await _initSpeech(); if (!_speechAvailable) { _showVoiceSnack(l10n.voiceUnavailable); return; } } _voiceBaseText = _messageController.text; setState(() => _isListening = true); try { await _speech.listen( onResult: (result) { final words = result.recognizedWords; if (!mounted) return; final next = _voiceBaseText.isEmpty ? words : (words.isEmpty ? _voiceBaseText : '$_voiceBaseText $words'); _messageController.value = TextEditingValue( text: next, selection: TextSelection.collapsed(offset: next.length), ); }, listenFor: const Duration(seconds: 30), pauseFor: const Duration(seconds: 3), localeId: _resolveSpeechLocaleId(), listenOptions: stt.SpeechListenOptions( partialResults: true, cancelOnError: true, listenMode: stt.ListenMode.dictation, ), ); } catch (e) { debugPrint('[iOS-STT] listen threw $e'); if (mounted) { setState(() => _isListening = false); _showVoiceSnack(l10n.voiceError); } } } void _showVoiceLoadingSnack(AppLocalizations l10n) { if (!mounted) return; ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( behavior: SnackBarBehavior.floating, backgroundColor: AppColors.textPrimary.withOpacity(0.9), margin: const EdgeInsets.fromLTRB(16, 0, 16, 90), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), duration: const Duration(seconds: 10), content: Row( children: [ const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white), ), ), const SizedBox(width: 12), Expanded( child: Text( l10n.voiceModelLoading, style: const TextStyle(color: Colors.white, fontSize: 13), ), ), ], ), ), ); } void _showPermissionSettingsSnack(AppLocalizations l10n) { if (!mounted) return; ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( behavior: SnackBarBehavior.floating, backgroundColor: AppColors.textPrimary.withOpacity(0.92), margin: const EdgeInsets.fromLTRB(16, 0, 16, 90), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), duration: const Duration(seconds: 4), content: Row( children: [ const Icon(Icons.mic_off, color: Colors.white, size: 18), const SizedBox(width: 8), Expanded( child: Text( l10n.voicePermissionDenied, style: const TextStyle(color: Colors.white, fontSize: 13), ), ), ], ), action: SnackBarAction( label: l10n.settings, textColor: AppColors.primaryLight, onPressed: () { openAppSettings(); }, ), ), ); } void _showVoiceSnack(String message) { if (!mounted) return; ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( behavior: SnackBarBehavior.floating, backgroundColor: AppColors.textPrimary.withOpacity(0.9), margin: const EdgeInsets.fromLTRB(16, 0, 16, 90), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), duration: const Duration(seconds: 2), content: Row( children: [ const Icon(Icons.mic_off, color: Colors.white, size: 18), const SizedBox(width: 8), Expanded( child: Text( message, style: const TextStyle(color: Colors.white, fontSize: 13), ), ), ], ), ), ); } void _scrollToBottom() { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } } void _sendMessage() { final text = _messageController.text.trim(); if (text.isEmpty) return; _messageController.clear(); FocusScope.of(context).unfocus(); ref.read(chatProvider.notifier).sendMessage(text).then((_) { Future.delayed(const Duration(milliseconds: 100), _scrollToBottom); }); setState(() {}); } void _openScanner() { Navigator.of(context).push( MaterialPageRoute( builder: (_) => const ScannerPage(), ), ); } List<_NotificationItem> _buildNotificationItems(BuildContext context) { return [ _NotificationItem( icon: Icons.assignment_outlined, color: const Color(0xFFF59E0B), title: '新工单', subtitle: '待接单工单提醒', count: 3, onTap: () => context.push('/order-work'), ), _NotificationItem( icon: Icons.receipt_long_outlined, color: const Color(0xFF3B82F6), title: '新订单', subtitle: '待核销订单提醒', count: 2, onTap: () => context.push('/order-work?tab=order'), ), _NotificationItem( icon: Icons.campaign_outlined, color: const Color(0xFF8B5CF6), title: '系统通知', subtitle: '系统公告与活动消息', count: 1, onTap: () => context.push('/messages'), ), ]; } Widget _buildNotificationButton(BuildContext context) { final items = _buildNotificationItems(context); final total = items.fold(0, (sum, e) => sum + e.count); return Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ IconButton( onPressed: () => _showNotifications(context, items), icon: Icon( Icons.notifications_none_outlined, size: 22, color: AppColors.textSecondary, ), ), if (total > 0) Positioned( right: 6, top: 6, child: IgnorePointer( child: Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ Positioned.fill( child: Container( decoration: BoxDecoration( color: AppColors.error, borderRadius: BorderRadius.circular(9), ), ) .animate(onPlay: (c) => c.repeat()) .scaleXY( begin: 1.0, end: 2.1, duration: 1400.ms, curve: Curves.easeOut, ) .fade( begin: 0.55, end: 0, duration: 1400.ms, curve: Curves.easeOut, ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 4), constraints: const BoxConstraints(minWidth: 17, minHeight: 17), decoration: BoxDecoration( color: AppColors.error, borderRadius: BorderRadius.circular(9), border: Border.all(color: AppColors.surface, width: 1.5), boxShadow: [ BoxShadow( color: AppColors.error.withOpacity(0.35), blurRadius: 4, offset: const Offset(0, 1), ), ], ), child: Center( child: Text( total > 99 ? '99+' : '$total', style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.w700, height: 1.1, ), ), ), ) .animate(onPlay: (c) => c.repeat(reverse: true)) .scaleXY( begin: 1.0, end: 1.12, duration: 1400.ms, curve: Curves.easeInOut, ), ], ), ), ), ], ); } void _showNotifications(BuildContext context, List<_NotificationItem> items) { showModalBottomSheet( context: context, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (ctx) { return Container( decoration: BoxDecoration( color: AppColors.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), ), child: SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( width: 40, height: 4, decoration: BoxDecoration( color: AppColors.divider, borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 18), Row( children: [ Text( '消息中心', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary, ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: AppColors.primary.withOpacity(0.1), borderRadius: BorderRadius.circular(10), ), child: Text( '${items.fold(0, (s, e) => s + e.count)} 条未读', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.primary, ), ), ), ], ), const SizedBox(height: 16), ...items.map( (item) => Padding( padding: const EdgeInsets.only(bottom: 10), child: Material( color: AppColors.background, borderRadius: BorderRadius.circular(14), child: InkWell( borderRadius: BorderRadius.circular(14), onTap: () { Navigator.of(ctx).pop(); item.onTap(); }, child: Padding( padding: const EdgeInsets.all(14), child: Row( children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: item.color.withOpacity(0.12), borderRadius: BorderRadius.circular(12), ), child: Icon(item.icon, color: item.color, size: 22), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.title, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), const SizedBox(height: 2), Text( item.subtitle, style: TextStyle( fontSize: 12.5, color: AppColors.textSecondary, ), ), ], ), ), if (item.count > 0) Container( margin: const EdgeInsets.only(left: 8), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: AppColors.error, borderRadius: BorderRadius.circular(10), ), child: Text( '${item.count}', style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700, ), ), ), Icon( Icons.chevron_right, color: AppColors.textTertiary, size: 20, ), ], ), ), ), ), ), ), ], ), ), ), ); }, ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final authState = ref.watch(authProvider); final chatState = ref.watch(chatProvider); final user = authState.user; ref.listen(chatProvider, (prev, next) { if (next.messages.length != (prev?.messages.length ?? 0)) { Future.delayed(const Duration(milliseconds: 100), _scrollToBottom); } }); return Scaffold( backgroundColor: AppColors.background, appBar: AppBar( backgroundColor: AppColors.surface, elevation: 0, automaticallyImplyLeading: false, title: Row( children: [ Container( width: 36, height: 36, decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), ), clipBehavior: Clip.antiAlias, child: Image.asset('assets/logo.png', fit: BoxFit.cover), ), const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.aiAssistant, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), Row( children: [ Container( width: 6, height: 6, decoration: const BoxDecoration( color: AppColors.success, shape: BoxShape.circle, ), ), const SizedBox(width: 4), Text( l10n.online, style: TextStyle(fontSize: 11, color: AppColors.textSecondary), ), ], ), ], ), ], ), actions: [ if (user != null) ...[ _buildNotificationButton(context), IconButton( onPressed: () => context.push('/settings'), icon: Icon( Icons.settings_outlined, size: 22, color: AppColors.textSecondary, ), ), const SizedBox(width: 4), ], ], ), body: Column( children: [ Expanded( child: ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(16), itemCount: chatState.messages.length, itemBuilder: (context, index) { final message = chatState.messages[index]; return _buildMessageBubble(message, index, l10n); }, ), ), Container( decoration: BoxDecoration( color: AppColors.surface, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.04), blurRadius: 12, offset: const Offset(0, -4), ), ], ), child: Column( children: [ _buildQuickActions(l10n), _buildInputArea(l10n), ], ), ), ], ), ); } Widget _buildMessageBubble(ChatMessage message, int index, AppLocalizations l10n) { final isUser = message.sender == MessageSender.user; if (message.type == MessageType.loading) { return Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: AppColors.chatAiBubble, borderRadius: BorderRadius.circular(16), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( AppColors.primary.withOpacity(0.6), ), ), ), const SizedBox(width: 8), Text( l10n.thinking, style: TextStyle( fontSize: 14, color: AppColors.textSecondary, ), ), ], ), ), ); } return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 16), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.82, ), child: Column( crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: isUser ? AppColors.chatUserBubble : AppColors.chatAiBubble, borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), bottomLeft: Radius.circular(isUser ? 16 : 4), bottomRight: Radius.circular(isUser ? 4 : 16), ), ), child: isUser || message.type == MessageType.text ? Text( message.content, style: TextStyle( fontSize: 15, color: isUser ? Colors.white : AppColors.textPrimary, height: 1.5, ), ) : MarkdownMessage( data: message.content, builders: {'code': ChartCodeBuilder()}, styleSheet: MarkdownStyleSheet( p: TextStyle( fontSize: 15, color: AppColors.textPrimary, height: 1.6, ), code: TextStyle( fontSize: 13, color: AppColors.primary, backgroundColor: AppColors.primary.withOpacity(0.08), fontFamily: 'monospace', ), codeblockDecoration: const BoxDecoration( color: Colors.transparent, ), codeblockPadding: EdgeInsets.zero, blockquote: TextStyle( fontSize: 14, color: AppColors.textSecondary, fontStyle: FontStyle.italic, ), blockquoteDecoration: BoxDecoration( border: Border( left: BorderSide( color: AppColors.primary.withOpacity(0.4), width: 3, ), ), ), tableHead: TextStyle( fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), tableBody: TextStyle( color: AppColors.textSecondary, ), tableBorder: TableBorder.all( color: AppColors.divider, width: 1, ), tableCellsPadding: const EdgeInsets.all(8), h1: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary, ), h2: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary, ), h3: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), strong: const TextStyle( fontWeight: FontWeight.w600, color: AppColors.primary, ), ), ), ), const SizedBox(height: 4), Text( _formatTime(message.timestamp), style: TextStyle( fontSize: 11, color: AppColors.textTertiary, ), ), ], ), ), ).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0, duration: 300.ms); } Widget _buildQuickActions(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 2), child: Row( children: [ Expanded( child: _QuickAction( icon: Icons.event_note_outlined, label: l10n.eventPublish, color: const Color(0xFF8B5CF6), onTap: () => context.push('/event/publish'), ), ), Expanded( child: _QuickAction( icon: Icons.assignment_outlined, label: l10n.orderWork, color: const Color(0xFFF59E0B), onTap: () => context.push('/order-work'), ), ), Expanded( child: _QuickAction( icon: Icons.qr_code_scanner, label: l10n.verify, color: const Color(0xFF10B981), onTap: () => _openScanner(), ), ), ], ), ); } Widget _buildInputArea(AppLocalizations l10n) { final isAndroid = Theme.of(context).platform == TargetPlatform.android; return Padding( padding: EdgeInsets.fromLTRB(16, 2, 16, isAndroid ? 16 : 4), child: SafeArea( top: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (_isListening) _buildListeningHint(l10n), Row( children: [ Expanded( child: TextField( controller: _messageController, maxLines: null, textInputAction: TextInputAction.send, onSubmitted: (_) => _sendMessage(), decoration: InputDecoration( hintText: _isListening ? l10n.voiceListening : l10n.enterMessage, hintStyle: TextStyle( fontSize: 14, color: _isListening ? AppColors.error.withOpacity(0.85) : AppColors.textTertiary, ), filled: true, fillColor: AppColors.background, border: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.fromLTRB(18, 10, 8, 10), isDense: true, suffixIcon: _buildMicButton(l10n), suffixIconConstraints: const BoxConstraints( minWidth: 36, minHeight: 36, ), ), ), ), const SizedBox(width: 8), GestureDetector( onTap: _sendMessage, child: Container( width: 36, height: 36, decoration: BoxDecoration( gradient: AppGradients.primary, shape: BoxShape.circle, ), child: const Icon( Icons.send, color: Colors.white, size: 17, ), ), ), ], ), ], ), ), ); } Widget _buildMicButton(AppLocalizations l10n) { final listening = _isListening; final tooltip = listening ? l10n.voiceListening : (_speechAvailable ? l10n.aiAssistant : l10n.voiceUnavailable); final icon = Icon( listening ? Icons.mic : Icons.mic_none, color: listening ? AppColors.error : AppColors.textSecondary, size: 22, ); final button = InkWell( onTap: _toggleListening, customBorder: const CircleBorder(), child: Padding( padding: const EdgeInsets.all(7), child: listening ? icon .animate(onPlay: (c) => c.repeat(reverse: true)) .scaleXY( begin: 1.0, end: 1.15, duration: 700.ms, curve: Curves.easeInOut, ) : icon, ), ); return Tooltip(message: tooltip, child: button); } Widget _buildListeningHint(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: AppColors.error.withOpacity(0.1), borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.error.withOpacity(0.25), width: 1), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 8, height: 8, decoration: const BoxDecoration( color: AppColors.error, shape: BoxShape.circle, ), ) .animate(onPlay: (c) => c.repeat(reverse: true)) .fadeIn(begin: 0.35, duration: 600.ms, curve: Curves.easeInOut) .scaleXY(begin: 0.8, end: 1.1, duration: 600.ms), const SizedBox(width: 8), Text( l10n.voiceListening, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: AppColors.error, ), ), ], ), ), const Spacer(), ], ), ); } String _formatTime(DateTime time) { final now = DateTime.now(); if (time.year == now.year && time.month == now.month && time.day == now.day) { return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; } return '${time.month}/${time.day} ${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; } } class _QuickAction extends StatelessWidget { final IconData icon; final String label; final Color color; final VoidCallback onTap; const _QuickAction({ required this.icon, required this.label, required this.color, required this.onTap, }); @override Widget build(BuildContext context) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(14), child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 38, height: 38, decoration: BoxDecoration( color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(12), ), child: Icon(icon, color: color, size: 20), ), const SizedBox(height: 4), Text( label, style: TextStyle( fontSize: 11.5, fontWeight: FontWeight.w500, color: AppColors.textSecondary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ); } } class _NotificationItem { final IconData icon; final Color color; final String title; final String subtitle; final int count; final VoidCallback onTap; const _NotificationItem({ required this.icon, required this.color, required this.title, required this.subtitle, required this.count, required this.onTap, }); } class ScannerPage extends StatefulWidget { const ScannerPage({super.key}); @override State createState() => _ScannerPageState(); } class _ScannerPageState extends State { bool _isScanned = false; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: Colors.black, body: Stack( children: [ MobileScanner( onDetect: (capture) { if (_isScanned) return; final barcodes = capture.barcodes; for (final barcode in barcodes) { final code = barcode.rawValue; if (code != null && code.isNotEmpty) { _isScanned = true; Navigator.of(context).pop(); context.push('/scan-result?code=$code'); break; } } }, ), _buildOverlay(), SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ GestureDetector( onTap: () => Navigator.of(context).pop(), child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.black.withOpacity(0.5), borderRadius: BorderRadius.circular(12), ), child: const Icon(Icons.close, color: Colors.white), ), ), const Spacer(), Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: Colors.black.withOpacity(0.5), borderRadius: BorderRadius.circular(20), ), child: Text( l10n.scanning, style: const TextStyle(color: Colors.white, fontSize: 13), ), ), const Spacer(), const SizedBox(width: 44), ], ), ), ), ], ), ); } Widget _buildOverlay() { return Center( child: Container( width: 260, height: 260, decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), border: Border.all( color: AppColors.primary.withOpacity(0.8), width: 2, ), ), child: Stack( children: [ Positioned( top: 0, left: 0, child: _buildCorner(true, true), ), Positioned( top: 0, right: 0, child: _buildCorner(true, false), ), Positioned( bottom: 0, left: 0, child: _buildCorner(false, true), ), Positioned( bottom: 0, right: 0, child: _buildCorner(false, false), ), ], ), ), ); } Widget _buildCorner(bool top, bool left) { return Container( width: 30, height: 30, decoration: BoxDecoration( border: Border( top: top ? const BorderSide(color: AppColors.primary, width: 4) : BorderSide.none, bottom: !top ? const BorderSide(color: AppColors.primary, width: 4) : BorderSide.none, left: left ? const BorderSide(color: AppColors.primary, width: 4) : BorderSide.none, right: !left ? const BorderSide(color: AppColors.primary, width: 4) : BorderSide.none, ), borderRadius: BorderRadius.only( topLeft: top && left ? const Radius.circular(12) : Radius.zero, topRight: top && !left ? const Radius.circular(12) : Radius.zero, bottomLeft: !top && left ? const Radius.circular(12) : Radius.zero, bottomRight: !top && !left ? const Radius.circular(12) : Radius.zero, ), ), ); } }