Showing
6 changed files
with
168 additions
and
57 deletions
| @@ -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'; |
lib/l10n/app_locale_resolution.dart
0 → 100644
| 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( |
-
Please register or login to post a comment