Commit c159902ac985296db6b71351ea2cf6a59d8e1f96

Authored by 权海
1 parent 67367a11

feat(ui):本地推送国际化

@@ -19,56 +19,7 @@ class DoubleFeelApp extends StatelessWidget { @@ -19,56 +19,7 @@ class DoubleFeelApp extends StatelessWidget {
19 getPages: AppPages.routes, 19 getPages: AppPages.routes,
20 localizationsDelegates: AppLocalizations.localizationsDelegates, 20 localizationsDelegates: AppLocalizations.localizationsDelegates,
21 supportedLocales: AppLocalizations.supportedLocales, 21 supportedLocales: AppLocalizations.supportedLocales,
22 - localeResolutionCallback: (deviceLocale, supportedLocales) {  
23 - if (deviceLocale == null) return const Locale('en');  
24 -  
25 - // 1. 完全精确匹配 (Language + Country + Script)  
26 - for (final locale in supportedLocales) {  
27 - if (locale == deviceLocale) {  
28 - return locale;  
29 - }  
30 - }  
31 -  
32 - // 2. 繁简中文专门推导 (区分 zh_Hans 与 zh_Hant)  
33 - if (deviceLocale.languageCode == 'zh') {  
34 - final script = deviceLocale.scriptCode?.toLowerCase();  
35 - final country = deviceLocale.countryCode?.toUpperCase();  
36 - final isHant = script == 'hant' ||  
37 - country == 'TW' ||  
38 - country == 'HK' ||  
39 - country == 'MO';  
40 -  
41 - return supportedLocales.firstWhere(  
42 - (l) =>  
43 - l.languageCode == 'zh' &&  
44 - l.scriptCode == (isHant ? 'Hant' : 'Hans'),  
45 - orElse: () => Locale.fromSubtags(  
46 - languageCode: 'zh',  
47 - scriptCode: isHant ? 'Hant' : 'Hans',  
48 - ),  
49 - );  
50 - }  
51 -  
52 - // 3. 国家/地区匹配 (如 pt_PT、pt_BR)  
53 - if (deviceLocale.countryCode != null) {  
54 - for (final locale in supportedLocales) {  
55 - if (locale.languageCode == deviceLocale.languageCode &&  
56 - locale.countryCode == deviceLocale.countryCode) {  
57 - return locale;  
58 - }  
59 - }  
60 - }  
61 -  
62 - // 4. 仅语言代码匹配 (如 es_MX 匹配 es)  
63 - for (final locale in supportedLocales) {  
64 - if (locale.languageCode == deviceLocale.languageCode) {  
65 - return locale;  
66 - }  
67 - }  
68 -  
69 - // 5. 兜底语言返回英文  
70 - return const Locale('en');  
71 - }, 22 + localeResolutionCallback: resolveAppLocale,
72 ); 23 );
73 } 24 }
74 } 25 }
@@ -10,7 +10,10 @@ import 'package:get/get.dart'; @@ -10,7 +10,10 @@ import 'package:get/get.dart';
10 10
11 import '../../../../core/logging/app_logger.dart'; 11 import '../../../../core/logging/app_logger.dart';
12 import '../../../../core/services/raw_data_service/health_raw_data_core_service.dart'; 12 import '../../../../core/services/raw_data_service/health_raw_data_core_service.dart';
  13 +import '../../../../core/services/raw_data_service/health_raw_models.dart';
  14 +import '../../../../core/services/raw_data_service/platform_ios/apple_health_raw_local_notification.dart';
13 import '../../../../data/local/user_preferences_storage.dart'; 15 import '../../../../data/local/user_preferences_storage.dart';
  16 +import '../../../../l10n/l10n_extensions.dart';
14 import '../../../../pigeon/platform_api.g.dart'; 17 import '../../../../pigeon/platform_api.g.dart';
15 import '../../../routes/app_pages.dart'; 18 import '../../../routes/app_pages.dart';
16 19
@@ -33,6 +36,7 @@ enum DeveloperOptionsAction { @@ -33,6 +36,7 @@ enum DeveloperOptionsAction {
33 shareFlutterObserverRecord, 36 shareFlutterObserverRecord,
34 shareUploadTaskRecord, 37 shareUploadTaskRecord,
35 shareLocalNotificationDebugRecord, 38 shareLocalNotificationDebugRecord,
  39 + sendTestHrvLocalNotification,
36 clearHealthRawDataDatabaseAndUploadLog, 40 clearHealthRawDataDatabaseAndUploadLog,
37 globalEnv, 41 globalEnv,
38 } 42 }
@@ -86,6 +90,10 @@ class DeveloperOptionsController extends GetxController { @@ -86,6 +90,10 @@ class DeveloperOptionsController extends GetxController {
86 action: DeveloperOptionsAction.shareLocalNotificationDebugRecord, 90 action: DeveloperOptionsAction.shareLocalNotificationDebugRecord,
87 ), 91 ),
88 DeveloperOptionsItem( 92 DeveloperOptionsItem(
  93 + title: '测试本地推送 HRV',
  94 + action: DeveloperOptionsAction.sendTestHrvLocalNotification,
  95 + ),
  96 + DeveloperOptionsItem(
89 title: '清除本地数据库、上传日志', 97 title: '清除本地数据库、上传日志',
90 action: DeveloperOptionsAction.clearHealthRawDataDatabaseAndUploadLog, 98 action: DeveloperOptionsAction.clearHealthRawDataDatabaseAndUploadLog,
91 ), 99 ),
@@ -127,6 +135,9 @@ class DeveloperOptionsController extends GetxController { @@ -127,6 +135,9 @@ class DeveloperOptionsController extends GetxController {
127 case DeveloperOptionsAction.shareLocalNotificationDebugRecord: 135 case DeveloperOptionsAction.shareLocalNotificationDebugRecord:
128 await shareLocalNotificationDebugRecord(); 136 await shareLocalNotificationDebugRecord();
129 break; 137 break;
  138 + case DeveloperOptionsAction.sendTestHrvLocalNotification:
  139 + await sendTestHrvLocalNotification();
  140 + break;
130 case DeveloperOptionsAction.clearHealthRawDataDatabaseAndUploadLog: 141 case DeveloperOptionsAction.clearHealthRawDataDatabaseAndUploadLog:
131 await clearHealthRawDataDatabaseAndUploadLog(); 142 await clearHealthRawDataDatabaseAndUploadLog();
132 break; 143 break;
@@ -159,7 +170,9 @@ class DeveloperOptionsController extends GetxController { @@ -159,7 +170,9 @@ class DeveloperOptionsController extends GetxController {
159 }); 170 });
160 Get.offAllNamed(AppRoutes.login); 171 Get.offAllNamed(AppRoutes.login);
161 } 172 }
162 - } catch (e) {} 173 + } catch (error, stackTrace) {
  174 + AppLogger.e('Set global env failed', error, stackTrace);
  175 + }
163 } 176 }
164 break; 177 break;
165 case null: 178 case null:
@@ -244,6 +257,65 @@ class DeveloperOptionsController extends GetxController { @@ -244,6 +257,65 @@ class DeveloperOptionsController extends GetxController {
244 } 257 }
245 } 258 }
246 259
  260 + Future<void> sendTestHrvLocalNotification() async {
  261 + try {
  262 + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
  263 + final notifications = HealthRawLocalNotificationBuilder(l10n).build(
  264 + result: HealthRawStressCalculationResult(
  265 + userId: _userPreferencesStorage.preferences.value.meUserInfo?.id ?? 0,
  266 + hrvStressPoints: [
  267 + HealthRawHrvStressPoint(
  268 + userId:
  269 + _userPreferencesStorage.preferences.value.meUserInfo?.id ?? 0,
  270 + rawEndTime: now,
  271 + rawHrv: 35,
  272 + result: 32,
  273 + sourceStartTime: now,
  274 + sourceEndTime: now,
  275 + state: HealthRawStressState.excellent,
  276 + baselineHrv: 30,
  277 + baselineAwakeHrv: 30,
  278 + baselineSleepHrv: null,
  279 + baselineRestingHr: 60,
  280 + ),
  281 + ],
  282 + realtimeStressPoints: const [],
  283 + dailyStressPoints: const [],
  284 + sleepResults: const [],
  285 + ),
  286 + hasExistingHrv: true,
  287 + hasExistingSleep: false,
  288 + realtimeWindow: const [],
  289 + record: const HealthRawLocalNotificationRecord(),
  290 + );
  291 + final notification = notifications.firstWhereOrNull(
  292 + (item) => item.recordType == HealthRawLocalNotificationRecordType.hrv,
  293 + );
  294 + if (notification == null) {
  295 + Get.snackbar('Send failed', 'No HRV notification was built');
  296 + return;
  297 + }
  298 +
  299 + final userId =
  300 + _userPreferencesStorage.preferences.value.meUserInfo?.id ?? 0;
  301 + final sent = await HealthRawLocalNotificationDispatcher().sendOne(
  302 + userId: userId,
  303 + notification: notification,
  304 + );
  305 + Get.snackbar(
  306 + sent ? '已触发 HRV 本地推送' : 'HRV 本地推送发送失败',
  307 + '${notification.title}\n${notification.content}',
  308 + );
  309 + } catch (error, stackTrace) {
  310 + AppLogger.e(
  311 + 'Send test HRV local notification failed',
  312 + error,
  313 + stackTrace,
  314 + );
  315 + Get.snackbar('Send failed', error.toString());
  316 + }
  317 + }
  318 +
247 Future<void> clearHealthRawDataDatabaseAndUploadLog() async { 319 Future<void> clearHealthRawDataDatabaseAndUploadLog() async {
248 try { 320 try {
249 await _healthRawDataCoreService.clearLocalDatabaseAndUploadLog(); 321 await _healthRawDataCoreService.clearLocalDatabaseAndUploadLog();
@@ -27,9 +27,10 @@ class WatchFacePreview extends StatelessWidget { @@ -27,9 +27,10 @@ class WatchFacePreview extends StatelessWidget {
27 final Function()? switchFriend; 27 final Function()? switchFriend;
28 28
29 String _backgroundAsset(BuildContext context) { 29 String _backgroundAsset(BuildContext context) {
30 - final isChinese = Localizations.localeOf(context).languageCode == 'zh';  
31 - final directory =  
32 - isChinese ? 'assets/images/watch_theme/zh' : 'assets/images/watch_theme'; 30 + final isChinese = context.l10n.localeName.startsWith('zh');
  31 + final directory = isChinese
  32 + ? 'assets/images/watch_theme/zh'
  33 + : 'assets/images/watch_theme';
33 34
34 if (type == WatchFacePreviewType.surface) { 35 if (type == WatchFacePreviewType.surface) {
35 return '$directory/watch_theme_preview_surface.png'; 36 return '$directory/watch_theme_preview_surface.png';
  1 +import 'package:flutter/widgets.dart';
  2 +
  3 +Locale resolveAppLocale(
  4 + Locale? deviceLocale, Iterable<Locale> supportedLocales) {
  5 + if (deviceLocale == null) return const Locale('en');
  6 +
  7 + for (final locale in supportedLocales) {
  8 + if (locale == deviceLocale) {
  9 + return locale;
  10 + }
  11 + }
  12 +
  13 + if (deviceLocale.languageCode == 'zh') {
  14 + final script = deviceLocale.scriptCode?.toLowerCase();
  15 + final country = deviceLocale.countryCode?.toUpperCase();
  16 + final isHant = script == 'hant' ||
  17 + country == 'TW' ||
  18 + country == 'HK' ||
  19 + country == 'MO';
  20 +
  21 + return supportedLocales.firstWhere(
  22 + (locale) =>
  23 + locale.languageCode == 'zh' &&
  24 + locale.scriptCode == (isHant ? 'Hant' : 'Hans'),
  25 + orElse: () => Locale.fromSubtags(
  26 + languageCode: 'zh',
  27 + scriptCode: isHant ? 'Hant' : 'Hans',
  28 + ),
  29 + );
  30 + }
  31 +
  32 + if (deviceLocale.countryCode != null) {
  33 + for (final locale in supportedLocales) {
  34 + if (locale.languageCode == deviceLocale.languageCode &&
  35 + locale.countryCode == deviceLocale.countryCode) {
  36 + return locale;
  37 + }
  38 + }
  39 + }
  40 +
  41 + for (final locale in supportedLocales) {
  42 + if (locale.languageCode == deviceLocale.languageCode) {
  43 + return locale;
  44 + }
  45 + }
  46 +
  47 + return const Locale('en');
  48 +}
  1 +import 'dart:ui' show PlatformDispatcher;
  2 +
1 import 'package:flutter/widgets.dart'; 3 import 'package:flutter/widgets.dart';
2 import 'package:get/get.dart'; 4 import 'package:get/get.dart';
3 5
  6 +import 'app_locale_resolution.dart';
4 import 'gen/app_localizations.dart'; 7 import 'gen/app_localizations.dart';
5 -import 'gen/app_localizations_zh.dart';  
6 8
  9 +export 'app_locale_resolution.dart';
7 export 'gen/app_localizations.dart'; 10 export 'gen/app_localizations.dart';
8 11
9 /// 在 Widget build() 中通过 context 访问:context.l10n.appName 12 /// 在 Widget build() 中通过 context 访问:context.l10n.appName
10 extension AppLocalizationsX on BuildContext { 13 extension AppLocalizationsX on BuildContext {
11 AppLocalizations get l10n => 14 AppLocalizations get l10n =>
12 - AppLocalizations.of(this) ?? AppLocalizationsZh(); 15 + AppLocalizations.of(this) ?? currentAppLocalizations();
13 } 16 }
14 17
15 /// 在 Controller / 无 context 场景中全局访问:l10n.appName 18 /// 在 Controller / 无 context 场景中全局访问:l10n.appName
16 AppLocalizations get l10n { 19 AppLocalizations get l10n {
17 final context = _safeGetContext(); 20 final context = _safeGetContext();
18 final localizations = context != null ? AppLocalizations.of(context) : null; 21 final localizations = context != null ? AppLocalizations.of(context) : null;
19 - return localizations ?? AppLocalizationsZh(); 22 + return localizations ?? currentAppLocalizations();
  23 +}
  24 +
  25 +AppLocalizations currentAppLocalizations() {
  26 + final locale = resolveAppLocale(
  27 + Get.locale ?? PlatformDispatcher.instance.locale,
  28 + AppLocalizations.supportedLocales,
  29 + );
  30 + return lookupAppLocalizations(locale);
20 } 31 }
21 32
22 BuildContext? _safeGetContext() { 33 BuildContext? _safeGetContext() {
@@ -2,6 +2,7 @@ import 'dart:io'; @@ -2,6 +2,7 @@ import 'dart:io';
2 2
3 import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_models.dart'; 3 import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_models.dart';
4 import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ios/apple_health_raw_local_notification.dart'; 4 import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ios/apple_health_raw_local_notification.dart';
  5 +import 'package:doublefeel_flutter/l10n/gen/app_localizations_en.dart';
5 import 'package:doublefeel_flutter/l10n/gen/app_localizations_zh.dart'; 6 import 'package:doublefeel_flutter/l10n/gen/app_localizations_zh.dart';
6 import 'package:flutter_test/flutter_test.dart'; 7 import 'package:flutter_test/flutter_test.dart';
7 8
@@ -180,6 +181,33 @@ void main() { @@ -180,6 +181,33 @@ void main() {
180 HealthRawStressState.attention.value); 181 HealthRawStressState.attention.value);
181 }); 182 });
182 183
  184 + test('builds realtime stress notification with supplied localizations', () {
  185 + final englishBuilder =
  186 + HealthRawLocalNotificationBuilder(AppLocalizationsEn());
  187 + final base = _seconds(DateTime(2026, 1, 1, 9));
  188 + final notifications = englishBuilder.build(
  189 + result: HealthRawStressCalculationResult(
  190 + userId: 1,
  191 + hrvStressPoints: const [],
  192 + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
  193 + dailyStressPoints: const [],
  194 + ),
  195 + hasExistingHrv: false,
  196 + hasExistingSleep: false,
  197 + realtimeWindow: [
  198 + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
  199 + ],
  200 + record: const HealthRawLocalNotificationRecord(),
  201 + );
  202 +
  203 + expect(notifications, hasLength(1));
  204 + expect(notifications.single.title, 'Pay Attention · 08:45-09:45');
  205 + expect(
  206 + notifications.single.content,
  207 + 'Your stress was elevated over the past 60 minutes. Consider relaxing and making time for rest and recovery. Elevated stress during workouts is normal.',
  208 + );
  209 + });
  210 +
183 test('skips realtime stress notification during likely sleep', () { 211 test('skips realtime stress notification during likely sleep', () {
184 final base = _seconds(DateTime(2026, 1, 1, 9)); 212 final base = _seconds(DateTime(2026, 1, 1, 9));
185 final notifications = builder.build( 213 final notifications = builder.build(