Files
AI-Health/health_app/lib/pages/home/home_page.dart
MingNian 39c32f842b feat: 去除"问诊"字眼 + "药管家"改名 + 删除AI对话欢迎卡片紫色框
- "AI问诊" -> "AI对话"(胶囊名、首页标签、页面标题、提示词)
- "药管家" -> "药提醒"(胶囊名、枚举注释、处理器注释)
- AI对话欢迎卡片删除紫色提示框(与底部提示重复)
- 底部提示文字去掉"观察或就医建议",改为"帮您记录和整理症状信息"
2026-07-22 17:08:16 +08:00

1054 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage>
with WidgetsBindingObserver {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
final _focusNode = FocusNode();
final _scaffoldKey = GlobalKey<ScaffoldState>();
String? _pickedImagePath;
Timer? _notificationTimer;
double _conversationDragDistance = 0;
bool _voiceMode = false;
_SpeechPhase _speechPhase = _SpeechPhase.idle;
RealtimeSpeechSession? _speechSession;
StreamSubscription<SpeechRecognitionEvent>? _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<bool>(
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<int>(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<Widget> _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<Widget> _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<void> _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<void> _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<void> _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<void> _cancelVoiceRecording() async {
try {
await _speechSession?.cancel();
} finally {
await _releaseSpeechSession();
if (mounted) _resetSpeechState();
}
}
Future<void> _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<void> _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<void> _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<void> _checkDueNotifications() async {
try {
await ref.read(inAppNotificationServiceProvider).checkDue();
} catch (_) {
// Network failures should not block the home page.
} finally {
ref.invalidate(notificationUnreadCountProvider);
}
}
Future<void> _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,
),
),
),
),
],
),
);
}
}