feat: 长辈模式(大字大按钮 + 偏好按账号隔离)+ 语音输入(按住说话 + 实时转写 + 后端代理 DashScope ASR + 患者端专用)+ 健康仪表盘背景渐变
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace Health.Application.Speech;
|
||||
|
||||
public interface IRealtimeSpeechRecognitionProxy
|
||||
{
|
||||
bool IsConfigured { get; }
|
||||
|
||||
Task ProxyAsync(WebSocket clientSocket, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -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<DashScopeSpeechRecognitionProxy> _logger;
|
||||
|
||||
public DashScopeSpeechRecognitionProxy(
|
||||
IConfiguration configuration,
|
||||
ILogger<DashScopeSpeechRecognitionProxy> 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<ClientAction> 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,
|
||||
}
|
||||
}
|
||||
69
backend/src/Health.WebApi/Endpoints/speech_endpoints.cs
Normal file
69
backend/src/Health.WebApi/Endpoints/speech_endpoints.cs
Normal file
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<IReportAnalysisService, ReportAnalysisService>();
|
||||
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
||||
builder.Services.AddSingleton<IRealtimeSpeechRecognitionProxy, DashScopeSpeechRecognitionProxy>();
|
||||
builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup(
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "uploads")));
|
||||
|
||||
@@ -205,6 +208,10 @@ app.UseMiddleware<ExceptionMiddleware>();
|
||||
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<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation");
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
<string>需要使用蓝牙连接支持的血压计等健康设备</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要使用麦克风将你按住说出的健康问题转换为文字并发送给小脉健康助手</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>需要保存图片到相册</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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(
|
||||
|
||||
42
health_app/lib/core/elder_mode_scope.dart
Normal file
42
health_app/lib/core/elder_mode_scope.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
420
health_app/lib/pages/settings/elder_mode_page.dart
Normal file
420
health_app/lib/pages/settings/elder_mode_page.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
162
health_app/lib/providers/elder_mode_provider.dart
Normal file
162
health_app/lib/providers/elder_mode_provider.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
268
health_app/lib/services/realtime_speech_service.dart
Normal file
268
health_app/lib/services/realtime_speech_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
113
health_app/test/elder_mode_page_test.dart
Normal file
113
health_app/test/elder_mode_page_test.dart
Normal file
@@ -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<bool> enable() async {
|
||||
state = state.copyWith(
|
||||
preferences: state.preferences.copyWith(enabled: true),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> disable() async {
|
||||
state = state.copyWith(
|
||||
preferences: state.preferences.copyWith(enabled: false),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> setLargeTextEnabled(bool enabled) async {
|
||||
state = state.copyWith(
|
||||
preferences: state.preferences.copyWith(largeTextEnabled: enabled),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> 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<void> _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);
|
||||
});
|
||||
}
|
||||
@@ -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')));
|
||||
|
||||
39
health_app/test/speech_input_test.dart
Normal file
39
health_app/test/speech_input_test.dart
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user