import 'dart:io'; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; import '../../core/elder_mode_scope.dart'; import '../../core/app_module_visuals.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/chat_provider.dart'; import '../../providers/data_providers.dart'; import '../../providers/elder_mode_provider.dart'; import '../../services/realtime_speech_service.dart'; import '../../widgets/app_toast.dart'; import '../../widgets/health_drawer.dart'; import '../../widgets/keyboard_lift.dart'; import '../diet/diet_capture_page.dart'; import 'widgets/chat_messages_view.dart'; class HomePage extends ConsumerStatefulWidget { const HomePage({super.key}); @override ConsumerState createState() => _HomePageState(); } class _HomePageState extends ConsumerState with WidgetsBindingObserver { final _textCtrl = TextEditingController(); final _scrollCtrl = ScrollController(); final _focusNode = FocusNode(); final _scaffoldKey = GlobalKey(); String? _pickedImagePath; Timer? _notificationTimer; double _conversationDragDistance = 0; bool _voiceMode = false; _SpeechPhase _speechPhase = _SpeechPhase.idle; RealtimeSpeechSession? _speechSession; StreamSubscription? _speechEventSubscription; String _speechText = ''; bool _voicePressActive = false; bool _cancelVoiceOnRelease = false; bool _finishVoiceWhenReady = false; double? _voicePressStartY; void _startConversationDrawerDrag(DragStartDetails details) { _conversationDragDistance = 0; } void _updateConversationDrawerDrag(DragUpdateDetails details) { _conversationDragDistance += details.delta.dx; if (_conversationDragDistance < 0) { _conversationDragDistance = 0; } if (_conversationDragDistance >= 42) { _conversationDragDistance = 0; _scaffoldKey.currentState?.openDrawer(); } } void _endConversationDrawerDrag(DragEndDetails details) { _conversationDragDistance = 0; } @override void initState() { super.initState(); final elderPreferences = ref.read(elderModeProvider).preferences; _voiceMode = elderPreferences.enabled && elderPreferences.voiceInputEnabled; WidgetsBinding.instance.addObserver(this); _scheduleScrollToLatest(); WidgetsBinding.instance.addPostFrameCallback((_) { _checkDueNotifications(); _refreshTodayHealthIfCached(); }); _notificationTimer = Timer.periodic( const Duration(minutes: 2), (_) => _checkDueNotifications(), ); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _checkDueNotifications(); } if (state == AppLifecycleState.paused && _speechPhase != _SpeechPhase.idle) { unawaited(_cancelVoiceRecording()); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _notificationTimer?.cancel(); _textCtrl.dispose(); _scrollCtrl.dispose(); _focusNode.dispose(); unawaited(_speechEventSubscription?.cancel()); unawaited(_speechSession?.cancel()); super.dispose(); } void _sendMessage() { if (ref.read(chatProvider).isStreaming) return; final text = _textCtrl.text.trim(); final imagePath = _pickedImagePath; if (text.isEmpty && imagePath == null) return; _textCtrl.clear(); _dismissKeyboard(); setState(() => _pickedImagePath = null); if (imagePath != null) { ref.read(chatProvider.notifier).sendImage(imagePath, text); } else { ref.read(chatProvider.notifier).sendMessage(text); } } void _refreshTodayHealthIfCached() { if (!ref.read(todayHealthSnapshotProvider).hasValue) return; ref.invalidate(latestHealthProvider); ref.invalidate(medicationReminderProvider); ref.invalidate(currentExercisePlanProvider); } void _scheduleScrollToLatest() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_scrollCtrl.hasClients) return; _scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent); // Lazy list items can refine the final extent after the first layout. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_scrollCtrl.hasClients) return; _scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent); }); }); } @override Widget build(BuildContext context) { final auth = ref.watch(authProvider); final user = auth.user; ref.listen( elderModeProvider.select( (state) => state.isEnabled && state.preferences.voiceInputEnabled, ), (previous, current) { if (previous == current || !mounted) return; if (_speechPhase != _SpeechPhase.idle) { unawaited(_cancelVoiceRecording()); } setState(() => _voiceMode = current); }, ); ref.listen(cameraActionProvider, (prev, next) { if (next == 'camera') { _pickImage(ImageSource.camera); ref.read(cameraActionProvider.notifier).clear(); } else if (next == 'gallery') { _pickImage(ImageSource.gallery); ref.read(cameraActionProvider.notifier).clear(); } }); ref.listen(dietActionProvider, (prev, next) { if (next == 'pickFoodCamera') { _pickFoodImage(ImageSource.camera); ref.read(dietActionProvider.notifier).clear(); } else if (next == 'pickFoodGallery') { _pickFoodImage(ImageSource.gallery); ref.read(dietActionProvider.notifier).clear(); } }); ref.listen(chatProvider.select((state) => state.messages.length), ( previous, current, ) { if (current <= (previous ?? 0)) return; _scheduleScrollToLatest(); }); return Scaffold( key: _scaffoldKey, resizeToAvoidBottomInset: false, backgroundColor: const Color(0xFFFFFCFF), drawer: const HealthDrawer(), drawerEnableOpenDragGesture: false, body: DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF0EDFF), Color(0xFFE6ECFF)], ), ), child: SafeArea( child: Column( children: [ _buildHeader(user), Expanded( child: ClipRect( child: KeyboardTranslate( child: Column( children: [ Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, onHorizontalDragStart: _startConversationDrawerDrag, onHorizontalDragUpdate: _updateConversationDrawerDrag, onHorizontalDragEnd: _endConversationDrawerDrag, onHorizontalDragCancel: () => _conversationDragDistance = 0, child: RepaintBoundary( child: _HomeMessages(scrollCtrl: _scrollCtrl), ), ), ), _buildBottomBar(context), ], ), ), ), ), ], ), ), ), ); } Widget _buildHeader(dynamic user) { final elderMode = ElderModeScope.enabledOf(context); final name = (user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'; final unreadCount = ref.watch(notificationUnreadCountProvider).value ?? 0; return Container( padding: EdgeInsets.fromLTRB(14, elderMode ? 10 : 8, 14, 6), child: Row( children: [ Builder( builder: (ctx) { return _HeaderIconButton( icon: LucideIcons.menu, onTap: () => Scaffold.of(ctx).openDrawer(), ); }, ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '小脉健康', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.textHint, ), ), const SizedBox(height: 2), Text( '${_getGreeting()},$name', style: TextStyle( fontSize: elderMode ? 20 : 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), ], ), ), _HeaderIconButton( icon: LucideIcons.bell, badgeCount: unreadCount, onTap: () => pushRoute(ref, 'notifications'), ), ], ), ); } String _getGreeting() { final hour = DateTime.now().hour; if (hour < 9) return '早上好'; if (hour < 12) return '上午好'; if (hour < 18) return '下午好'; return '晚上好'; } static final _agentDefs = [ ActiveAgent.consultation, ActiveAgent.health, ActiveAgent.diet, ActiveAgent.medication, ActiveAgent.report, ActiveAgent.exercise, ]; Widget _buildAgentBar() { final elderMode = ElderModeScope.enabledOf(context); final activeAgent = ref.watch( chatProvider.select((state) => state.activeAgent), ); return SizedBox( height: elderMode ? 58 : 46, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), itemCount: _agentDefs.length, separatorBuilder: (_, _) => const SizedBox(width: 8), itemBuilder: (_, i) { final agent = _agentDefs[i]; final visual = _agentVisual(agent); final selected = activeAgent == agent; return GestureDetector( onTap: () => ref .read(chatProvider.notifier) .triggerAgent(agent, visual.label), child: Container( decoration: BoxDecoration( color: selected ? null : Colors.transparent, gradient: selected ? AppColors.actionOutlineGradient : null, borderRadius: AppRadius.pillBorder, ), padding: const EdgeInsets.all(1.2), child: Container( padding: EdgeInsets.symmetric( horizontal: elderMode ? 15 : 11.8, vertical: elderMode ? 10 : 7.8, ), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.pillBorder, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: elderMode ? 24 : 18, height: elderMode ? 24 : 18, decoration: BoxDecoration( gradient: visual.gradient, borderRadius: BorderRadius.circular(6), ), child: Icon( visual.icon, size: elderMode ? 17 : 13, color: visual.iconColor, ), ), const SizedBox(width: 6), Text( visual.label, style: TextStyle( fontSize: elderMode ? 17 : 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), ], ), ), ), ); }, ), ); } ({String label, IconData icon, LinearGradient gradient, Color iconColor}) _agentVisual(ActiveAgent agent) { ({String label, AppModuleVisual visual}) fromModule( String label, AppModuleVisual visual, ) => (label: label, visual: visual); final module = switch (agent) { ActiveAgent.health => fromModule('记数据', AppModuleVisuals.health), ActiveAgent.diet => fromModule('拍饮食', AppModuleVisuals.diet), ActiveAgent.medication => fromModule('药管家', AppModuleVisuals.medication), ActiveAgent.report => fromModule('报告', AppModuleVisuals.report), ActiveAgent.exercise => fromModule('运动', AppModuleVisuals.exercise), _ => null, }; if (module != null) { return ( label: module.label, icon: module.visual.icon, gradient: module.visual.gradient, iconColor: Colors.white, ); } return switch (agent) { ActiveAgent.consultation => ( label: 'AI问诊', icon: LucideIcons.messageCircle, gradient: AppColors.doctorGradient, iconColor: Colors.white, ), _ => ( label: 'AI问诊', icon: LucideIcons.messageCircle, gradient: AppColors.primaryGradient, iconColor: Colors.white, ), }; } Widget _buildBottomBar(BuildContext context) { return Container( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.28), border: Border( top: BorderSide(color: Colors.white.withValues(alpha: 0.62)), ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8), _buildAgentBar(), const SizedBox(height: 12), if (_pickedImagePath != null) _buildImagePreview(), _buildInputBar(), ], ), ); } Widget _buildImagePreview() { return Container( margin: const EdgeInsets.fromLTRB(14, 8, 14, 0), padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.borderLight), ), child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(10), child: Image.file( File(_pickedImagePath!), width: 48, height: 48, fit: BoxFit.cover, ), ), const SizedBox(width: 10), const Expanded( child: Text( '点击发送图片', style: TextStyle(fontSize: 14, color: AppColors.textSecondary), ), ), GestureDetector( onTap: () => setState(() => _pickedImagePath = null), child: const Icon(Icons.close, size: 20, color: AppColors.textHint), ), ], ), ); } Widget _buildInputBar() { final elderMode = ElderModeScope.enabledOf(context); final isStreaming = ref.watch( chatProvider.select((state) => state.isStreaming), ); return Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), child: Container( padding: EdgeInsets.fromLTRB( 6, elderMode ? 7 : 5, 6, elderMode ? 7 : 5, ), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.pillBorder, ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: _voiceMode ? _buildVoiceInputContents(elderMode, isStreaming) : _buildTextInputContents(elderMode, isStreaming), ), ), ); } List _buildTextInputContents(bool elderMode, bool isStreaming) { return [ _InputCircleButton( icon: LucideIcons.plus, elderMode: elderMode, gradient: AppColors.primaryGradient, iconColor: Colors.white, onTap: () => _showAttachmentPicker(context), ), const SizedBox(width: 6), Expanded( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 112), child: TextField( controller: _textCtrl, focusNode: _focusNode, style: TextStyle( fontSize: elderMode ? 18 : 15, color: AppColors.textPrimary, ), maxLines: null, textInputAction: TextInputAction.newline, decoration: InputDecoration( hintText: '输入你想说的...', filled: false, isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: 4, vertical: elderMode ? 15 : 12, ), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, ), onSubmitted: (_) => _sendMessage(), ), ), ), const SizedBox(width: 4), _InputCircleButton( icon: LucideIcons.mic, elderMode: elderMode, gradient: AppColors.primaryGradient, iconColor: Colors.white, onTap: isStreaming ? null : _switchToVoiceMode, ), const SizedBox(width: 4), _InputCircleButton( icon: isStreaming ? Icons.stop_rounded : LucideIcons.send, elderMode: elderMode, gradient: AppColors.primaryGradient, iconColor: Colors.white, onTap: isStreaming ? () => ref.read(chatProvider.notifier).stopGenerating() : _sendMessage, ), ]; } List _buildVoiceInputContents(bool elderMode, bool isStreaming) { return [ _InputCircleButton( icon: LucideIcons.plus, elderMode: elderMode, gradient: AppColors.primaryGradient, iconColor: Colors.white, onTap: _speechPhase == _SpeechPhase.idle ? () => _showAttachmentPicker(context) : null, ), const SizedBox(width: 6), Expanded( child: isStreaming ? InkWell( onTap: () => ref.read(chatProvider.notifier).stopGenerating(), borderRadius: AppRadius.pillBorder, child: _VoiceHoldSurface( elderMode: elderMode, label: '停止生成', color: AppColors.primary, ), ) : Listener( behavior: HitTestBehavior.opaque, onPointerDown: _onVoicePointerDown, onPointerMove: _onVoicePointerMove, onPointerUp: _onVoicePointerUp, onPointerCancel: _onVoicePointerCancel, child: _VoiceHoldSurface( elderMode: elderMode, label: _voiceInputLabel, color: _voiceLabelColor, ), ), ), const SizedBox(width: 4), _InputCircleButton( icon: LucideIcons.keyboard, elderMode: elderMode, gradient: AppColors.primaryGradient, iconColor: Colors.white, onTap: _speechPhase == _SpeechPhase.idle ? _switchToTextMode : null, ), ]; } String get _voiceInputLabel { if (_cancelVoiceOnRelease) return '松开取消'; return switch (_speechPhase) { _SpeechPhase.connecting => '正在连接…', _SpeechPhase.listening => _speechText.trim().isEmpty ? '松开发送' : _speechText.trim(), _SpeechPhase.finishing => '正在识别…', _SpeechPhase.idle => '按住说话', }; } Color get _voiceLabelColor { if (_cancelVoiceOnRelease) return AppColors.error; if (_speechPhase == _SpeechPhase.listening || _speechPhase == _SpeechPhase.connecting || _speechPhase == _SpeechPhase.finishing) { return AppColors.primary; } return AppColors.textSecondary; } Future _switchToVoiceMode() async { _dismissKeyboard(); final allowed = await ref .read(realtimeSpeechServiceProvider) .requestPermission(); if (!mounted) return; if (!allowed) { AppToast.show(context, '需要麦克风权限才能使用语音输入', type: AppToastType.error); return; } setState(() => _voiceMode = true); } void _switchToTextMode() { if (_speechPhase != _SpeechPhase.idle) return; setState(() => _voiceMode = false); } void _onVoicePointerDown(PointerDownEvent event) { if (_speechPhase != _SpeechPhase.idle) return; _voicePressActive = true; _cancelVoiceOnRelease = false; _finishVoiceWhenReady = false; _voicePressStartY = event.position.dy; unawaited(_startVoiceRecording()); } void _onVoicePointerMove(PointerMoveEvent event) { if (!_voicePressActive || _voicePressStartY == null) return; final shouldCancel = _voicePressStartY! - event.position.dy >= 60; if (shouldCancel != _cancelVoiceOnRelease && mounted) { setState(() => _cancelVoiceOnRelease = shouldCancel); } } void _onVoicePointerUp(PointerUpEvent event) { if (!_voicePressActive) return; _voicePressActive = false; if (_speechPhase == _SpeechPhase.connecting) { _finishVoiceWhenReady = true; return; } if (_cancelVoiceOnRelease) { unawaited(_cancelVoiceRecording()); } else if (_speechPhase == _SpeechPhase.listening) { unawaited(_finishVoiceRecording()); } } void _onVoicePointerCancel(PointerCancelEvent event) { if (!_voicePressActive) return; _voicePressActive = false; _cancelVoiceOnRelease = true; unawaited(_cancelVoiceRecording()); } Future _startVoiceRecording() async { setState(() { _speechPhase = _SpeechPhase.connecting; _speechText = ''; }); try { final session = await ref.read(realtimeSpeechServiceProvider).start(); if (!mounted) { await session.cancel(); return; } _speechSession = session; _speechEventSubscription = session.events.listen((event) { if (!mounted) return; if (event.type == SpeechRecognitionEventType.error) { _handleSpeechFailure(event.text); return; } setState(() => _speechText = event.text); }); setState(() => _speechPhase = _SpeechPhase.listening); if (_finishVoiceWhenReady || !_voicePressActive) { if (_cancelVoiceOnRelease) { await _cancelVoiceRecording(); } else { await _finishVoiceRecording(); } } } on SpeechRecognitionException catch (error) { _handleSpeechFailure(error.message); } catch (_) { _handleSpeechFailure('语音输入启动失败,请稍后重试'); } } Future _finishVoiceRecording() async { final session = _speechSession; if (session == null || _speechPhase == _SpeechPhase.finishing) return; setState(() => _speechPhase = _SpeechPhase.finishing); try { final text = (await session.finish()).trim(); if (!mounted) return; await _releaseSpeechSession(); if (!mounted) return; _resetSpeechState(); if (text.isEmpty) { AppToast.show(context, '没有听清,请再说一次'); return; } _textCtrl.text = text; _sendMessage(); } on SpeechRecognitionException catch (error) { _handleSpeechFailure(error.message); } catch (_) { _handleSpeechFailure('语音识别失败,请重试'); } } Future _cancelVoiceRecording() async { try { await _speechSession?.cancel(); } finally { await _releaseSpeechSession(); if (mounted) _resetSpeechState(); } } Future _releaseSpeechSession() async { await _speechEventSubscription?.cancel(); _speechEventSubscription = null; await _speechSession?.dispose(); _speechSession = null; } void _handleSpeechFailure(String message) { if (_speechPhase == _SpeechPhase.idle) return; final partialText = _speechText.trim(); unawaited(_releaseSpeechSession()); if (!mounted) return; _resetSpeechState(); if (partialText.isNotEmpty) { _textCtrl.text = partialText; setState(() => _voiceMode = false); } AppToast.show(context, message, type: AppToastType.error); } void _resetSpeechState() { if (!mounted) return; setState(() { _speechPhase = _SpeechPhase.idle; _speechText = ''; _voicePressActive = false; _cancelVoiceOnRelease = false; _finishVoiceWhenReady = false; _voicePressStartY = null; }); } void _showAttachmentPicker(BuildContext context) { _dismissKeyboard(); showModalBottomSheet( context: context, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (ctx) => SafeArea( child: Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Wrap( children: [ ListTile( leading: const Icon( Icons.camera_alt_outlined, color: AppColors.primary, ), title: const Text('拍照'), onTap: () { Navigator.pop(ctx); _dismissKeyboard(); _pickImage(ImageSource.camera); }, ), ListTile( leading: const Icon( Icons.photo_library_outlined, color: AppColors.primary, ), title: const Text('从相册选择'), onTap: () { Navigator.pop(ctx); _dismissKeyboard(); _pickImage(ImageSource.gallery); }, ), ListTile( leading: const Icon( Icons.picture_as_pdf_outlined, color: AppColors.primary, ), title: const Text('上传 PDF'), onTap: () async { Navigator.pop(ctx); await _pickPdf(); }, ), ], ), ), ), ); } Future _pickImage(ImageSource source) async { _dismissKeyboard(); final picked = await ImagePicker().pickImage( source: source, imageQuality: 85, ); _dismissKeyboard(); if (picked != null) { final token = await ref.read(apiClientProvider).accessToken; if (token == null) return; setState(() => _pickedImagePath = picked.path); WidgetsBinding.instance.addPostFrameCallback((_) => _dismissKeyboard()); } } void _dismissKeyboard() { _focusNode.unfocus(); FocusManager.instance.primaryFocus?.unfocus(); } Future _pickPdf() async { _dismissKeyboard(); final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['pdf'], withData: false, ); if (result == null || result.files.isEmpty) return; final pdfFile = result.files.first; final path = pdfFile.path; if (path == null || path.isEmpty) return; await ref .read(chatProvider.notifier) .sendPdf(path, pdfFile.name, _textCtrl.text.trim()); _textCtrl.clear(); _dismissKeyboard(); } Future _checkDueNotifications() async { try { await ref.read(inAppNotificationServiceProvider).checkDue(); } catch (_) { // Network failures should not block the home page. } finally { ref.invalidate(notificationUnreadCountProvider); } } Future _pickFoodImage(ImageSource source) async { final picked = await ImagePicker().pickImage( source: source, imageQuality: 80, maxWidth: 1024, maxHeight: 1024, ); if (picked != null && mounted) { ref.read(dietProvider.notifier).reset(); ref.read(dietProvider.notifier).setImage(picked.path); ref.read(dietProvider.notifier).analyzeImage(); pushRoute(ref, 'dietCapture'); } } } enum _SpeechPhase { idle, connecting, listening, finishing } class _InputCircleButton extends StatelessWidget { final IconData icon; final bool elderMode; final Gradient? gradient; final Color iconColor; final VoidCallback? onTap; const _InputCircleButton({ required this.icon, required this.elderMode, this.gradient, required this.iconColor, required this.onTap, }); @override Widget build(BuildContext context) { final size = elderMode ? 52.0 : 38.0; return Opacity( opacity: onTap == null ? 0.45 : 1, child: Material( color: Colors.transparent, child: InkWell( customBorder: const CircleBorder(), onTap: onTap, child: Ink( width: size, height: size, decoration: BoxDecoration( gradient: gradient, shape: BoxShape.circle, ), child: Icon(icon, size: elderMode ? 25 : 20, color: iconColor), ), ), ), ); } } class _VoiceHoldSurface extends StatelessWidget { final bool elderMode; final String label; final Color color; const _VoiceHoldSurface({ required this.elderMode, required this.label, required this.color, }); @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.symmetric( horizontal: 4, vertical: elderMode ? 14 : 11, ), alignment: Alignment.center, child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: TextStyle( fontSize: elderMode ? 20 : 17, fontWeight: FontWeight.w700, color: color, ), ), ); } } class _HomeMessages extends ConsumerWidget { final ScrollController scrollCtrl; const _HomeMessages({required this.scrollCtrl}); @override Widget build(BuildContext context, WidgetRef ref) { final messages = ref.watch(chatProvider.select((state) => state.messages)); return ChatMessagesView(scrollCtrl: scrollCtrl, messages: messages); } } class _HeaderIconButton extends StatelessWidget { final IconData icon; final VoidCallback onTap; final int badgeCount; const _HeaderIconButton({ required this.icon, required this.onTap, this.badgeCount = 0, }); @override Widget build(BuildContext context) { final elderMode = ElderModeScope.enabledOf(context); return GestureDetector( onTap: onTap, child: Stack( clipBehavior: Clip.none, children: [ Container( width: elderMode ? 54 : 44, height: elderMode ? 54 : 44, decoration: BoxDecoration( gradient: AppColors.surfaceGradient, borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.borderLight), boxShadow: AppColors.cardShadowLight, ), child: Icon( icon, size: elderMode ? 27 : 20, color: AppColors.textPrimary, ), ), if (badgeCount > 0) Positioned( right: -5, top: -5, child: Container( constraints: const BoxConstraints(minWidth: 18, minHeight: 18), padding: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( color: AppColors.error, borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.white, width: 2), ), alignment: Alignment.center, child: Text( badgeCount > 99 ? '99+' : '$badgeCount', style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600, ), ), ), ), ], ), ); } }