feat: 长辈模式(大字大按钮 + 偏好按账号隔离)+ 语音输入(按住说话 + 实时转写 + 后端代理 DashScope ASR + 患者端专用)+ 健康仪表盘背景渐变

This commit is contained in:
MingNian
2026-07-21 10:53:16 +08:00
parent 0cb5b8e85a
commit 28f704c98e
25 changed files with 2214 additions and 131 deletions

View File

@@ -5,10 +5,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'core/app_router.dart';
import 'core/app_theme.dart';
import 'core/elder_mode_scope.dart';
import 'core/navigation_provider.dart';
import 'pages/splash_page.dart';
import 'providers/auth_provider.dart';
import 'providers/data_providers.dart';
import 'providers/elder_mode_provider.dart';
/// 健康管家 App 根组件
class HealthApp extends ConsumerWidget {
@@ -16,10 +18,16 @@ class HealthApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final elderMode = ref.watch(elderModeProvider);
final isPatient = auth.isLoggedIn && auth.user?.role == 'User';
final useElderMode = isPatient && elderMode.isEnabled;
final useLargeText = useElderMode && elderMode.preferences.largeTextEnabled;
return MaterialApp(
title: '小脉健康',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
theme: useElderMode ? AppTheme.elderLightTheme : AppTheme.lightTheme,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
@@ -30,12 +38,23 @@ class HealthApp extends ConsumerWidget {
home: const _RootNavigator(),
// 注入 ShadTheme + 启动闸门Splash 盖在最上层,直到首页今日健康卡数据就绪
// 外层包一层 GestureDetector点击任意空白处取消输入框焦点全 app 生效)
builder: (context, child) => ShadTheme(
data: AppTheme.shadTheme,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: _BootGate(child: child!),
builder: (context, child) => ElderModeScope(
enabled: useElderMode,
largeTextEnabled: useLargeText,
child: Builder(
builder: (scopeContext) => MediaQuery(
data: MediaQuery.of(
scopeContext,
).copyWith(textScaler: ElderModeScope.textScalerFor(scopeContext)),
child: ShadTheme(
data: AppTheme.shadTheme,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: _BootGate(child: child!),
),
),
),
),
),
);
@@ -52,6 +71,7 @@ final appReadyProvider = Provider<bool>((ref) {
if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash
final role = auth.user?.role ?? 'User';
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
if (!ref.watch(elderModeProvider).isLoaded) return false;
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
final health = ref.watch(latestHealthProvider);
return health.hasValue || health.hasError;

View File

@@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: kReleaseMode
? 'https://erpapi.datalumina.cn/xiaomai'
: 'http://192.168.1.34:5000',
: 'http://10.4.178.175:5000',
);
class ApiException implements Exception {

View File

@@ -11,6 +11,7 @@ import '../pages/report/report_pages.dart';
import '../pages/report/ai_analysis_page.dart';
import '../pages/consultation/consultation_pages.dart';
import '../pages/settings/settings_pages.dart';
import '../pages/settings/elder_mode_page.dart';
import '../pages/settings/notification_prefs_page.dart';
import '../pages/notifications/notification_center_page.dart';
import '../pages/history/conversation_history_page.dart';
@@ -127,6 +128,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return const FollowUpListPage();
case 'settings':
return const SettingsPage();
case 'elderMode':
return const ElderModePage();
case 'notificationPrefs':
return const NotificationPrefsPage();
case 'notifications':

View File

@@ -776,6 +776,74 @@ class AppTheme {
),
);
/// 长辈模式只在已登录的患者端启用。这里提高主题级控件尺寸,页面中写死的
/// 字号再由 ElderModeScope 提供的文本缩放统一兜底。
static ThemeData get elderLightTheme {
final base = lightTheme;
return base.copyWith(
appBarTheme: base.appBarTheme.copyWith(
toolbarHeight: 60,
titleTextStyle: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: text,
),
iconTheme: const IconThemeData(size: 28, color: text),
),
textTheme: base.textTheme.copyWith(
headlineLarge: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: text,
),
titleLarge: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: text,
),
titleMedium: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
color: text,
),
bodyLarge: const TextStyle(fontSize: 18, color: text, height: 1.5),
bodyMedium: const TextStyle(fontSize: 16, color: textSub, height: 1.45),
labelMedium: const TextStyle(fontSize: 16, color: textSub),
labelSmall: const TextStyle(fontSize: 15, color: textHint),
),
inputDecorationTheme: base.inputDecorationTheme.copyWith(
contentPadding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 16,
),
hintStyle: const TextStyle(color: textHint, fontSize: 17),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(rLg),
),
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
elevation: 0,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: text,
minimumSize: const Size(52, 56),
side: const BorderSide(color: border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(rMd),
),
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
),
);
}
static ShadThemeData get shadTheme => ShadThemeData(
brightness: Brightness.light,
colorScheme: ShadVioletColorScheme.light(

View File

@@ -0,0 +1,42 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
class ElderModeScope extends InheritedWidget {
final bool enabled;
final bool largeTextEnabled;
const ElderModeScope({
super.key,
required this.enabled,
required this.largeTextEnabled,
required super.child,
});
static ElderModeScope? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ElderModeScope>();
}
static bool enabledOf(BuildContext context) {
return maybeOf(context)?.enabled == true;
}
static bool largeTextOf(BuildContext context) {
final scope = maybeOf(context);
return scope?.enabled == true && scope?.largeTextEnabled == true;
}
static TextScaler textScalerFor(BuildContext context) {
final systemScaler = MediaQuery.textScalerOf(context);
if (!largeTextOf(context)) return systemScaler;
final systemFactor = systemScaler.scale(1);
final factor = math.max(systemFactor, 1.18).clamp(1.0, 1.6).toDouble();
return TextScaler.linear(factor);
}
@override
bool updateShouldNotify(ElderModeScope oldWidget) {
return enabled != oldWidget.enabled ||
largeTextEnabled != oldWidget.largeTextEnabled;
}
}

View File

@@ -7,11 +7,15 @@ 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';
@@ -32,6 +36,15 @@ class _HomePageState extends ConsumerState<HomePage>
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;
@@ -55,6 +68,8 @@ class _HomePageState extends ConsumerState<HomePage>
@override
void initState() {
super.initState();
final elderPreferences = ref.read(elderModeProvider).preferences;
_voiceMode = elderPreferences.enabled && elderPreferences.voiceInputEnabled;
WidgetsBinding.instance.addObserver(this);
_scheduleScrollToLatest();
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -72,6 +87,10 @@ class _HomePageState extends ConsumerState<HomePage>
if (state == AppLifecycleState.resumed) {
_checkDueNotifications();
}
if (state == AppLifecycleState.paused &&
_speechPhase != _SpeechPhase.idle) {
unawaited(_cancelVoiceRecording());
}
}
@override
@@ -81,6 +100,8 @@ class _HomePageState extends ConsumerState<HomePage>
_textCtrl.dispose();
_scrollCtrl.dispose();
_focusNode.dispose();
unawaited(_speechEventSubscription?.cancel());
unawaited(_speechSession?.cancel());
super.dispose();
}
@@ -124,6 +145,19 @@ class _HomePageState extends ConsumerState<HomePage>
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);
@@ -202,12 +236,13 @@ class _HomePageState extends ConsumerState<HomePage>
}
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: const EdgeInsets.fromLTRB(14, 8, 14, 6),
padding: EdgeInsets.fromLTRB(14, elderMode ? 10 : 8, 14, 6),
child: Row(
children: [
Builder(
@@ -234,8 +269,8 @@ class _HomePageState extends ConsumerState<HomePage>
const SizedBox(height: 2),
Text(
'${_getGreeting()}$name',
style: const TextStyle(
fontSize: 18,
style: TextStyle(
fontSize: elderMode ? 20 : 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
@@ -271,11 +306,12 @@ class _HomePageState extends ConsumerState<HomePage>
];
Widget _buildAgentBar() {
final elderMode = ElderModeScope.enabledOf(context);
final activeAgent = ref.watch(
chatProvider.select((state) => state.activeAgent),
);
return SizedBox(
height: 46,
height: elderMode ? 58 : 46,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
@@ -297,9 +333,9 @@ class _HomePageState extends ConsumerState<HomePage>
),
padding: const EdgeInsets.all(1.2),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 11.8,
vertical: 7.8,
padding: EdgeInsets.symmetric(
horizontal: elderMode ? 15 : 11.8,
vertical: elderMode ? 10 : 7.8,
),
decoration: BoxDecoration(
color: Colors.white,
@@ -309,23 +345,23 @@ class _HomePageState extends ConsumerState<HomePage>
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 18,
height: 18,
width: elderMode ? 24 : 18,
height: elderMode ? 24 : 18,
decoration: BoxDecoration(
gradient: visual.gradient,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
visual.icon,
size: 13,
size: elderMode ? 17 : 13,
color: visual.iconColor,
),
),
const SizedBox(width: 6),
Text(
visual.label,
style: const TextStyle(
fontSize: 15,
style: TextStyle(
fontSize: elderMode ? 17 : 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
@@ -438,98 +474,315 @@ class _HomePageState extends ConsumerState<HomePage>
}
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: const EdgeInsets.fromLTRB(6, 5, 6, 5),
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: [
Material(
color: Colors.transparent,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _showAttachmentPicker(context),
child: Ink(
width: 38,
height: 38,
decoration: const BoxDecoration(
gradient: AppColors.primaryGradient,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.plus,
size: 21,
color: Colors.white,
),
),
),
),
const SizedBox(width: 6),
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 112),
child: TextField(
controller: _textCtrl,
focusNode: _focusNode,
style: const TextStyle(
fontSize: 15,
color: AppColors.textPrimary,
),
maxLines: null,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(
hintText: '输入你想说的...',
filled: false,
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 4,
vertical: 12,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
onSubmitted: (_) => _sendMessage(),
),
),
),
const SizedBox(width: 6),
Material(
color: Colors.transparent,
child: InkWell(
customBorder: const CircleBorder(),
onTap: isStreaming
? () => ref.read(chatProvider.notifier).stopGenerating()
: _sendMessage,
child: Ink(
width: 38,
height: 38,
decoration: const BoxDecoration(
gradient: AppColors.primaryGradient,
shape: BoxShape.circle,
),
child: Icon(
isStreaming ? Icons.stop_rounded : LucideIcons.send,
size: 18,
color: Colors.white,
),
),
),
),
],
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(
@@ -649,6 +902,82 @@ class _HomePageState extends ConsumerState<HomePage>
}
}
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;
@@ -673,21 +1002,26 @@ class _HeaderIconButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final elderMode = ElderModeScope.enabledOf(context);
return GestureDetector(
onTap: onTap,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 44,
height: 44,
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: 20, color: AppColors.textPrimary),
child: Icon(
icon,
size: elderMode ? 27 : 20,
color: AppColors.textPrimary,
),
),
if (badgeCount > 0)
Positioned(

View File

@@ -0,0 +1,420 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/navigation_provider.dart';
import '../../providers/elder_mode_provider.dart';
import '../../widgets/app_toast.dart';
class ElderModePage extends ConsumerWidget {
const ElderModePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(elderModeProvider);
final enabled = state.isEnabled;
ref.listen<String?>(elderModeProvider.select((value) => value.error), (
previous,
next,
) {
if (next != null && next != previous) {
AppToast.show(context, next, type: AppToastType.error);
}
});
return Scaffold(
backgroundColor: const Color(0xFFF0F1FF),
appBar: AppBar(
backgroundColor: Colors.transparent,
leading: IconButton(
onPressed: () => popRoute(ref),
icon: const Icon(Icons.arrow_back_rounded),
),
title: const Text('长辈模式'),
),
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFE7ECFF), Color(0xFFF2F1FF)],
),
),
child: SafeArea(
top: false,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _ElderHero(),
const SizedBox(height: 18),
if (enabled)
_EnabledPanel(
largeTextEnabled: state.preferences.largeTextEnabled,
voiceInputEnabled:
state.preferences.voiceInputEnabled,
saving: state.isSaving,
onLargeTextChanged: (value) => ref
.read(elderModeProvider.notifier)
.setLargeTextEnabled(value),
onVoiceInputChanged: (value) => ref
.read(elderModeProvider.notifier)
.setVoiceInputEnabled(value),
)
else
const _DisabledPanel(),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 18),
child: SizedBox(
height: 58,
width: double.infinity,
child: enabled
? OutlinedButton(
onPressed: state.isSaving
? null
: () => ref
.read(elderModeProvider.notifier)
.disable(),
style: OutlinedButton.styleFrom(
backgroundColor: Colors.white.withValues(
alpha: 0.72,
),
foregroundColor: AppColors.textSecondary,
side: const BorderSide(color: Color(0xFFD9DDF2)),
shape: RoundedRectangleBorder(
borderRadius: AppRadius.pillBorder,
),
),
child: const Text('关闭长辈模式'),
)
: ElevatedButton(
onPressed: state.isSaving
? null
: () => ref
.read(elderModeProvider.notifier)
.enable(),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF5B55E7),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.pillBorder,
),
),
child: const Text('立即开启'),
),
),
),
],
),
),
),
);
}
}
class _ElderHero extends StatelessWidget {
const _ElderHero();
@override
Widget build(BuildContext context) {
return Column(
children: [
const Text(
'暖心护长辈\n便捷新体验',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 34,
height: 1.25,
fontWeight: FontWeight.w800,
color: Color(0xFF252470),
),
),
const SizedBox(height: 8),
SizedBox(
height: 190,
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: 190,
height: 150,
decoration: BoxDecoration(
color: const Color(0xFFB8B9FF).withValues(alpha: 0.24),
shape: BoxShape.circle,
),
),
Image.asset(
'assets/branding/health_login_character_transparent.png',
height: 182,
fit: BoxFit.contain,
),
const Positioned(left: 4, top: 92, child: _HeroTag(label: '字更大')),
const Positioned(
right: 0,
top: 118,
child: _HeroTag(label: '更清楚'),
),
],
),
),
],
);
}
}
class _HeroTag extends StatelessWidget {
final String label;
const _HeroTag({required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9),
decoration: BoxDecoration(
color: const Color(0xFF7B78E8).withValues(alpha: 0.72),
borderRadius: AppRadius.pillBorder,
border: Border.all(color: Colors.white.withValues(alpha: 0.72)),
),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _DisabledPanel extends StatelessWidget {
const _DisabledPanel();
@override
Widget build(BuildContext context) {
return _ModePanel(
title: '开启长辈模式,操作更清晰',
child: const Column(
children: [
_FeatureRow(
icon: Icons.text_fields_rounded,
title: '大字体',
subtitle: '正文和按钮文字更大',
),
_PanelDivider(),
_FeatureRow(
icon: Icons.touch_app_rounded,
title: '大按钮',
subtitle: '点击区域更大,不易点错',
),
_PanelDivider(),
_FeatureRow(
icon: Icons.grid_view_rounded,
title: '大图标',
subtitle: '常用功能更醒目',
),
_PanelDivider(),
_FeatureRow(
icon: Icons.mic_rounded,
title: '语音输入',
subtitle: '首页默认使用按住说话',
),
],
),
);
}
}
class _EnabledPanel extends StatelessWidget {
final bool largeTextEnabled;
final bool voiceInputEnabled;
final bool saving;
final ValueChanged<bool> onLargeTextChanged;
final ValueChanged<bool> onVoiceInputChanged;
const _EnabledPanel({
required this.largeTextEnabled,
required this.voiceInputEnabled,
required this.saving,
required this.onLargeTextChanged,
required this.onVoiceInputChanged,
});
@override
Widget build(BuildContext context) {
return _ModePanel(
title: '长辈模式已开启',
subtitle: '大图标和大按钮已启用',
child: Column(
children: [
_FeatureRow(
icon: Icons.text_fields_rounded,
title: '大字体',
subtitle: '文字更大,阅读更轻松',
trailing: Switch(
value: largeTextEnabled,
onChanged: saving ? null : onLargeTextChanged,
activeTrackColor: const Color(0xFF5B55E7),
),
),
const _PanelDivider(),
_FeatureRow(
icon: Icons.mic_rounded,
title: '语音输入',
subtitle: '进入首页默认按住说话',
trailing: Switch(
value: voiceInputEnabled,
onChanged: saving ? null : onVoiceInputChanged,
activeTrackColor: const Color(0xFF5B55E7),
),
),
const _PanelDivider(),
const _FeatureRow(
icon: Icons.touch_app_rounded,
title: '大按钮大图标',
subtitle: '操作区域更大、更清楚',
trailing: Icon(
Icons.check_circle_rounded,
color: Color(0xFF5B55E7),
size: 30,
),
),
],
),
);
}
}
class _ModePanel extends StatelessWidget {
final String title;
final String? subtitle;
final Widget child;
const _ModePanel({required this.title, this.subtitle, required this.child});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.72),
borderRadius: AppRadius.cardBorder,
border: Border.all(color: Colors.white),
boxShadow: AppShadows.soft,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: Color(0xFF25244F),
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: const TextStyle(
fontSize: 15,
color: AppColors.textSecondary,
),
),
],
const SizedBox(height: 14),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.xlBorder,
),
child: child,
),
],
),
);
}
}
class _FeatureRow extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final Widget? trailing;
const _FeatureRow({
required this.icon,
required this.title,
required this.subtitle,
this.trailing,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 17),
child: Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: const Color(0xFFE8E7FF),
borderRadius: AppRadius.mdBorder,
),
child: Icon(icon, size: 26, color: const Color(0xFF5B55E7)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 3),
Text(
subtitle,
style: const TextStyle(
fontSize: 15,
color: AppColors.textSecondary,
),
),
],
),
),
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
],
),
);
}
}
class _PanelDivider extends StatelessWidget {
const _PanelDivider();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.only(left: 76),
child: Divider(height: 1, color: Color(0xFFE9EAF3)),
);
}
}

View File

@@ -44,6 +44,11 @@ class SettingsPage extends ConsumerWidget {
children: [
_SettingsGroup(
children: [
_SettingsTile(
icon: Icons.elderly_rounded,
title: '长辈模式',
onTap: () => pushRoute(ref, 'elderMode'),
),
_SettingsTile(
icon: LucideIcons.bluetooth,
title: '蓝牙设备',
@@ -272,6 +277,7 @@ class _SettingsTile extends StatelessWidget {
),
),
),
const SizedBox(width: 6),
const Icon(
Icons.arrow_forward_ios_rounded,
size: 15,

View File

@@ -0,0 +1,162 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart';
class ElderModePreferences {
final bool enabled;
final bool largeTextEnabled;
final bool voiceInputEnabled;
const ElderModePreferences({
this.enabled = false,
this.largeTextEnabled = true,
this.voiceInputEnabled = true,
});
ElderModePreferences copyWith({
bool? enabled,
bool? largeTextEnabled,
bool? voiceInputEnabled,
}) {
return ElderModePreferences(
enabled: enabled ?? this.enabled,
largeTextEnabled: largeTextEnabled ?? this.largeTextEnabled,
voiceInputEnabled: voiceInputEnabled ?? this.voiceInputEnabled,
);
}
Map<String, dynamic> toJson() => {
'schemaVersion': 1,
'enabled': enabled,
'largeTextEnabled': largeTextEnabled,
'voiceInputEnabled': voiceInputEnabled,
};
factory ElderModePreferences.fromJson(Map<String, dynamic> json) {
return ElderModePreferences(
enabled: json['enabled'] == true,
largeTextEnabled: json['largeTextEnabled'] != false,
voiceInputEnabled: json['voiceInputEnabled'] != false,
);
}
}
class ElderModeState {
final ElderModePreferences preferences;
final bool isLoaded;
final bool isSaving;
final String? error;
const ElderModeState({
this.preferences = const ElderModePreferences(),
this.isLoaded = false,
this.isSaving = false,
this.error,
});
bool get isEnabled => preferences.enabled;
bool get useLargeText => isEnabled && preferences.largeTextEnabled;
ElderModeState copyWith({
ElderModePreferences? preferences,
bool? isLoaded,
bool? isSaving,
String? error,
bool clearError = false,
}) {
return ElderModeState(
preferences: preferences ?? this.preferences,
isLoaded: isLoaded ?? this.isLoaded,
isSaving: isSaving ?? this.isSaving,
error: clearError ? null : error ?? this.error,
);
}
}
final elderModeProvider = NotifierProvider<ElderModeNotifier, ElderModeState>(
ElderModeNotifier.new,
);
class ElderModeNotifier extends Notifier<ElderModeState> {
static const _storageKeyPrefix = 'accessibility_preferences_v1:user_';
String? _currentUserId;
@override
ElderModeState build() {
final userId = ref.watch(
authProvider.select((state) => state.user?.id),
);
_currentUserId = userId;
if (userId == null || userId.isEmpty) {
return const ElderModeState(isLoaded: true);
}
Future<void>.microtask(_load);
return const ElderModeState();
}
String get _storageKey => '$_storageKeyPrefix$_currentUserId';
Future<void> _load() async {
if (_currentUserId == null || _currentUserId!.isEmpty) return;
try {
final raw = await ref.read(localDbProvider).read(_storageKey);
if (raw == null || raw.trim().isEmpty) {
state = state.copyWith(isLoaded: true, clearError: true);
return;
}
final decoded = jsonDecode(raw);
if (decoded is! Map) throw const FormatException('invalid preferences');
state = state.copyWith(
preferences: ElderModePreferences.fromJson(
Map<String, dynamic>.from(decoded),
),
isLoaded: true,
clearError: true,
);
} catch (_) {
state = state.copyWith(isLoaded: true, error: '长辈模式设置读取失败,已使用默认设置');
}
}
Future<bool> enable() =>
_save(state.preferences.copyWith(enabled: true, largeTextEnabled: true));
Future<bool> disable() => _save(state.preferences.copyWith(enabled: false));
Future<bool> setLargeTextEnabled(bool enabled) =>
_save(state.preferences.copyWith(largeTextEnabled: enabled));
Future<bool> setVoiceInputEnabled(bool enabled) =>
_save(state.preferences.copyWith(voiceInputEnabled: enabled));
Future<bool> _save(ElderModePreferences next) async {
if (_currentUserId == null || _currentUserId!.isEmpty) {
state = state.copyWith(
isLoaded: true,
error: '请先登录后再调整长辈模式',
);
return false;
}
final previous = state.preferences;
state = state.copyWith(
preferences: next,
isLoaded: true,
isSaving: true,
clearError: true,
);
try {
await ref.read(localDbProvider).write(_storageKey, jsonEncode(next));
state = state.copyWith(isSaving: false, clearError: true);
return true;
} catch (_) {
state = state.copyWith(
preferences: previous,
isSaving: false,
error: '保存失败,请稍后重试',
);
return false;
}
}
}

View File

@@ -0,0 +1,268 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:record/record.dart';
import '../core/api_client.dart';
import '../providers/auth_provider.dart';
enum SpeechRecognitionEventType { partial, done, error }
class SpeechRecognitionEvent {
final SpeechRecognitionEventType type;
final String text;
const SpeechRecognitionEvent(this.type, this.text);
}
class SpeechRecognitionException implements Exception {
final String message;
const SpeechRecognitionException(this.message);
@override
String toString() => message;
}
final realtimeSpeechServiceProvider = Provider<RealtimeSpeechService>((ref) {
return RealtimeSpeechService(ref.watch(apiClientProvider));
});
class RealtimeSpeechService {
final ApiClient _apiClient;
const RealtimeSpeechService(this._apiClient);
Future<bool> requestPermission() async {
final recorder = AudioRecorder();
try {
return await recorder.hasPermission();
} finally {
recorder.dispose();
}
}
Future<RealtimeSpeechSession> start() async {
final token = await _apiClient.accessToken;
if (token == null || token.isEmpty) {
throw const SpeechRecognitionException('登录状态已失效,请重新登录');
}
final session = RealtimeSpeechSession(
endpoint: buildRealtimeSpeechEndpoint(baseUrl),
accessToken: token,
);
try {
await session.start();
return session;
} catch (_) {
await session.dispose();
rethrow;
}
}
}
Uri buildRealtimeSpeechEndpoint(String apiBaseUrl) {
final apiUri = Uri.parse(apiBaseUrl);
final basePath = apiUri.path.endsWith('/')
? apiUri.path.substring(0, apiUri.path.length - 1)
: apiUri.path;
return apiUri.replace(
scheme: apiUri.scheme == 'https' ? 'wss' : 'ws',
path: '$basePath/api/speech/realtime',
query: null,
fragment: null,
);
}
class RealtimeSpeechSession {
final Uri endpoint;
final String accessToken;
final AudioRecorder _recorder = AudioRecorder();
final StreamController<SpeechRecognitionEvent> _events =
StreamController<SpeechRecognitionEvent>.broadcast();
final Completer<void> _ready = Completer<void>();
final Completer<String> _done = Completer<String>();
WebSocket? _socket;
StreamSubscription<Uint8List>? _audioSubscription;
StreamSubscription<dynamic>? _socketSubscription;
bool _finishing = false;
bool _disposed = false;
String _latestText = '';
RealtimeSpeechSession({required this.endpoint, required this.accessToken}) {
_ready.future.ignore();
_done.future.ignore();
}
Stream<SpeechRecognitionEvent> get events => _events.stream;
String get latestText => _latestText;
Future<void> start() async {
if (!await _recorder.hasPermission()) {
throw const SpeechRecognitionException('没有麦克风权限,请在系统设置中允许后重试');
}
try {
_socket = await WebSocket.connect(
endpoint.toString(),
headers: {'Authorization': 'Bearer $accessToken'},
).timeout(const Duration(seconds: 10));
} on TimeoutException {
throw const SpeechRecognitionException('连接语音服务超时,请检查网络');
} on WebSocketException {
throw const SpeechRecognitionException('无法连接语音服务,请稍后重试');
}
_socketSubscription = _socket!.listen(
_handleSocketMessage,
onError: _handleSocketError,
onDone: _handleSocketDone,
cancelOnError: false,
);
try {
await _ready.future.timeout(const Duration(seconds: 10));
} on TimeoutException {
throw const SpeechRecognitionException('语音服务准备超时,请重试');
}
final audioStream = await _recorder.startStream(
const RecordConfig(
encoder: AudioEncoder.pcm16bits,
sampleRate: 16000,
numChannels: 1,
autoGain: true,
noiseSuppress: true,
echoCancel: true,
streamBufferSize: 3200,
),
);
_audioSubscription = audioStream.listen(
(chunk) {
final socket = _socket;
if (!_finishing && socket?.readyState == WebSocket.open) {
socket!.add(chunk);
}
},
onError: (Object error, StackTrace stackTrace) {
_completeError('麦克风录音失败,请重试');
},
);
}
Future<String> finish() async {
if (_disposed) return _latestText;
if (_finishing) return _done.future;
_finishing = true;
await _recorder.stop();
await _audioSubscription?.cancel();
_audioSubscription = null;
// 后端可能在用户松手前已主动结束(例如达到 60 秒上限),
// 此时 _done 已完成,直接返回结果即可,不需要再发 finish。
if (_done.isCompleted) return _done.future;
final socket = _socket;
if (socket?.readyState != WebSocket.open) {
throw const SpeechRecognitionException('语音连接已断开,请重试');
}
socket!.add(jsonEncode({'action': 'finish'}));
try {
return await _done.future.timeout(const Duration(seconds: 12));
} on TimeoutException {
throw const SpeechRecognitionException('识别结果返回超时,请重试');
}
}
Future<void> cancel() async {
if (_disposed) return;
if (!_finishing) {
_finishing = true;
final socket = _socket;
if (socket?.readyState == WebSocket.open) {
socket!.add(jsonEncode({'action': 'cancel'}));
}
}
await _recorder.cancel();
await dispose();
}
void _handleSocketMessage(dynamic raw) {
if (raw is! String) return;
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) return;
final type = decoded['type']?.toString();
if (type == 'ready') {
if (!_ready.isCompleted) _ready.complete();
return;
}
if (type == 'partial') {
_latestText = decoded['text']?.toString() ?? _latestText;
_events.add(
SpeechRecognitionEvent(
SpeechRecognitionEventType.partial,
_latestText,
),
);
return;
}
if (type == 'done') {
_latestText = decoded['text']?.toString() ?? _latestText;
_events.add(
SpeechRecognitionEvent(SpeechRecognitionEventType.done, _latestText),
);
if (!_done.isCompleted) _done.complete(_latestText);
return;
}
if (type == 'error') {
_completeError(decoded['message']?.toString() ?? '语音识别失败,请重试');
}
} catch (_) {
_completeError('语音服务返回了无法识别的数据');
}
}
void _handleSocketError(Object error, StackTrace stackTrace) {
_completeError('语音连接异常,请检查网络');
}
void _handleSocketDone() {
if (!_ready.isCompleted) {
_ready.completeError(const SpeechRecognitionException('语音服务连接失败,请重试'));
} else if (!_done.isCompleted && !_disposed) {
_completeError('语音连接已断开,请重试');
}
}
void _completeError(String message) {
final error = SpeechRecognitionException(message);
if (!_ready.isCompleted) _ready.completeError(error);
if (!_done.isCompleted) _done.completeError(error);
if (!_events.isClosed) {
_events.add(
SpeechRecognitionEvent(SpeechRecognitionEventType.error, message),
);
}
}
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
await _audioSubscription?.cancel();
await _socketSubscription?.cancel();
final socket = _socket;
if (socket != null && socket.readyState == WebSocket.open) {
await socket.close(WebSocketStatus.normalClosure, 'completed');
}
await _events.close();
_recorder.dispose();
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.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';
@@ -307,11 +308,12 @@ class _MetricTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final elderMode = ElderModeScope.enabledOf(context);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
height: 100,
height: elderMode ? 112 : 100,
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.22),
@@ -368,6 +370,7 @@ class _NavigationSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final elderMode = ElderModeScope.enabledOf(context);
final items = [
_NavItem(
icon: LucideIcons.folderHeart,
@@ -426,15 +429,19 @@ class _NavigationSection extends StatelessWidget {
itemCount: items.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisExtent: 82,
mainAxisSpacing: 7,
crossAxisSpacing: 8,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: elderMode ? 2 : 4,
mainAxisExtent: elderMode ? 82 : 82,
mainAxisSpacing: elderMode ? 10 : 7,
crossAxisSpacing: elderMode ? 10 : 8,
),
itemBuilder: (context, index) {
final item = items[index];
return _NavTile(item: item, onTap: () => pushRoute(ref, item.route));
return _NavTile(
item: item,
elderMode: elderMode,
onTap: () => pushRoute(ref, item.route),
);
},
),
);
@@ -443,8 +450,13 @@ class _NavigationSection extends StatelessWidget {
class _NavTile extends StatelessWidget {
final _NavItem item;
final bool elderMode;
final VoidCallback onTap;
const _NavTile({required this.item, required this.onTap});
const _NavTile({
required this.item,
required this.elderMode,
required this.onTap,
});
String get _title => switch (item.route) {
'healthArchive' => '健康档案',
@@ -473,6 +485,54 @@ class _NavTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDevice = item.route == 'devices';
if (elderMode) {
return InkWell(
onTap: onTap,
borderRadius: AppRadius.lgBorder,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFF8F8FC),
borderRadius: AppRadius.lgBorder,
border: Border.all(color: const Color(0xFFE7E8F0)),
),
child: Row(
children: [
Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: isDevice ? AppColors.device : null,
gradient: isDevice
? null
: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: _colors,
),
borderRadius: AppRadius.mdBorder,
),
child: Icon(item.icon, color: Colors.white, size: 29),
),
const SizedBox(width: 10),
Expanded(
child: Text(
_title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
height: 1.15,
),
),
),
],
),
),
);
}
return InkWell(
onTap: onTap,
borderRadius: AppRadius.mdBorder,
@@ -635,23 +695,13 @@ class _HealthDashboardBackgroundPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
canvas.drawRect(rect, Paint()..color = const Color(0xFFBAE6FD));
void drawGlow(Alignment center, Color color) {
final paint = Paint()
..shader = RadialGradient(
center: center,
radius: 1,
colors: [color, color.withValues(alpha: 0)],
stops: const [0, 0.8],
).createShader(rect);
canvas.drawRect(rect, paint);
}
drawGlow(Alignment.bottomRight, const Color(0xFF818CF8));
drawGlow(const Alignment(0, 0.6), const Color(0xFF60A5FA));
drawGlow(const Alignment(0.6, -0.6), const Color(0xFF6366F1));
drawGlow(const Alignment(-0.6, -0.6), const Color(0xFF38BDF8));
final paint = Paint()
..shader = const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF00C6FB), Color(0xFF005BEA)],
).createShader(rect);
canvas.drawRect(rect, paint);
}
@override