diff --git a/backend/.env.example b/backend/.env.example index 60ba196..94823f7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,6 +16,14 @@ QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 QWEN_API_KEY=sk-your-key-here QWEN_VISION_MODEL=qwen-vl-max +# 阿里云百炼实时语音输入(Fun-ASR) +DASHSCOPE_ASR_API_KEY=sk-your-dashscope-key-here +DASHSCOPE_ASR_WORKSPACE_ID=your-workspace-id +DASHSCOPE_ASR_MODEL=fun-asr-realtime +DASHSCOPE_ASR_MAX_DURATION_SECONDS=60 +# 可选;不填时按 Workspace ID 自动生成北京地域专属地址 +# DASHSCOPE_ASR_WEBSOCKET_URL=wss://your-workspace-id.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference + # MinIO MINIO_ENDPOINT=localhost:9000 MINIO_ACCESS_KEY=minioadmin diff --git a/backend/src/Health.Application/Speech/realtime_speech_contracts.cs b/backend/src/Health.Application/Speech/realtime_speech_contracts.cs new file mode 100644 index 0000000..54fffb1 --- /dev/null +++ b/backend/src/Health.Application/Speech/realtime_speech_contracts.cs @@ -0,0 +1,10 @@ +using System.Net.WebSockets; + +namespace Health.Application.Speech; + +public interface IRealtimeSpeechRecognitionProxy +{ + bool IsConfigured { get; } + + Task ProxyAsync(WebSocket clientSocket, CancellationToken cancellationToken); +} diff --git a/backend/src/Health.Infrastructure/Speech/dashscope_speech_recognition_proxy.cs b/backend/src/Health.Infrastructure/Speech/dashscope_speech_recognition_proxy.cs new file mode 100644 index 0000000..66e06e0 --- /dev/null +++ b/backend/src/Health.Infrastructure/Speech/dashscope_speech_recognition_proxy.cs @@ -0,0 +1,389 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Health.Application.Speech; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Health.Infrastructure.Speech; + +public sealed class DashScopeSpeechRecognitionProxy : IRealtimeSpeechRecognitionProxy +{ + private const int SampleRate = 16_000; + private const int BytesPerSecond = SampleRate * 2; + private readonly string _apiKey; + private readonly string _model; + private readonly string _workspaceId; + private readonly Uri? _endpoint; + private readonly int _maxDurationSeconds; + private readonly ILogger _logger; + + public DashScopeSpeechRecognitionProxy( + IConfiguration configuration, + ILogger logger) + { + _apiKey = FirstConfigured( + configuration["DASHSCOPE_ASR_API_KEY"], + configuration["VLM_API_KEY"], + configuration["QWEN_API_KEY"]); + _model = configuration["DASHSCOPE_ASR_MODEL"]?.Trim() is { Length: > 0 } model + ? model + : "fun-asr-realtime"; + _workspaceId = configuration["DASHSCOPE_ASR_WORKSPACE_ID"]?.Trim() ?? ""; + var configuredMaxDuration = int.TryParse( + configuration["DASHSCOPE_ASR_MAX_DURATION_SECONDS"], + out var parsedMaxDuration) + ? parsedMaxDuration + : 60; + _maxDurationSeconds = Math.Clamp(configuredMaxDuration, 10, 180); + + var configuredEndpoint = configuration["DASHSCOPE_ASR_WEBSOCKET_URL"]?.Trim(); + var endpoint = !string.IsNullOrWhiteSpace(configuredEndpoint) + ? configuredEndpoint + : !string.IsNullOrWhiteSpace(_workspaceId) + ? $"wss://{_workspaceId}.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference" + : "wss://dashscope.aliyuncs.com/api-ws/v1/inference"; + if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) && uri.Scheme == "wss") + _endpoint = uri; + + _logger = logger; + } + + public bool IsConfigured => + !string.IsNullOrWhiteSpace(_apiKey) && _endpoint is not null; + + private static string FirstConfigured(params string?[] values) => + values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? ""; + + public async Task ProxyAsync( + WebSocket clientSocket, + CancellationToken cancellationToken) + { + if (!IsConfigured) + throw new InvalidOperationException("实时语音识别服务尚未配置"); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(_maxDurationSeconds + 15)); + var ct = timeoutCts.Token; + + using var cloudSocket = new ClientWebSocket(); + cloudSocket.Options.SetRequestHeader("Authorization", $"Bearer {_apiKey}"); + cloudSocket.Options.SetRequestHeader("User-Agent", "XiaomaiHealth/1.0"); + if (!string.IsNullOrWhiteSpace(_workspaceId)) + cloudSocket.Options.SetRequestHeader("X-DashScope-WorkSpace", _workspaceId); + + await cloudSocket.ConnectAsync(_endpoint!, ct); + + var taskId = Guid.NewGuid().ToString(); + await SendJsonAsync(cloudSocket, new + { + header = new + { + action = "run-task", + task_id = taskId, + streaming = "duplex", + }, + payload = new + { + task_group = "audio", + task = "asr", + function = "recognition", + model = _model, + parameters = new + { + format = "pcm", + sample_rate = SampleRate, + language_hints = new[] { "zh" }, + semantic_punctuation_enabled = false, + max_sentence_silence = 800, + heartbeat = true, + }, + input = new { }, + }, + }, ct); + + await WaitForTaskStartedAsync(cloudSocket, taskId, ct); + await SendClientEventAsync(clientSocket, new { type = "ready" }, ct); + + var clientPump = PumpClientAudioAsync( + clientSocket, + cloudSocket, + taskId, + _maxDurationSeconds, + ct); + var cloudPump = PumpCloudResultsAsync(cloudSocket, clientSocket, ct); + + var completed = await Task.WhenAny(clientPump, cloudPump); + if (completed == cloudPump) + { + await cloudPump; + timeoutCts.Cancel(); + } + else + { + var action = await clientPump; + if (action == ClientAction.Finish) + { + try + { + await cloudPump.WaitAsync(TimeSpan.FromSeconds(12), ct); + } + catch (TimeoutException) + { + await SendClientEventAsync( + clientSocket, + new { type = "error", message = "语音识别超时,请重试" }, + CancellationToken.None); + } + catch (OperationCanceledException) + { + // 客户端断开或硬超时,无需再给客户端发消息 + } + } + else + { + timeoutCts.Cancel(); + } + } + + await CloseSocketQuietlyAsync(cloudSocket); + await CloseSocketQuietlyAsync(clientSocket); + } + + private static async Task WaitForTaskStartedAsync( + WebSocket cloudSocket, + string taskId, + CancellationToken ct) + { + while (true) + { + var message = await ReceiveMessageAsync(cloudSocket, ct); + if (message is null) + throw new InvalidOperationException("语音识别服务提前断开"); + if (message.Value.Type != WebSocketMessageType.Text) continue; + + using var json = JsonDocument.Parse(message.Value.Payload); + var header = json.RootElement.GetProperty("header"); + var eventName = header.GetProperty("event").GetString(); + var responseTaskId = header.TryGetProperty("task_id", out var id) + ? id.GetString() + : null; + if (eventName == "task-started" && responseTaskId == taskId) return; + if (eventName == "task-failed") + { + var error = header.TryGetProperty("error_message", out var errorMessage) + ? errorMessage.GetString() + : "语音识别启动失败"; + throw new InvalidOperationException(error); + } + } + } + + private static async Task PumpClientAudioAsync( + WebSocket clientSocket, + WebSocket cloudSocket, + string taskId, + int maxDurationSeconds, + CancellationToken ct) + { + var maxBytes = (long)BytesPerSecond * maxDurationSeconds; + long audioBytes = 0; + + while (!ct.IsCancellationRequested && + clientSocket.State == WebSocketState.Open) + { + var message = await ReceiveMessageAsync(clientSocket, ct); + if (message is null) return ClientAction.Disconnect; + + if (message.Value.Type == WebSocketMessageType.Binary) + { + audioBytes += message.Value.Payload.Length; + if (audioBytes > maxBytes) + { + // 达到时长上限:主动结束并让阿里云返回已识别的结果, + // 而不是直接断开。客户端松手后会拿到这段文字。 + await SendJsonAsync(cloudSocket, new + { + header = new + { + action = "finish-task", + task_id = taskId, + streaming = "duplex", + }, + payload = new { input = new { } }, + }, ct); + return ClientAction.Finish; + } + await cloudSocket.SendAsync( + message.Value.Payload, + WebSocketMessageType.Binary, + true, + ct); + continue; + } + + if (message.Value.Type != WebSocketMessageType.Text) continue; + using var json = JsonDocument.Parse(message.Value.Payload); + var action = json.RootElement.TryGetProperty("action", out var actionElement) + ? actionElement.GetString() + : null; + if (action == "cancel") return ClientAction.Cancel; + if (action != "finish") continue; + + await SendJsonAsync(cloudSocket, new + { + header = new + { + action = "finish-task", + task_id = taskId, + streaming = "duplex", + }, + payload = new { input = new { } }, + }, ct); + return ClientAction.Finish; + } + + return ClientAction.Disconnect; + } + + private async Task PumpCloudResultsAsync( + WebSocket cloudSocket, + WebSocket clientSocket, + CancellationToken ct) + { + var committed = new StringBuilder(); + var latest = ""; + + while (!ct.IsCancellationRequested && + cloudSocket.State == WebSocketState.Open) + { + var message = await ReceiveMessageAsync(cloudSocket, ct); + if (message is null) return; + if (message.Value.Type != WebSocketMessageType.Text) continue; + + using var json = JsonDocument.Parse(message.Value.Payload); + var root = json.RootElement; + if (!root.TryGetProperty("header", out var header)) continue; + var eventName = header.TryGetProperty("event", out var eventElement) + ? eventElement.GetString() + : null; + + if (eventName == "result-generated") + { + if (!TryReadSentence(root, out var sentence, out var sentenceEnd)) + continue; + if (sentenceEnd && !string.IsNullOrWhiteSpace(sentence)) + committed.Append(sentence); + latest = sentenceEnd + ? committed.ToString() + : committed + sentence; + await SendClientEventAsync(clientSocket, new + { + type = "partial", + text = latest, + sentenceEnd, + }, ct); + continue; + } + + if (eventName == "task-finished") + { + await SendClientEventAsync( + clientSocket, + new { type = "done", text = latest.Trim() }, + ct); + return; + } + + if (eventName == "task-failed") + { + var error = header.TryGetProperty("error_message", out var errorElement) + ? errorElement.GetString() + : "语音识别失败"; + _logger.LogWarning("DashScope ASR task failed: {Error}", error); + await SendClientEventAsync( + clientSocket, + new { type = "error", message = "语音识别失败,请重试" }, + ct); + return; + } + } + } + + private static bool TryReadSentence( + JsonElement root, + out string text, + out bool sentenceEnd) + { + text = ""; + sentenceEnd = false; + if (!root.TryGetProperty("payload", out var payload) || + !payload.TryGetProperty("output", out var output) || + !output.TryGetProperty("sentence", out var sentence)) + return false; + + text = sentence.TryGetProperty("text", out var textElement) + ? textElement.GetString() ?? "" + : ""; + sentenceEnd = sentence.TryGetProperty("sentence_end", out var endElement) && + endElement.ValueKind == JsonValueKind.True; + return true; + } + + private static Task SendClientEventAsync( + WebSocket socket, + object value, + CancellationToken ct) => SendJsonAsync(socket, value, ct); + + private static async Task SendJsonAsync( + WebSocket socket, + object value, + CancellationToken ct) + { + if (socket.State != WebSocketState.Open) return; + var bytes = JsonSerializer.SerializeToUtf8Bytes(value); + await socket.SendAsync(bytes, WebSocketMessageType.Text, true, ct); + } + + private static async Task<(WebSocketMessageType Type, byte[] Payload)?> + ReceiveMessageAsync(WebSocket socket, CancellationToken ct) + { + var buffer = new byte[16 * 1024]; + using var stream = new MemoryStream(); + while (true) + { + var result = await socket.ReceiveAsync(buffer, ct); + if (result.MessageType == WebSocketMessageType.Close) return null; + stream.Write(buffer, 0, result.Count); + if (stream.Length > 2 * 1024 * 1024) + throw new InvalidOperationException("WebSocket 消息过大"); + if (result.EndOfMessage) + return (result.MessageType, stream.ToArray()); + } + } + + private static async Task CloseSocketQuietlyAsync(WebSocket socket) + { + if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived)) + return; + try + { + await socket.CloseAsync( + WebSocketCloseStatus.NormalClosure, + "completed", + CancellationToken.None); + } + catch (WebSocketException) + { + // 对端可能已经主动断开。 + } + } + + private enum ClientAction + { + Finish, + Cancel, + Disconnect, + } +} diff --git a/backend/src/Health.WebApi/Endpoints/speech_endpoints.cs b/backend/src/Health.WebApi/Endpoints/speech_endpoints.cs new file mode 100644 index 0000000..f9469b5 --- /dev/null +++ b/backend/src/Health.WebApi/Endpoints/speech_endpoints.cs @@ -0,0 +1,69 @@ +using System.Net.WebSockets; +using System.Text.Json; +using Health.Application.Speech; + +namespace Health.WebApi.Endpoints; + +public static class SpeechEndpoints +{ + public static void MapSpeechEndpoints(this WebApplication app) + { + app.Map("/api/speech/realtime", async ( + HttpContext http, + IRealtimeSpeechRecognitionProxy proxy, + ILoggerFactory loggerFactory) => + { + if (!http.WebSockets.IsWebSocketRequest) + { + http.Response.StatusCode = StatusCodes.Status400BadRequest; + await http.Response.WriteAsJsonAsync(new + { + code = 40001, + data = (object?)null, + message = "该接口仅支持 WebSocket 连接", + }); + return; + } + + if (!proxy.IsConfigured) + { + http.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await http.Response.WriteAsJsonAsync(new + { + code = 50301, + data = (object?)null, + message = "实时语音识别服务尚未配置", + }); + return; + } + + using var socket = await http.WebSockets.AcceptWebSocketAsync(); + try + { + await proxy.ProxyAsync(socket, http.RequestAborted); + } + catch (OperationCanceledException) when (http.RequestAborted.IsCancellationRequested) + { + // 客户端离开页面或断开网络。 + } + catch (Exception ex) + { + loggerFactory.CreateLogger("SpeechEndpoints") + .LogError(ex, "Realtime speech proxy failed"); + if (socket.State == WebSocketState.Open) + { + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + type = "error", + message = "语音服务暂时不可用,请稍后重试", + }); + await socket.SendAsync( + payload, + WebSocketMessageType.Text, + true, + CancellationToken.None); + } + } + }).RequireAuthorization(policy => policy.RequireRole("User")); + } +} diff --git a/backend/src/Health.WebApi/Program.cs b/backend/src/Health.WebApi/Program.cs index 4da8344..bbbb4e0 100644 --- a/backend/src/Health.WebApi/Program.cs +++ b/backend/src/Health.WebApi/Program.cs @@ -11,6 +11,7 @@ using Health.Application.Medications; using Health.Application.Maintenance; using Health.Application.Notifications; using Health.Application.Reports; +using Health.Application.Speech; using Health.Application.Users; using Health.Infrastructure.AI; using Health.Infrastructure.Admin; @@ -25,6 +26,7 @@ using Health.Infrastructure.Medications; using Health.Infrastructure.Maintenance; using Health.Infrastructure.Notifications; using Health.Infrastructure.Reports; +using Health.Infrastructure.Speech; using Health.Infrastructure.Services; using Health.Infrastructure.Users; using Health.WebApi.BackgroundServices; @@ -150,6 +152,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddScoped(_ => new LocalAccountFileCleanup( Path.Combine(Directory.GetCurrentDirectory(), "uploads"))); @@ -205,6 +208,10 @@ app.UseMiddleware(); app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); +app.UseWebSockets(new WebSocketOptions +{ + KeepAliveInterval = TimeSpan.FromSeconds(20), +}); // 默认 wwwroot 静态文件(法律文档 H5 页面等) app.UseDefaultFiles(); @@ -240,6 +247,7 @@ app.MapNotificationEndpoints(); app.MapDoctorEndpoints(); app.MapAdminEndpoints(); app.MapFollowUpEndpoints(); +app.MapSpeechEndpoints(); // SignalR Hub app.MapHub("/hubs/consultation"); diff --git a/health_app/android/app/build.gradle.kts b/health_app/android/app/build.gradle.kts index 6169d34..c09d431 100644 --- a/health_app/android/app/build.gradle.kts +++ b/health_app/android/app/build.gradle.kts @@ -36,7 +36,7 @@ android { versionName = flutter.versionName } - // 正式签名配置 + // 正式签名配置:key.properties 不存在时回退到 debug keystore,仅用于本地 release 测试 signingConfigs { create("release") { if (keystorePropertiesFile.exists()) { @@ -46,6 +46,11 @@ android { file(path) } storePassword = keystoreProperties.getProperty("storePassword") + } else { + keyAlias = "androiddebugkey" + keyPassword = "android" + storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore") + storePassword = "android" } } } diff --git a/health_app/android/app/src/main/AndroidManifest.xml b/health_app/android/app/src/main/AndroidManifest.xml index 06ecede..958e97c 100644 --- a/health_app/android/app/src/main/AndroidManifest.xml +++ b/health_app/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/health_app/ios/Runner/Info.plist b/health_app/ios/Runner/Info.plist index 22bbfbb..6152c17 100644 --- a/health_app/ios/Runner/Info.plist +++ b/health_app/ios/Runner/Info.plist @@ -32,6 +32,8 @@ 需要使用蓝牙连接支持的血压计等健康设备 NSCameraUsageDescription 需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录 + NSMicrophoneUsageDescription + 需要使用麦克风将你按住说出的健康问题转换为文字并发送给小脉健康助手 NSPhotoLibraryAddUsageDescription 需要保存图片到相册 NSPhotoLibraryUsageDescription diff --git a/health_app/lib/app.dart b/health_app/lib/app.dart index 571b683..2fa4d11 100644 --- a/health_app/lib/app.dart +++ b/health_app/lib/app.dart @@ -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((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; diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 030278d..8fcd9e1 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -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 { diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index fbf39e4..4996a36 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -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': diff --git a/health_app/lib/core/app_theme.dart b/health_app/lib/core/app_theme.dart index 739f70e..ccf384b 100644 --- a/health_app/lib/core/app_theme.dart +++ b/health_app/lib/core/app_theme.dart @@ -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( diff --git a/health_app/lib/core/elder_mode_scope.dart b/health_app/lib/core/elder_mode_scope.dart new file mode 100644 index 0000000..182a679 --- /dev/null +++ b/health_app/lib/core/elder_mode_scope.dart @@ -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(); + } + + 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; + } +} diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index 1f37782..38ae51b 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -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 String? _pickedImagePath; Timer? _notificationTimer; double _conversationDragDistance = 0; + bool _voiceMode = false; + _SpeechPhase _speechPhase = _SpeechPhase.idle; + RealtimeSpeechSession? _speechSession; + StreamSubscription? _speechEventSubscription; + String _speechText = ''; + bool _voicePressActive = false; + bool _cancelVoiceOnRelease = false; + bool _finishVoiceWhenReady = false; + double? _voicePressStartY; void _startConversationDrawerDrag(DragStartDetails details) { _conversationDragDistance = 0; @@ -55,6 +68,8 @@ class _HomePageState extends ConsumerState @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 if (state == AppLifecycleState.resumed) { _checkDueNotifications(); } + if (state == AppLifecycleState.paused && + _speechPhase != _SpeechPhase.idle) { + unawaited(_cancelVoiceRecording()); + } } @override @@ -81,6 +100,8 @@ class _HomePageState extends ConsumerState _textCtrl.dispose(); _scrollCtrl.dispose(); _focusNode.dispose(); + unawaited(_speechEventSubscription?.cancel()); + unawaited(_speechSession?.cancel()); super.dispose(); } @@ -124,6 +145,19 @@ class _HomePageState extends ConsumerState final auth = ref.watch(authProvider); final user = auth.user; + ref.listen( + elderModeProvider.select( + (state) => state.isEnabled && state.preferences.voiceInputEnabled, + ), + (previous, current) { + if (previous == current || !mounted) return; + if (_speechPhase != _SpeechPhase.idle) { + unawaited(_cancelVoiceRecording()); + } + setState(() => _voiceMode = current); + }, + ); + ref.listen(cameraActionProvider, (prev, next) { if (next == 'camera') { _pickImage(ImageSource.camera); @@ -202,12 +236,13 @@ class _HomePageState extends ConsumerState } 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 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 ]; 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 ), 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 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 } 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 _buildTextInputContents(bool elderMode, bool isStreaming) { + return [ + _InputCircleButton( + icon: LucideIcons.plus, + elderMode: elderMode, + gradient: AppColors.primaryGradient, + iconColor: Colors.white, + onTap: () => _showAttachmentPicker(context), + ), + const SizedBox(width: 6), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 112), + child: TextField( + controller: _textCtrl, + focusNode: _focusNode, + style: TextStyle( + fontSize: elderMode ? 18 : 15, + color: AppColors.textPrimary, + ), + maxLines: null, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + hintText: '输入你想说的...', + filled: false, + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 4, + vertical: elderMode ? 15 : 12, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + onSubmitted: (_) => _sendMessage(), + ), + ), + ), + const SizedBox(width: 4), + _InputCircleButton( + icon: LucideIcons.mic, + elderMode: elderMode, + gradient: AppColors.primaryGradient, + iconColor: Colors.white, + onTap: isStreaming ? null : _switchToVoiceMode, + ), + const SizedBox(width: 4), + _InputCircleButton( + icon: isStreaming ? Icons.stop_rounded : LucideIcons.send, + elderMode: elderMode, + gradient: AppColors.primaryGradient, + iconColor: Colors.white, + onTap: isStreaming + ? () => ref.read(chatProvider.notifier).stopGenerating() + : _sendMessage, + ), + ]; + } + + List _buildVoiceInputContents(bool elderMode, bool isStreaming) { + return [ + _InputCircleButton( + icon: LucideIcons.plus, + elderMode: elderMode, + gradient: AppColors.primaryGradient, + iconColor: Colors.white, + onTap: _speechPhase == _SpeechPhase.idle + ? () => _showAttachmentPicker(context) + : null, + ), + const SizedBox(width: 6), + Expanded( + child: isStreaming + ? InkWell( + onTap: () => ref.read(chatProvider.notifier).stopGenerating(), + borderRadius: AppRadius.pillBorder, + child: _VoiceHoldSurface( + elderMode: elderMode, + label: '停止生成', + color: AppColors.primary, + ), + ) + : Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: _onVoicePointerDown, + onPointerMove: _onVoicePointerMove, + onPointerUp: _onVoicePointerUp, + onPointerCancel: _onVoicePointerCancel, + child: _VoiceHoldSurface( + elderMode: elderMode, + label: _voiceInputLabel, + color: _voiceLabelColor, + ), + ), + ), + const SizedBox(width: 4), + _InputCircleButton( + icon: LucideIcons.keyboard, + elderMode: elderMode, + gradient: AppColors.primaryGradient, + iconColor: Colors.white, + onTap: _speechPhase == _SpeechPhase.idle ? _switchToTextMode : null, + ), + ]; + } + + String get _voiceInputLabel { + if (_cancelVoiceOnRelease) return '松开取消'; + return switch (_speechPhase) { + _SpeechPhase.connecting => '正在连接…', + _SpeechPhase.listening => + _speechText.trim().isEmpty ? '松开发送' : _speechText.trim(), + _SpeechPhase.finishing => '正在识别…', + _SpeechPhase.idle => '按住说话', + }; + } + + Color get _voiceLabelColor { + if (_cancelVoiceOnRelease) return AppColors.error; + if (_speechPhase == _SpeechPhase.listening || + _speechPhase == _SpeechPhase.connecting || + _speechPhase == _SpeechPhase.finishing) { + return AppColors.primary; + } + return AppColors.textSecondary; + } + + Future _switchToVoiceMode() async { + _dismissKeyboard(); + final allowed = await ref + .read(realtimeSpeechServiceProvider) + .requestPermission(); + if (!mounted) return; + if (!allowed) { + AppToast.show(context, '需要麦克风权限才能使用语音输入', type: AppToastType.error); + return; + } + setState(() => _voiceMode = true); + } + + void _switchToTextMode() { + if (_speechPhase != _SpeechPhase.idle) return; + setState(() => _voiceMode = false); + } + + void _onVoicePointerDown(PointerDownEvent event) { + if (_speechPhase != _SpeechPhase.idle) return; + _voicePressActive = true; + _cancelVoiceOnRelease = false; + _finishVoiceWhenReady = false; + _voicePressStartY = event.position.dy; + unawaited(_startVoiceRecording()); + } + + void _onVoicePointerMove(PointerMoveEvent event) { + if (!_voicePressActive || _voicePressStartY == null) return; + final shouldCancel = _voicePressStartY! - event.position.dy >= 60; + if (shouldCancel != _cancelVoiceOnRelease && mounted) { + setState(() => _cancelVoiceOnRelease = shouldCancel); + } + } + + void _onVoicePointerUp(PointerUpEvent event) { + if (!_voicePressActive) return; + _voicePressActive = false; + if (_speechPhase == _SpeechPhase.connecting) { + _finishVoiceWhenReady = true; + return; + } + if (_cancelVoiceOnRelease) { + unawaited(_cancelVoiceRecording()); + } else if (_speechPhase == _SpeechPhase.listening) { + unawaited(_finishVoiceRecording()); + } + } + + void _onVoicePointerCancel(PointerCancelEvent event) { + if (!_voicePressActive) return; + _voicePressActive = false; + _cancelVoiceOnRelease = true; + unawaited(_cancelVoiceRecording()); + } + + Future _startVoiceRecording() async { + setState(() { + _speechPhase = _SpeechPhase.connecting; + _speechText = ''; + }); + try { + final session = await ref.read(realtimeSpeechServiceProvider).start(); + if (!mounted) { + await session.cancel(); + return; + } + _speechSession = session; + _speechEventSubscription = session.events.listen((event) { + if (!mounted) return; + if (event.type == SpeechRecognitionEventType.error) { + _handleSpeechFailure(event.text); + return; + } + setState(() => _speechText = event.text); + }); + setState(() => _speechPhase = _SpeechPhase.listening); + if (_finishVoiceWhenReady || !_voicePressActive) { + if (_cancelVoiceOnRelease) { + await _cancelVoiceRecording(); + } else { + await _finishVoiceRecording(); + } + } + } on SpeechRecognitionException catch (error) { + _handleSpeechFailure(error.message); + } catch (_) { + _handleSpeechFailure('语音输入启动失败,请稍后重试'); + } + } + + Future _finishVoiceRecording() async { + final session = _speechSession; + if (session == null || _speechPhase == _SpeechPhase.finishing) return; + setState(() => _speechPhase = _SpeechPhase.finishing); + try { + final text = (await session.finish()).trim(); + if (!mounted) return; + await _releaseSpeechSession(); + if (!mounted) return; + _resetSpeechState(); + if (text.isEmpty) { + AppToast.show(context, '没有听清,请再说一次'); + return; + } + _textCtrl.text = text; + _sendMessage(); + } on SpeechRecognitionException catch (error) { + _handleSpeechFailure(error.message); + } catch (_) { + _handleSpeechFailure('语音识别失败,请重试'); + } + } + + Future _cancelVoiceRecording() async { + try { + await _speechSession?.cancel(); + } finally { + await _releaseSpeechSession(); + if (mounted) _resetSpeechState(); + } + } + + Future _releaseSpeechSession() async { + await _speechEventSubscription?.cancel(); + _speechEventSubscription = null; + await _speechSession?.dispose(); + _speechSession = null; + } + + void _handleSpeechFailure(String message) { + if (_speechPhase == _SpeechPhase.idle) return; + final partialText = _speechText.trim(); + unawaited(_releaseSpeechSession()); + if (!mounted) return; + _resetSpeechState(); + if (partialText.isNotEmpty) { + _textCtrl.text = partialText; + setState(() => _voiceMode = false); + } + AppToast.show(context, message, type: AppToastType.error); + } + + void _resetSpeechState() { + if (!mounted) return; + setState(() { + _speechPhase = _SpeechPhase.idle; + _speechText = ''; + _voicePressActive = false; + _cancelVoiceOnRelease = false; + _finishVoiceWhenReady = false; + _voicePressStartY = null; + }); + } + void _showAttachmentPicker(BuildContext context) { _dismissKeyboard(); showModalBottomSheet( @@ -649,6 +902,82 @@ class _HomePageState extends ConsumerState } } +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( diff --git a/health_app/lib/pages/settings/elder_mode_page.dart b/health_app/lib/pages/settings/elder_mode_page.dart new file mode 100644 index 0000000..533b174 --- /dev/null +++ b/health_app/lib/pages/settings/elder_mode_page.dart @@ -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(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 onLargeTextChanged; + final ValueChanged 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)), + ); + } +} diff --git a/health_app/lib/pages/settings/settings_pages.dart b/health_app/lib/pages/settings/settings_pages.dart index 5006dee..fe374bb 100644 --- a/health_app/lib/pages/settings/settings_pages.dart +++ b/health_app/lib/pages/settings/settings_pages.dart @@ -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, diff --git a/health_app/lib/providers/elder_mode_provider.dart b/health_app/lib/providers/elder_mode_provider.dart new file mode 100644 index 0000000..f4b9498 --- /dev/null +++ b/health_app/lib/providers/elder_mode_provider.dart @@ -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 toJson() => { + 'schemaVersion': 1, + 'enabled': enabled, + 'largeTextEnabled': largeTextEnabled, + 'voiceInputEnabled': voiceInputEnabled, + }; + + factory ElderModePreferences.fromJson(Map 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.new, +); + +class ElderModeNotifier extends Notifier { + 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.microtask(_load); + return const ElderModeState(); + } + + String get _storageKey => '$_storageKeyPrefix$_currentUserId'; + + Future _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.from(decoded), + ), + isLoaded: true, + clearError: true, + ); + } catch (_) { + state = state.copyWith(isLoaded: true, error: '长辈模式设置读取失败,已使用默认设置'); + } + } + + Future enable() => + _save(state.preferences.copyWith(enabled: true, largeTextEnabled: true)); + + Future disable() => _save(state.preferences.copyWith(enabled: false)); + + Future setLargeTextEnabled(bool enabled) => + _save(state.preferences.copyWith(largeTextEnabled: enabled)); + + Future setVoiceInputEnabled(bool enabled) => + _save(state.preferences.copyWith(voiceInputEnabled: enabled)); + + Future _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; + } + } +} diff --git a/health_app/lib/services/realtime_speech_service.dart b/health_app/lib/services/realtime_speech_service.dart new file mode 100644 index 0000000..e29fa66 --- /dev/null +++ b/health_app/lib/services/realtime_speech_service.dart @@ -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((ref) { + return RealtimeSpeechService(ref.watch(apiClientProvider)); +}); + +class RealtimeSpeechService { + final ApiClient _apiClient; + + const RealtimeSpeechService(this._apiClient); + + Future requestPermission() async { + final recorder = AudioRecorder(); + try { + return await recorder.hasPermission(); + } finally { + recorder.dispose(); + } + } + + Future 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 _events = + StreamController.broadcast(); + final Completer _ready = Completer(); + final Completer _done = Completer(); + + WebSocket? _socket; + StreamSubscription? _audioSubscription; + StreamSubscription? _socketSubscription; + bool _finishing = false; + bool _disposed = false; + String _latestText = ''; + + RealtimeSpeechSession({required this.endpoint, required this.accessToken}) { + _ready.future.ignore(); + _done.future.ignore(); + } + + Stream get events => _events.stream; + String get latestText => _latestText; + + Future 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 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 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 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(); + } +} diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 78d3baf..c3d06b0 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -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 diff --git a/health_app/pubspec.lock b/health_app/pubspec.lock index 0432735..2427e08 100644 --- a/health_app/pubspec.lock +++ b/health_app/pubspec.lock @@ -829,6 +829,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + record: + dependency: "direct main" + description: + name: record + sha256: "10911465138fafacef459a780564e883e01bd48eabf87ab20543684884492870" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + record_android: + dependency: transitive + description: + name: record_android + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: cfe1b61435e27db418bf513dc36820d10c9f7eb1843786c2c9a52e07e2f4f627 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488" + url: "https://pub.dev" + source: hosted + version: "1.6.0" record_use: dependency: transitive description: @@ -837,6 +885,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" riverpod: dependency: transitive description: diff --git a/health_app/pubspec.yaml b/health_app/pubspec.yaml index cb41e2c..1274ff8 100644 --- a/health_app/pubspec.yaml +++ b/health_app/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: # 蓝牙 BLE flutter_blue_plus: ^1.34.0 permission_handler: ^11.3.0 + record: ^6.2.1 # Apple 登录 sign_in_with_apple: ^6.1.0 diff --git a/health_app/test/audit_fix_guardrails_test.dart b/health_app/test/audit_fix_guardrails_test.dart index 82ce615..7a97e25 100644 --- a/health_app/test/audit_fix_guardrails_test.dart +++ b/health_app/test/audit_fix_guardrails_test.dart @@ -53,8 +53,8 @@ void main() { source.indexOf('class _HeaderIconButton'), ); - expect(buttonSource, contains('width: 44')); - expect(buttonSource, contains('height: 44')); + expect(buttonSource, contains('width: elderMode ? 54 : 44')); + expect(buttonSource, contains('height: elderMode ? 54 : 44')); }); test('settings logout action is white with dark text', () { diff --git a/health_app/test/elder_mode_page_test.dart b/health_app/test/elder_mode_page_test.dart new file mode 100644 index 0000000..dd9bf57 --- /dev/null +++ b/health_app/test/elder_mode_page_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/core/app_theme.dart'; +import 'package:health_app/core/elder_mode_scope.dart'; +import 'package:health_app/pages/settings/elder_mode_page.dart'; +import 'package:health_app/providers/elder_mode_provider.dart'; + +class _FakeElderModeNotifier extends ElderModeNotifier { + final bool initiallyEnabled; + + _FakeElderModeNotifier(this.initiallyEnabled); + + @override + ElderModeState build() => ElderModeState( + preferences: ElderModePreferences(enabled: initiallyEnabled), + isLoaded: true, + ); + + @override + Future enable() async { + state = state.copyWith( + preferences: state.preferences.copyWith(enabled: true), + ); + return true; + } + + @override + Future disable() async { + state = state.copyWith( + preferences: state.preferences.copyWith(enabled: false), + ); + return true; + } + + @override + Future setLargeTextEnabled(bool enabled) async { + state = state.copyWith( + preferences: state.preferences.copyWith(largeTextEnabled: enabled), + ); + return true; + } + + @override + Future setVoiceInputEnabled(bool enabled) async { + state = state.copyWith( + preferences: state.preferences.copyWith(voiceInputEnabled: enabled), + ); + return true; + } +} + +class _ElderModeHarness extends ConsumerWidget { + const _ElderModeHarness(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(elderModeProvider); + final baseTheme = state.isEnabled + ? AppTheme.elderLightTheme + : AppTheme.lightTheme; + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: baseTheme, + home: ElderModeScope( + enabled: state.isEnabled, + largeTextEnabled: state.isEnabled && state.preferences.largeTextEnabled, + child: const ElderModePage(), + ), + ); + } +} + +Future _pumpPage(WidgetTester tester, {required bool enabled}) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + elderModeProvider.overrideWith(() => _FakeElderModeNotifier(enabled)), + ], + child: const _ElderModeHarness(), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('长辈模式可立即开启并显示已开启状态', (tester) async { + await _pumpPage(tester, enabled: false); + + expect(find.text('开启长辈模式,操作更清晰'), findsOneWidget); + expect(find.text('立即开启'), findsOneWidget); + + await tester.tap(find.text('立即开启')); + await tester.pumpAndSettle(); + + expect(find.text('长辈模式已开启'), findsOneWidget); + expect(find.text('大图标和大按钮已启用'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('长辈模式页面在 390x844 下无布局溢出', (tester) async { + await _pumpPage(tester, enabled: true); + + expect(find.text('关闭长辈模式'), findsOneWidget); + expect(find.byType(Switch), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }); +} diff --git a/health_app/test/home_message_order_test.dart b/health_app/test/home_message_order_test.dart index a7b7a69..2b5f175 100644 --- a/health_app/test/home_message_order_test.dart +++ b/health_app/test/home_message_order_test.dart @@ -139,11 +139,11 @@ void main() { ).readAsStringSync(); final drawer = File('lib/widgets/health_drawer.dart').readAsStringSync(); - expect(source, contains('width: 18')); - expect(source, contains('height: 18')); + expect(source, contains('width: elderMode ? 24 : 18')); + expect(source, contains('height: elderMode ? 24 : 18')); expect(source, contains('visual.icon,')); - expect(source, contains('size: 13')); - expect(source, contains('fontSize: 15')); + expect(source, contains('size: elderMode ? 17 : 13')); + expect(source, contains('fontSize: elderMode ? 17 : 15')); expect(source, contains('gradient: selected')); expect(source, contains('? AppColors.actionOutlineGradient')); expect(source, contains('selected ? null : Colors.transparent')); @@ -165,9 +165,10 @@ void main() { expect(input, contains('LucideIcons.send')); expect(input, contains('border: InputBorder.none')); expect(input, contains('crossAxisAlignment: CrossAxisAlignment.center')); + // 文字模式:附件 / 麦克风 / 发送;语音模式:附件 / 键盘。全部用主渐变。 expect( RegExp('gradient: AppColors.primaryGradient').allMatches(input).length, - 2, + 5, ); expect(input, isNot(contains('Border.all(color: AppColors.borderLight)'))); expect(source, isNot(contains('class _RoundToolButton'))); diff --git a/health_app/test/speech_input_test.dart b/health_app/test/speech_input_test.dart new file mode 100644 index 0000000..30b9536 --- /dev/null +++ b/health_app/test/speech_input_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/providers/elder_mode_provider.dart'; +import 'package:health_app/services/realtime_speech_service.dart'; + +void main() { + test('实时语音地址保留后端基础路径并切换为 WebSocket', () { + expect( + buildRealtimeSpeechEndpoint('http://192.168.1.34:5000').toString(), + 'ws://192.168.1.34:5000/api/speech/realtime', + ); + expect( + buildRealtimeSpeechEndpoint( + 'https://erpapi.datalumina.cn/xiaomai/', + ).toString(), + 'wss://erpapi.datalumina.cn/xiaomai/api/speech/realtime', + ); + }); + + test('旧版长辈设置升级后默认启用语音输入', () { + final preferences = ElderModePreferences.fromJson({ + 'schemaVersion': 1, + 'enabled': true, + 'largeTextEnabled': true, + }); + + expect(preferences.voiceInputEnabled, isTrue); + }); + + test('长辈模式语音输入开关可以持久化', () { + const preferences = ElderModePreferences( + enabled: true, + voiceInputEnabled: false, + ); + final restored = ElderModePreferences.fromJson(preferences.toJson()); + + expect(restored.enabled, isTrue); + expect(restored.voiceInputEnabled, isFalse); + }); +}