Files
AI-Health/health_app/lib/pages/home/home_page.dart
MingNian 3b5cec10a6 feat: AI 提示词模块化 + AI 同意门控 + AI 草稿存储 + 意图路由 + 医疗引用知识库 + 多页面 UI 优化
- AI 提示词拆分为 markdown 模块(Prompts/global + modules + rag + router)
- 新增 AI 同意门控(用户需同意后才能使用 AI 功能)
- 新增 AI 录入草稿存储(AiEntryDraftContracts/EfAiEntryDraftStore/AiEntryDraftRecord)
- 新增 AI 意图路由(ai_intent_router)
- 新增医疗引用知识库(MedicalCitationKnowledge)
- 重构 prompt_manager 和 ai_chat_endpoints
- 优化聊天/趋势/档案/抽屉/医生端等多个页面 UI
- 新增 agent 插画和趋势指标图标资源
- 删除 HANDOFF-2026-07-17.md
2026-07-27 16:53:20 +08:00

1065 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 => (
label: '记数据',
visual: AppModuleVisual(
module: AppModule.health,
label: AppModuleVisuals.health.label,
icon: AppModuleVisuals.healthOverviewIcon,
color: AppModuleVisuals.health.color,
lightColor: AppModuleVisuals.health.lightColor,
borderColor: AppModuleVisuals.health.borderColor,
gradient: AppModuleVisuals.health.gradient,
),
),
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,
),
),
),
),
],
),
);
}
}