Commit c159902ac985296db6b71351ea2cf6a59d8e1f96

Authored by 权海
1 parent 67367a11

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

... ... @@ -19,56 +19,7 @@ class DoubleFeelApp extends StatelessWidget {
getPages: AppPages.routes,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
localeResolutionCallback: (deviceLocale, supportedLocales) {
if (deviceLocale == null) return const Locale('en');
// 1. 完全精确匹配 (Language + Country + Script)
for (final locale in supportedLocales) {
if (locale == deviceLocale) {
return locale;
}
}
// 2. 繁简中文专门推导 (区分 zh_Hans 与 zh_Hant)
if (deviceLocale.languageCode == 'zh') {
final script = deviceLocale.scriptCode?.toLowerCase();
final country = deviceLocale.countryCode?.toUpperCase();
final isHant = script == 'hant' ||
country == 'TW' ||
country == 'HK' ||
country == 'MO';
return supportedLocales.firstWhere(
(l) =>
l.languageCode == 'zh' &&
l.scriptCode == (isHant ? 'Hant' : 'Hans'),
orElse: () => Locale.fromSubtags(
languageCode: 'zh',
scriptCode: isHant ? 'Hant' : 'Hans',
),
);
}
// 3. 国家/地区匹配 (如 pt_PT、pt_BR)
if (deviceLocale.countryCode != null) {
for (final locale in supportedLocales) {
if (locale.languageCode == deviceLocale.languageCode &&
locale.countryCode == deviceLocale.countryCode) {
return locale;
}
}
}
// 4. 仅语言代码匹配 (如 es_MX 匹配 es)
for (final locale in supportedLocales) {
if (locale.languageCode == deviceLocale.languageCode) {
return locale;
}
}
// 5. 兜底语言返回英文
return const Locale('en');
},
localeResolutionCallback: resolveAppLocale,
);
}
}
... ...
... ... @@ -10,7 +10,10 @@ import 'package:get/get.dart';
import '../../../../core/logging/app_logger.dart';
import '../../../../core/services/raw_data_service/health_raw_data_core_service.dart';
import '../../../../core/services/raw_data_service/health_raw_models.dart';
import '../../../../core/services/raw_data_service/platform_ios/apple_health_raw_local_notification.dart';
import '../../../../data/local/user_preferences_storage.dart';
import '../../../../l10n/l10n_extensions.dart';
import '../../../../pigeon/platform_api.g.dart';
import '../../../routes/app_pages.dart';
... ... @@ -33,6 +36,7 @@ enum DeveloperOptionsAction {
shareFlutterObserverRecord,
shareUploadTaskRecord,
shareLocalNotificationDebugRecord,
sendTestHrvLocalNotification,
clearHealthRawDataDatabaseAndUploadLog,
globalEnv,
}
... ... @@ -86,6 +90,10 @@ class DeveloperOptionsController extends GetxController {
action: DeveloperOptionsAction.shareLocalNotificationDebugRecord,
),
DeveloperOptionsItem(
title: '测试本地推送 HRV',
action: DeveloperOptionsAction.sendTestHrvLocalNotification,
),
DeveloperOptionsItem(
title: '清除本地数据库、上传日志',
action: DeveloperOptionsAction.clearHealthRawDataDatabaseAndUploadLog,
),
... ... @@ -127,6 +135,9 @@ class DeveloperOptionsController extends GetxController {
case DeveloperOptionsAction.shareLocalNotificationDebugRecord:
await shareLocalNotificationDebugRecord();
break;
case DeveloperOptionsAction.sendTestHrvLocalNotification:
await sendTestHrvLocalNotification();
break;
case DeveloperOptionsAction.clearHealthRawDataDatabaseAndUploadLog:
await clearHealthRawDataDatabaseAndUploadLog();
break;
... ... @@ -159,7 +170,9 @@ class DeveloperOptionsController extends GetxController {
});
Get.offAllNamed(AppRoutes.login);
}
} catch (e) {}
} catch (error, stackTrace) {
AppLogger.e('Set global env failed', error, stackTrace);
}
}
break;
case null:
... ... @@ -244,6 +257,65 @@ class DeveloperOptionsController extends GetxController {
}
}
Future<void> sendTestHrvLocalNotification() async {
try {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final notifications = HealthRawLocalNotificationBuilder(l10n).build(
result: HealthRawStressCalculationResult(
userId: _userPreferencesStorage.preferences.value.meUserInfo?.id ?? 0,
hrvStressPoints: [
HealthRawHrvStressPoint(
userId:
_userPreferencesStorage.preferences.value.meUserInfo?.id ?? 0,
rawEndTime: now,
rawHrv: 35,
result: 32,
sourceStartTime: now,
sourceEndTime: now,
state: HealthRawStressState.excellent,
baselineHrv: 30,
baselineAwakeHrv: 30,
baselineSleepHrv: null,
baselineRestingHr: 60,
),
],
realtimeStressPoints: const [],
dailyStressPoints: const [],
sleepResults: const [],
),
hasExistingHrv: true,
hasExistingSleep: false,
realtimeWindow: const [],
record: const HealthRawLocalNotificationRecord(),
);
final notification = notifications.firstWhereOrNull(
(item) => item.recordType == HealthRawLocalNotificationRecordType.hrv,
);
if (notification == null) {
Get.snackbar('Send failed', 'No HRV notification was built');
return;
}
final userId =
_userPreferencesStorage.preferences.value.meUserInfo?.id ?? 0;
final sent = await HealthRawLocalNotificationDispatcher().sendOne(
userId: userId,
notification: notification,
);
Get.snackbar(
sent ? '已触发 HRV 本地推送' : 'HRV 本地推送发送失败',
'${notification.title}\n${notification.content}',
);
} catch (error, stackTrace) {
AppLogger.e(
'Send test HRV local notification failed',
error,
stackTrace,
);
Get.snackbar('Send failed', error.toString());
}
}
Future<void> clearHealthRawDataDatabaseAndUploadLog() async {
try {
await _healthRawDataCoreService.clearLocalDatabaseAndUploadLog();
... ...
... ... @@ -27,9 +27,10 @@ class WatchFacePreview extends StatelessWidget {
final Function()? switchFriend;
String _backgroundAsset(BuildContext context) {
final isChinese = Localizations.localeOf(context).languageCode == 'zh';
final directory =
isChinese ? 'assets/images/watch_theme/zh' : 'assets/images/watch_theme';
final isChinese = context.l10n.localeName.startsWith('zh');
final directory = isChinese
? 'assets/images/watch_theme/zh'
: 'assets/images/watch_theme';
if (type == WatchFacePreviewType.surface) {
return '$directory/watch_theme_preview_surface.png';
... ...
import 'package:flutter/widgets.dart';
Locale resolveAppLocale(
Locale? deviceLocale, Iterable<Locale> supportedLocales) {
if (deviceLocale == null) return const Locale('en');
for (final locale in supportedLocales) {
if (locale == deviceLocale) {
return locale;
}
}
if (deviceLocale.languageCode == 'zh') {
final script = deviceLocale.scriptCode?.toLowerCase();
final country = deviceLocale.countryCode?.toUpperCase();
final isHant = script == 'hant' ||
country == 'TW' ||
country == 'HK' ||
country == 'MO';
return supportedLocales.firstWhere(
(locale) =>
locale.languageCode == 'zh' &&
locale.scriptCode == (isHant ? 'Hant' : 'Hans'),
orElse: () => Locale.fromSubtags(
languageCode: 'zh',
scriptCode: isHant ? 'Hant' : 'Hans',
),
);
}
if (deviceLocale.countryCode != null) {
for (final locale in supportedLocales) {
if (locale.languageCode == deviceLocale.languageCode &&
locale.countryCode == deviceLocale.countryCode) {
return locale;
}
}
}
for (final locale in supportedLocales) {
if (locale.languageCode == deviceLocale.languageCode) {
return locale;
}
}
return const Locale('en');
}
... ...
import 'dart:ui' show PlatformDispatcher;
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import 'app_locale_resolution.dart';
import 'gen/app_localizations.dart';
import 'gen/app_localizations_zh.dart';
export 'app_locale_resolution.dart';
export 'gen/app_localizations.dart';
/// 在 Widget build() 中通过 context 访问:context.l10n.appName
extension AppLocalizationsX on BuildContext {
AppLocalizations get l10n =>
AppLocalizations.of(this) ?? AppLocalizationsZh();
AppLocalizations.of(this) ?? currentAppLocalizations();
}
/// 在 Controller / 无 context 场景中全局访问:l10n.appName
AppLocalizations get l10n {
final context = _safeGetContext();
final localizations = context != null ? AppLocalizations.of(context) : null;
return localizations ?? AppLocalizationsZh();
return localizations ?? currentAppLocalizations();
}
AppLocalizations currentAppLocalizations() {
final locale = resolveAppLocale(
Get.locale ?? PlatformDispatcher.instance.locale,
AppLocalizations.supportedLocales,
);
return lookupAppLocalizations(locale);
}
BuildContext? _safeGetContext() {
... ...
... ... @@ -2,6 +2,7 @@ import 'dart:io';
import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_models.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ios/apple_health_raw_local_notification.dart';
import 'package:doublefeel_flutter/l10n/gen/app_localizations_en.dart';
import 'package:doublefeel_flutter/l10n/gen/app_localizations_zh.dart';
import 'package:flutter_test/flutter_test.dart';
... ... @@ -180,6 +181,33 @@ void main() {
HealthRawStressState.attention.value);
});
test('builds realtime stress notification with supplied localizations', () {
final englishBuilder =
HealthRawLocalNotificationBuilder(AppLocalizationsEn());
final base = _seconds(DateTime(2026, 1, 1, 9));
final notifications = englishBuilder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: const [],
realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
dailyStressPoints: const [],
),
hasExistingHrv: false,
hasExistingSleep: false,
realtimeWindow: [
for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
],
record: const HealthRawLocalNotificationRecord(),
);
expect(notifications, hasLength(1));
expect(notifications.single.title, 'Pay Attention · 08:45-09:45');
expect(
notifications.single.content,
'Your stress was elevated over the past 60 minutes. Consider relaxing and making time for rest and recovery. Elevated stress during workouts is normal.',
);
});
test('skips realtime stress notification during likely sleep', () {
final base = _seconds(DateTime(2026, 1, 1, 9));
final notifications = builder.build(
... ...