Commit 2bf8d2c5795d801125e01f1d96f4ffd67b716efe

Authored by 常守达
1 parent 490cf3cd

feat(today): 资源图替换

Showing 75 changed files with 2346 additions and 924 deletions

Too many changes to show.

To preserve performance only 75 of 75+ files are displayed.

7.59 KB | W: | H:

5.1 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin
This diff could not be displayed because it is too large.
... ... @@ -83,7 +83,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
image_cropper: c4326ea50132b1e1564499e5d32a84f01fb03537
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
... ...
import 'package:doublefeel_flutter/app/models/dialog_meta_data.dart';
import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/guide_common_scaffold.dart';
import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/onboarding_common_widgets.dart';
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/app/utils/dialog_utils.dart';
import 'package:doublefeel_flutter/core/error/app_error.dart';
import 'package:doublefeel_flutter/core/error/http_error_handling_policy.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:lottie/lottie.dart';
import 'package:share_plus/share_plus.dart';
class BindPartnerController extends GetxController {
... ... @@ -23,9 +29,21 @@ class BindPartnerController extends GetxController {
final RxString myInviteCode = ''.obs;
late bool isShowAppBarBack;
late bool isShowSkipButton;
@override
void onInit() {
super.onInit();
final from = Get.arguments?['from'] ?? '';
if (from == 'onboarding') {
isShowAppBarBack = false;
isShowSkipButton = true;
} else {
isShowAppBarBack = true;
isShowSkipButton = false;
}
partnerIdController.addListener(() {
partnerIdInput.value = partnerIdController.text.trim();
});
... ... @@ -54,11 +72,84 @@ class BindPartnerController extends GetxController {
if (!canSubmit || isSubmitting.value) return;
isSubmitting.value = true;
try {
final result = await _friendApi.addFriend(partnerIdInput.value);
if (result is AppFailure) {
return;
final result = await _friendApi.addFriendHandleError(
partnerIdInput.value,
HttpErrorHandlingPolicy(
excludeErrorCodeList: {-1011201, -1011202, -1011203}));
switch (result) {
case AppFailure(error: final err):
if (err is AppHttpError) {
switch (err.businessCode) {
case -1011201:
DialogUtils.showCommonDialog(DialogMetaData(
title: '该ID不存在',
message: '这个ID不存在哦,请检查后重新输入',
confirmText: '我知道了',
iconAsset: null,
));
break;
case -1011202:
DialogUtils.showCommonDialog(DialogMetaData(
title: '添加失败',
message: '该用户不允许被添加好友,无法添加Ta哦',
confirmText: '我知道了',
iconAsset: null,
));
break;
case -1011203:
DialogUtils.showCommonDialog(DialogMetaData(
title: '对方已经是你的好友啦',
message: '请不要重复添加哦',
confirmText: '我知道了',
iconAsset: null,
));
break;
}
}
break;
case AppSuccess(data: final friendItem):
if (isShowAppBarBack) {
Get.back();
} else {
Get.to(_BindSuccessPage(friendItem));
}
break;
}
Get.to(_BindSuccessPage());
// if (result is AppFailure) {
// if (result.error is AppHttpError) {
// var error = result.error as AppHttpError;
// switch (error.businessCode) {
// case -1011201:
// DialogUtils.showCommonDialog(DialogMetaData(
// title: '该ID不存在',
// message: '这个ID不存在哦,请检查后重新输入',
// confirmText: '我知道了',
// iconAsset: null,
// ));
// break;
// case -1011202:
// DialogUtils.showCommonDialog(DialogMetaData(
// title: '添加失败',
// message: '该用户不允许被添加好友,无法添加Ta哦',
// confirmText: '我知道了',
// iconAsset: null,
// ));
// break;
// case -1011203:
// DialogUtils.showCommonDialog(DialogMetaData(
// title: '对方已经是你的好友啦',
// message: '请不要重复添加哦',
// confirmText: '我知道了',
// iconAsset: null,
// ));
// break;
// }
// }
// return;
// }
// if (result is AppSuccess) {}
} catch (e) {
} finally {
isSubmitting.value = false;
... ... @@ -68,18 +159,27 @@ class BindPartnerController extends GetxController {
void onSkip() {
final userStateService = Get.find<UserStateService>();
if (!userStateService.isVip) {
Get.toNamed(Routes.MEMBERSHIP_OFFER);
Get.toNamed(
Routes.MEMBERSHIP_OFFER,
arguments: {'from': 'onboarding'},
);
} else {
Get.offAllNamed(AppRoutes.home);
}
// Get.to(_BindSuccessPage(FriendItem(
// avatar: 'https://cdn.couple360.net/couple360/themes/im_theme7.png')));
}
}
class _BindSuccessPage extends StatelessWidget {
const _BindSuccessPage();
const _BindSuccessPage(this.friendItem);
final FriendItem friendItem;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final userPrefs = Get.find<UserPreferencesStorage>();
final preferences = userPrefs.preferences.value;
final me = preferences.meUserInfo;
return GuideCommonScaffold(
onBackPressed: () => Get.back(),
bottom: OnboardingBottomButton(
... ... @@ -88,7 +188,10 @@ class _BindSuccessPage extends StatelessWidget {
onPressed: () {
final userStateService = Get.find<UserStateService>();
if (!userStateService.isVip) {
Get.toNamed(Routes.MEMBERSHIP_OFFER);
Get.toNamed(
Routes.MEMBERSHIP_OFFER,
arguments: {'from': 'onboarding'},
);
} else {
Get.offAllNamed(AppRoutes.home);
}
... ... @@ -97,33 +200,82 @@ class _BindSuccessPage extends StatelessWidget {
child: OnboardingPageScrollBody(
horizontalPadding: 0,
topPadding: 0,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
child: Stack(
children: [
Container(
height: 509,
decoration:
BoxDecoration(color: const Color(0xFFFF0000).withAlpha(120)),
),
const SizedBox(height: 16),
OnboardingTitleText(
context.l10n.healthCompanionIsNowAvailable,
maxWidth: 310,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
l10n.youCanNowViewEachOtherSHrvStressLevelsAndSleepPatternsAndReachOutToCheckInWhenTheOtherPersonSeemsTired,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black,
fontSize: 12,
fontWeight: FontWeight.w400,
Lottie.asset('assets/lottie/scatter_flowers.json'),
Column(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 509,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 160.0, end: 46.0),
duration: const Duration(milliseconds: 800),
curve: Curves.easeOut,
builder: (context, offset, _) {
final avatar = Container(
width: 100,
height: 100,
decoration: ShapeDecoration(
image: DecorationImage(
image: NetworkImage(me?.avatar ?? ''),
fit: BoxFit.cover,
),
shape: OvalBorder(
side: BorderSide(width: 1.79, color: Colors.white),
),
),
);
final friendAvatar = Container(
width: 100,
height: 100,
decoration: ShapeDecoration(
image: DecorationImage(
image: NetworkImage(friendItem.avatar ?? ''),
fit: BoxFit.cover,
),
shape: OvalBorder(
side: BorderSide(width: 1.79, color: Colors.white),
),
),
);
return Stack(
alignment: Alignment(0.5, 0.5),
children: [
Transform.translate(
offset: Offset(offset, 0),
child: friendAvatar,
),
Transform.translate(
offset: Offset(-offset, 0),
child: avatar,
),
],
);
},
),
),
const SizedBox(height: 16),
OnboardingTitleText(
context.l10n.healthCompanionIsNowAvailable,
maxWidth: 310,
),
),
)
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
l10n.youCanNowViewEachOtherSHrvStressLevelsAndSleepPatternsAndReachOutToCheckInWhenTheOtherPersonSeemsTired,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
)
],
),
],
),
),
... ...
import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/onboarding_common_widgets.dart';
import 'package:doublefeel_flutter/app/widget/dash_divider.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
... ... @@ -18,6 +17,21 @@ class BindPartnerView extends GetView<BindPartnerController> {
return Scaffold(
backgroundColor: Colors.white,
resizeToAvoidBottomInset: true,
extendBodyBehindAppBar: true,
appBar: AppBar(
automaticallyImplyLeading: false,
leading: controller.isShowAppBarBack
? IconButton(
highlightColor: Colors.transparent,
padding: EdgeInsets.zero,
onPressed: Get.back,
icon: Image.asset(
'assets/images/common/ic_nav_back.webp',
width: 24,
height: 24,
),
)
: SizedBox()),
body: PopScope(
canPop: false,
child: Stack(
... ... @@ -33,32 +47,28 @@ class BindPartnerView extends GetView<BindPartnerController> {
SizedBox(height: 28),
_buildCardArea(context),
SizedBox(height: 58),
_buildSkipButton(context),
if (controller.isShowSkipButton) _buildSkipButton(context),
],
),
),
Positioned(
top: MediaQuery.paddingOf(context).top + 92,
left: 0,
child: Opacity(
opacity: 0.40,
child: Container(
width: 133,
height: 124,
decoration: BoxDecoration(color: const Color(0xFFFF0000)),
),
child: Image.asset(
'assets/images/user_onboarding/ic_bind_decor_top.png',
width: 133,
height: 124,
fit: BoxFit.fitHeight,
),
),
Positioned(
top: MediaQuery.paddingOf(context).top + 532,
right: 0,
child: Opacity(
opacity: 0.40,
child: Container(
width: 133,
height: 124,
decoration: BoxDecoration(color: const Color(0xFFFF0000)),
),
child: Image.asset(
'assets/images/user_onboarding/ic_bind_decor_bottom.png',
width: 133,
height: 124,
fit: BoxFit.fitHeight,
),
),
],
... ... @@ -109,8 +119,12 @@ class BindPartnerView extends GetView<BindPartnerController> {
return Container(
margin: EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
image: DecorationImage(
image:
AssetImage('assets/images/friends/bg_friend_add_subtract.webp'),
fit: BoxFit.cover,
),
boxShadow: [
BoxShadow(
color: context.colors.primary.withValues(alpha: 0.08),
... ...
... ... @@ -3,6 +3,7 @@ import 'package:doublefeel_flutter/app/utils/dialog_utils.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
... ... @@ -21,6 +22,8 @@ class SubmitFeedbackController extends GetxController {
final ImagePicker _picker = ImagePicker();
final PlatformHostApi _platformHostApi = PlatformHostApi();
@override
void onInit() {
super.onInit();
... ... @@ -70,27 +73,48 @@ class SubmitFeedbackController extends GetxController {
AppToast.show('邮箱格式错误,请重新输入');
return;
}
final result = await _userApi.submitFeedback(
content: feedbackTextController.text.trim(),
email: contactTextController.text.trim(),
// images: selectedImages.map((e) => e.path).toList(),
images: selectedImages
.map(
(e) => 'https://cdn.couple360.net/couple360/themes/im_theme7.png')
.toList(),
Get.dialog(
const PopScope(
canPop: false,
child: Center(child: CircularProgressIndicator()),
),
barrierDismissible: false,
);
switch (result) {
case AppSuccess():
await DialogUtils.showCommonDialog(DialogMetaData(
iconAsset: null,
title: '反馈提交成功',
message: '谢谢您的反馈。如需进一步沟通,我们会尽快通过您留下的邮箱地址与您联系,请留意查收邮件。',
confirmText: '好的',
));
Get.back();
case AppFailure(:final error):
AppToast.show(error.displayMessage);
try {
final selectedPaths = <String>[];
for (var i = 0; i < selectedImages.length; i++) {
final image = selectedImages[i];
image.mimeType;
final ossPath = await _platformHostApi.uploadFile(
image.path,
_isVideo(image) ? HResourceType.video : HResourceType.image,
);
if (ossPath != null && ossPath.isNotEmpty) {
selectedPaths.add(ossPath);
}
}
final result = await _userApi.submitFeedback(
content: feedbackTextController.text.trim(),
email: contactTextController.text.trim(),
// images: selectedImages.map((e) => e.path).toList(),
images: selectedPaths,
);
Get.back();
switch (result) {
case AppSuccess():
await DialogUtils.showCommonDialog(DialogMetaData(
iconAsset: null,
title: '反馈提交成功',
message: '谢谢您的反馈。如需进一步沟通,我们会尽快通过您留下的邮箱地址与您联系,请留意查收邮件。',
confirmText: '好的',
));
Get.back();
case AppFailure(:final error):
AppToast.show(error.displayMessage);
}
} catch (_) {
Get.back();
}
}
... ... @@ -100,4 +124,20 @@ class SubmitFeedbackController extends GetxController {
contactTextController.dispose();
super.onClose();
}
bool _isVideo(XFile file) {
const videoExtensions = {
'mp4',
'mov',
'avi',
'mkv',
'wmv',
'flv',
'webm',
'm4v',
'3gp'
};
final ext = file.path.split('.').last.toLowerCase();
return videoExtensions.contains(ext);
}
}
... ...
import 'package:doublefeel_flutter/app/modules/home/controllers/today_controller.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/pay_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
... ... @@ -14,6 +15,7 @@ class FriendHomeBinding extends Bindings {
final args = Get.arguments as FriendItem;
Get.lazyPut<TodayController>(
() => TodayController(
Get.find<PayApi>(),
Get.find<VipApi>(),
Get.find<HealthApi>(),
Get.find<FriendApi>(),
... ...
... ... @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_t
import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/pay_api.dart';
import 'package:doublefeel_flutter/core/network/api/theme_api.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
... ... @@ -20,6 +21,7 @@ class HomeBinding extends Bindings {
Get.lazyPut<HomeController>(() => HomeController(), fenix: true);
Get.lazyPut<TodayController>(
() => TodayController(
Get.find<PayApi>(),
Get.find<VipApi>(),
Get.find<HealthApi>(),
Get.find<FriendApi>(),
... ...
... ... @@ -9,6 +9,7 @@ import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:get/get.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:image_picker/image_picker.dart';
... ... @@ -22,6 +23,7 @@ class MyController extends GetxController {
final ImagePicker _picker = ImagePicker();
final watchThemeItems = <WatchThemeItem>[].obs;
final isLoadingWatchThemes = false.obs;
final PlatformHostApi _platformHostApi = PlatformHostApi();
List<WatchThemeItem> get officialWatchThemes =>
watchThemeItems.where((theme) => theme.isOfficialTheme).toList();
... ... @@ -77,9 +79,11 @@ class MyController extends GetxController {
);
if (cropped == null) return; // 用户取消裁剪
// 目前用占位 URL,替换为真实上传逻辑
const obsUrl = 'https://cdn.couple360.net/couple360/themes/im_theme7.png';
final updateResult = await _userApi.updateOwnerUserInfo(avatar: obsUrl);
final ossPath = await _platformHostApi.uploadFile(
cropped.path,
HResourceType.image,
);
final updateResult = await _userApi.updateOwnerUserInfo(avatar: ossPath);
if (updateResult is AppSuccess<UserInfoResponse>) {
await Get.find<UserPreferencesStorage>()
.updateMeUserInfo(updateResult.data);
... ...
... ... @@ -11,16 +11,21 @@ import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/pay_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/data/local/user_account_storage.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
import 'package:doublefeel_flutter/data/models/pay/pay_models.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:doublefeel_flutter/pigeon/wear_engine_api.g.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
... ... @@ -48,8 +53,9 @@ class HrvAnnotation {
});
}
class TodayController extends GetxController {
class TodayController extends GetMaterialController {
TodayController(
this._payApi,
this._vipApi,
this._healthApi,
this._friendApi,
... ... @@ -59,6 +65,7 @@ class TodayController extends GetxController {
FriendItem? friendInfo, // null = 自己,非 null = 好友
}) : _initialFriendInfo = friendInfo;
final PayApi _payApi;
final VipApi _vipApi;
final HealthApi _healthApi;
final FriendApi _friendApi;
... ... @@ -84,7 +91,7 @@ class TodayController extends GetxController {
final scrollOffset = 0.0.obs;
final isLoadingToday = false.obs;
final showHealthDataAuthCardStatus = (-1).obs;
final showHealthDataAuthCardStatus = 1.obs;
final stressSubtitle = 'Hi, 你今日的综合压力状态'.obs;
// ── HRV 表盘引导 Banner ───────────────────
... ... @@ -93,7 +100,14 @@ class TodayController extends GetxController {
void dismissHrvAdBanner() => showHrvAdBanner.value = false;
void dismissPartnerAdBanner() => showPartnerAdBanner.value = false;
Future<void> dismissPartnerAdBanner() async {
final userId =
Get.find<UserPreferencesStorage>().preferences.value.meUserInfo?.id ??
0;
await Get.find<UserAccountStorage>()
.saveLastAddFriendBannerShowTime(userId);
showPartnerAdBanner.value = false;
}
// ── HRV 数字 + 心率 ───────────────────────
final avgHrv = '--'.obs;
... ... @@ -106,8 +120,6 @@ class TodayController extends GetxController {
final activityMoveProgress = 0.0.obs;
final activityExerciseProgress = 0.0.obs;
final activityStandProgress = 0.0.obs;
int? _activityMoveTarget;
int? _activityStandTarget;
// ── HRV 趋势图 ────────────────────────────
final hrvChartData = <V2HrvTrendItem>[].obs;
... ... @@ -138,32 +150,50 @@ class TodayController extends GetxController {
});
unawaited(loadDataForDate(today));
checkAddFriendVisible();
checkHrvAdBannerVisible();
checkHealthDataAuthCardVisible();
if (!Get.find<UserStateService>().isVip) {
getProductList();
}
}
void checkHrvAdBannerVisible() {
Future<void> checkHrvAdBannerVisible() async {
if (isFriend) {
showHrvAdBanner.value = false;
} else {
showHrvAdBanner.value = true;
try {
bool hasInstalledWatchSurface =
await WearEngineHostApi().hasInstalledWatchSurface();
showHrvAdBanner.value = !hasInstalledWatchSurface;
} on Exception catch (e) {}
}
}
void checkAddFriendVisible() {
if (isFriend) {
showHrvAdBanner.value = false;
showPartnerAdBanner.value = false;
} else {
_refreshFriendList(onFriendListUpdated: () {
if (friendsList.isNotEmpty || friendsList.value.length >= 10) {
_refreshFriendList(onFriendListUpdated: () {
if (friendsList.isNotEmpty ||
friendsList.value.length >= friendsListLimit) {
showPartnerAdBanner.value = false;
return;
}
// 关闭后 3 天内不再显示
final userId =
Get.find<UserPreferencesStorage>().preferences.value.meUserInfo?.id ??
0;
final lastShowTime =
Get.find<UserAccountStorage>().getLastAddFriendBannerShowTime(userId);
if (lastShowTime != null) {
final diff = DateTime.now()
.difference(DateTime.fromMillisecondsSinceEpoch(lastShowTime));
if (diff.inDays < 3) {
showPartnerAdBanner.value = false;
} else {
showPartnerAdBanner.value = true;
return;
}
});
}
}
showPartnerAdBanner.value = true;
});
}
void changeDate(DateTime date, {DateTime? focused}) {
... ... @@ -251,8 +281,17 @@ class TodayController extends GetxController {
Future<void> checkHealthDataAuthCardVisible() async {
try {
final hasUploaded =
switch (await _healthApi.getHealthDataEverUploaded()) {
AppSuccess(:final data) => data,
AppFailure() => false,
};
final result = await _hostApi.checkHealthAppAuthorization();
if (!hasUploaded) {
showHealthDataAuthCardStatus.value = 0;
return;
}
showHealthDataAuthCardStatus.value = result.status;
// TODO:若用户从始至终没有任何数据,也显示该引导
if (result.status == 1) {
... ... @@ -276,13 +315,13 @@ class TodayController extends GetxController {
switch (await _healthApi.getV2StressScore(friendUserId, intDate)) {
case AppSuccess(:final data):
// v2StressScore.value = data;
v2StressScore.value = data;
var nextInt = Random().nextInt(4);
v2StressScore.value = V2StressScore(
state: nextInt,
comprehensiveScore: 100 - (nextInt * 25),
);
// var nextInt = Random().nextInt(4);
// v2StressScore.value = V2StressScore(
// state: nextInt,
// comprehensiveScore: 100 - (nextInt * 25),
// );
case AppFailure():
v2StressScore.value = null;
}
... ... @@ -296,66 +335,67 @@ class TodayController extends GetxController {
switch (await _healthApi.getV2HrvTrend(friendUserId, intDate)) {
case AppSuccess(:final data):
// hrvChartData.assignAll(data.list ?? []);
hrvChartData.assignAll([
// 每小时一条,00:00 ~ 15:00(CST 午夜 = 1782057600)
V2HrvTrendItem(time: 1782057600, trendHrv: 45, state: 3), // 00:00
V2HrvTrendItem(time: 1782061200, trendHrv: 42, state: 3), // 01:00
V2HrvTrendItem(time: 1782064800, trendHrv: 38, state: 3), // 02:00
V2HrvTrendItem(time: 1782068400, trendHrv: 35, state: 2), // 03:00
V2HrvTrendItem(time: 1782072000, trendHrv: 40, state: 3), // 04:00
V2HrvTrendItem(time: 1782075600, trendHrv: 55, state: 4), // 05:00
V2HrvTrendItem(time: 1782079200, trendHrv: 65, state: 4), // 06:00
V2HrvTrendItem(time: 1782082800, trendHrv: 72, state: 4), // 07:00
V2HrvTrendItem(time: 1782086400, trendHrv: 68, state: 4), // 08:00
V2HrvTrendItem(time: 1782090000, trendHrv: 58, state: 4), // 09:00
V2HrvTrendItem(time: 1782093600, trendHrv: 52, state: 3), // 10:00
V2HrvTrendItem(time: 1782097200, trendHrv: 48, state: 3), // 11:00
V2HrvTrendItem(time: 1782100800, trendHrv: 44, state: 3), // 12:00
V2HrvTrendItem(time: 1782104400, trendHrv: 38, state: 2), // 13:00
V2HrvTrendItem(time: 1782108000, trendHrv: 42, state: 3), // 14:00
V2HrvTrendItem(time: 1782111600, trendHrv: 45, state: 3), // 15:00
]);
hrvChartData.assignAll(data.list ?? []);
// hrvChartData.assignAll([
// // 每小时一条,00:00 ~ 15:00(CST 午夜 = 1782057600)
// V2HrvTrendItem(time: 1782057600, trendHrv: 45, state: 3), // 00:00
// V2HrvTrendItem(time: 1782061200, trendHrv: 42, state: 3), // 01:00
// V2HrvTrendItem(time: 1782064800, trendHrv: 38, state: 3), // 02:00
// V2HrvTrendItem(time: 1782068400, trendHrv: 35, state: 2), // 03:00
// V2HrvTrendItem(time: 1782072000, trendHrv: 40, state: 3), // 04:00
// V2HrvTrendItem(time: 1782075600, trendHrv: 55, state: 4), // 05:00
// V2HrvTrendItem(time: 1782079200, trendHrv: 65, state: 4), // 06:00
// V2HrvTrendItem(time: 1782082800, trendHrv: 72, state: 4), // 07:00
// V2HrvTrendItem(time: 1782086400, trendHrv: 68, state: 4), // 08:00
// V2HrvTrendItem(time: 1782090000, trendHrv: 58, state: 4), // 09:00
// V2HrvTrendItem(time: 1782093600, trendHrv: 52, state: 3), // 10:00
// V2HrvTrendItem(time: 1782097200, trendHrv: 48, state: 3), // 11:00
// V2HrvTrendItem(time: 1782100800, trendHrv: 44, state: 3), // 12:00
// V2HrvTrendItem(time: 1782104400, trendHrv: 38, state: 2), // 13:00
// V2HrvTrendItem(time: 1782108000, trendHrv: 42, state: 3), // 14:00
// V2HrvTrendItem(time: 1782111600, trendHrv: 45, state: 3), // 15:00
// ]);
case AppFailure():
hrvChartData.clear();
}
switch (await _healthApi.getV2RealtimeStress(friendUserId, intDate)) {
case AppSuccess(:final data):
// stressChartData.assignAll(data.list ?? []);
stressChartData.assignAll(
// 每 6 分钟一条,00:00 ~ 15:00(共 150 条)
// midnight CST = 1782057600,步长 360s
List.generate(150, (i) {
const midnight = 1782057600;
final ts = midnight + i * 360;
final minutesFromMidnight = i * 6;
final hour = minutesFromMidnight ~/ 60;
final minInHour = minutesFromMidnight % 60;
// 用正弦波模拟真实波动,早高峰(7-9点)压力最高
final base = hour < 6
? 25.0 // 深夜低压力
: hour < 9
? 55.0 + (hour - 6) * 10.0 // 早高峰爬升
: hour < 12
? 75.0 - (hour - 9) * 8.0 // 上午下降
: 50.0 - (hour - 12) * 3.0; // 下午缓降
// 叠加微小波动(用 i 模拟随机)
final jitter = (i % 7 - 3) * 2.0 + (minInHour % 3) * 1.5;
final value = (base + jitter).clamp(10.0, 100.0).round();
final int state;
if (value >= 75) {
state = 1;
} else if (value >= 55) {
state = 2;
} else if (value >= 35) {
state = 3;
} else {
state = 4;
}
return V2RealtimeStressItem(time: ts, value: value, state: state);
}),
);
stressChartData.assignAll(data.list ?? []);
// stressChartData.assignAll(
// // 每 6 分钟一条,00:00 ~ 15:00(共 150 条)
// // midnight CST = 1782057600,步长 360s
// List.generate(150, (i) {
// const midnight = 1782057600;
// final ts = midnight + i * 360;
// final minutesFromMidnight = i * 6;
// final hour = minutesFromMidnight ~/ 60;
// final minInHour = minutesFromMidnight % 60;
// // 用正弦波模拟真实波动,早高峰(7-9点)压力最高
// final base = hour < 6
// ? 25.0 // 深夜低压力
// : hour < 9
// ? 55.0 + (hour - 6) * 10.0 // 早高峰爬升
// : hour < 12
// ? 75.0 - (hour - 9) * 8.0 // 上午下降
// : 50.0 - (hour - 12) * 3.0; // 下午缓降
// // 叠加微小波动(用 i 模拟随机)
// final jitter = (i % 7 - 3) * 2.0 + (minInHour % 3) * 1.5;
// final value = (base + jitter).clamp(10.0, 100.0).round();
// final int state;
// if (value >= 75) {
// state = 1;
// } else if (value >= 55) {
// state = 2;
// } else if (value >= 35) {
// state = 3;
// } else {
// state = 4;
// }
// return V2RealtimeStressItem(time: ts, value: value, state: state);
// }),
// );
case AppFailure():
stressChartData.clear();
}
... ... @@ -459,6 +499,7 @@ class TodayController extends GetxController {
switch (res) {
case AppSuccess(:final data):
friendsList.assignAll(data.list);
friendsListLimit = data.limit ?? 10;
onFriendListUpdated?.call();
case AppFailure():
}
... ... @@ -467,6 +508,7 @@ class TodayController extends GetxController {
}
RxList<FriendItem> friendsList = RxList.empty();
int friendsListLimit = 10;
Future<void> toPremiumPage() async {
await Get.toNamed(Routes.PURCHASE);
... ... @@ -478,4 +520,88 @@ class TodayController extends GetxController {
} on Exception catch (e) {}
}
}
Future<void> toPremiumDiscoutPage() async {
await Get.toNamed(Routes.MEMBERSHIP_OFFER);
final vipResult = await _vipApi.getVipInfo();
if (vipResult case AppSuccess(data: final vip)) {
try {
final vipPrefs = UserPreferencesVipInfo.fromVipInfo(vip);
await Get.find<UserPreferencesStorage>().updateVipInfo(vipPrefs);
} on Exception catch (e) {}
}
}
final yearlyProduct = Rxn<PayProduct>();
final yearlyProductAppleInfo = Rxn<AppleProductInfo>();
final PlatformHostApi _platformHostApi = PlatformHostApi();
Future<void> getProductList() async {
final result = await _payApi.getProductList(10);
if (result is! AppSuccess<PayProductListResponse>) return;
final products = (result.data.productList ?? const <PayProduct>[])
.where((product) => _nonEmpty(product.appleId) != null)
.toList();
yearlyProduct.value = products
// .firstWhereOrNull((p) => p.appleId == 'com.doublefeel.yearly_v1');
.firstWhereOrNull((p) => p.appleId == 'com.doublefeel.yearly');
AppLogger.e(yearlyProduct);
if (yearlyProduct.value != null) {
final appleProductId = _nonEmpty(yearlyProduct.value!.appleId);
if (appleProductId == null) return;
yearlyProductAppleInfo.value = await _requestAppleProductInfo(
productId: appleProductId,
baseUnit: yearlyProduct.value!.content?.baseUnit() ?? 1);
}
}
Future<AppleProductInfo?> _requestAppleProductInfo({
required String productId,
required int baseUnit,
}) async {
try {
return await _platformHostApi.requestAppleProductInfo(
productId,
baseUnit,
);
} catch (_) {
return null;
}
}
String? _nonEmpty(String? value) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) return null;
return trimmed;
}
String originDisplayPrice() {
final appleInfo = yearlyProductAppleInfo.value;
if (appleInfo == null) return '';
final actual =
appleInfo.price > 0 ? appleInfo.price : appleInfo.originPrice;
final inflated = actual / 100.0 * 1.2;
// 保留两位小数,单位与 currencyCode 一致
return '${appleInfo.currencyCode}${inflated.toStringAsFixed(2)}';
}
String displayPrice() {
final appleInfo = yearlyProductAppleInfo.value;
if (appleInfo == null) return '';
final price = appleInfo.price;
if (price > 0) {
final priceDescription = appleInfo.priceDescription.trim();
if (priceDescription.isNotEmpty) {
return priceDescription;
}
}
return appleInfo.originPriceDescription;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
}
}
... ...
... ... @@ -51,7 +51,7 @@ class MyTab extends GetView<MyController> {
const SizedBox(height: 28),
vipInfo?.isVip == true
? _ProCard(vipInfo: vipInfo)
: const _UnlockPremiumCard(),
: _UnlockPremiumCard(controller),
const SizedBox(height: 12),
_WatchThemeCard(
themes: controller.officialWatchThemes,
... ... @@ -356,16 +356,18 @@ class _Avatar extends StatelessWidget {
}
class _UnlockPremiumCard extends StatelessWidget {
const _UnlockPremiumCard();
const _UnlockPremiumCard(this.controller);
final MyController controller;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Get.toNamed(Routes.PURCHASE),
onTap: () {
controller.toPremiumPage();
},
child: Container(
height: 128,
padding: const EdgeInsets.fromLTRB(20, 14, 20, 14),
padding: const EdgeInsets.fromLTRB(20, 14, 0, 0),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.centerLeft,
... ... @@ -377,48 +379,88 @@ class _UnlockPremiumCard extends StatelessWidget {
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
child: Row(
children: [
Container(
height: 32,
padding:
const EdgeInsets.symmetric(horizontal: 22, vertical: 8),
decoration: ShapeDecoration(
color: const Color(0xFFFFDF50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 4,
children: [
Image.asset('assets/images/common/ic_pro.png',
width: 18,
height: 18,
color: context.colors.textPrimary),
Text(
'立即解锁',
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFFFE8AE),
Color(0xFFFFFAED),
Color(0xFFFFFFFF)
],
).createShader(bounds),
child: const Text(
'解锁专业版',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
],
),
),
SizedBox(
height: 4,
),
Text(
'开启压力预警与健康陪伴之旅',
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xFFC6B2FF),
fontSize: 12,
fontFamily: 'PingFang SC',
fontWeight: FontWeight.w500,
),
),
Container(
height: 32,
padding: const EdgeInsets.symmetric(
horizontal: 22, vertical: 8),
margin: EdgeInsets.only(bottom: 20, top: 12),
decoration: ShapeDecoration(
color: const Color(0xFFFFDF50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 4,
children: [
Image.asset('assets/images/common/ic_pro.png',
width: 18,
height: 18,
color: context.colors.textPrimary),
Text(
'立即解锁',
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
),
],
),
),
Image.asset('assets/images/my/bg_my_unlock_vip.png')
],
),
));
... ... @@ -434,7 +476,7 @@ class _ProCard extends StatelessWidget {
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Get.toNamed(Routes.PURCHASE),
onTap: () => Get.toNamed(Routes.MEMBERSHIP_DETAIL),
child: Container(
height: 80,
padding: const EdgeInsets.fromLTRB(20, 14, 20, 14),
... ... @@ -458,7 +500,17 @@ class _ProCard extends StatelessWidget {
children: [
Row(
children: [
const Flexible(
Flexible(
child: ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFFFE8AE),
Color(0xFFFFFAED),
Color(0xFFFFFFFF),
],
).createShader(bounds),
child: Text(
'DoubleFeel Pro',
maxLines: 1,
... ... @@ -470,7 +522,7 @@ class _ProCard extends StatelessWidget {
height: 1.4,
),
),
),
)),
const SizedBox(width: 6),
Image.asset(
'assets/images/common/ic_pro_badge.webp',
... ...
... ... @@ -98,7 +98,7 @@ class TodayTabBody extends StatelessWidget {
),
onBackToToday: _backToToday,
onTapPremiumCard: () {
controller.toPremiumPage();
controller.toPremiumDiscoutPage();
},
),
_buildWeekCalendar(context),
... ... @@ -151,7 +151,7 @@ class TodayTabBody extends StatelessWidget {
child: Obx(() => Image.asset(
controller.v2StressScore.value?.stateBg() ??
'assets/images/today/bg_stress_state_0.png',
fit: BoxFit.fitHeight,
fit: BoxFit.fill,
)),
),
Obx(
... ... @@ -224,8 +224,10 @@ class TodayTabBody extends StatelessWidget {
return Get.find<UserStateService>().isVip
? const SizedBox.shrink()
: GestureDetector(
onTap: () => controller.toPremiumPage(),
child: const PremiumCard());
onTap: () => controller.toPremiumDiscoutPage(),
child: PremiumCard(
controller,
));
}),
Obx(() => controller.showHrvAdBanner.value
? TodayHrvAdBanner(controller: controller)
... ...
import 'package:doublefeel_flutter/app/modules/home/controllers/today_controller.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class PremiumCard extends StatelessWidget {
const PremiumCard({
const PremiumCard(
this.controller, {
super.key,
this.onClaimTap,
this.onAllPlansTap,
});
final VoidCallback? onClaimTap;
final VoidCallback? onAllPlansTap;
final TodayController controller;
static const _accent = Color(0xFFFF773C);
static const _proYellow = Color(0xFFFFDF51);
... ... @@ -44,7 +44,7 @@ class PremiumCard extends StatelessWidget {
top: 7,
right: 12,
child: Image.asset(
'assets/images/today/premium_gift_illustration.png',
'assets/images/today/ic_gift_box.png',
width: 90,
height: 98,
fit: BoxFit.contain,
... ... @@ -96,37 +96,42 @@ class PremiumCard extends StatelessWidget {
],
),
const Spacer(),
Row(
children: [
Text(
'现价',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: context.colors.textPrimary,
),
),
const SizedBox(width: 6),
Text(
'¥78.00/年',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: context.colors.textPrimary,
),
),
const SizedBox(width: 8),
Text(
'原价 ¥198/年',
style: TextStyle(
fontSize: 12,
color: context.colors.textTertiary,
decoration: TextDecoration.lineThrough,
decorationColor: context.colors.textTertiary,
),
),
],
),
Obx(() => controller.yearlyProductAppleInfo.value !=
null &&
controller.yearlyProduct.value != null
? Row(
children: [
Text(
'现价',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: context.colors.textPrimary,
),
),
const SizedBox(width: 6),
Text(
controller.displayPrice(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: context.colors.textPrimary,
),
),
const SizedBox(width: 8),
Text(
'原价${controller.originDisplayPrice()}',
style: TextStyle(
fontSize: 12,
color: context.colors.textTertiary,
decoration: TextDecoration.lineThrough,
decorationColor:
context.colors.textTertiary,
),
),
],
)
: SizedBox()),
const SizedBox(height: 12),
],
),
... ... @@ -145,7 +150,7 @@ class PremiumCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: onAllPlansTap,
onTap: () => controller.toPremiumPage(),
behavior: HitTestBehavior.opaque,
child: Text(
context.l10n.allPlans,
... ... @@ -157,7 +162,7 @@ class PremiumCard extends StatelessWidget {
),
),
GestureDetector(
onTap: onClaimTap,
onTap: () => controller.toPremiumDiscoutPage(),
child: Container(
height: 28,
padding: const EdgeInsets.only(left: 12, right: 8),
... ...
... ... @@ -61,7 +61,7 @@ class TodayPartnerAdBanner extends StatelessWidget {
return GestureDetector(
onTap: () async {
await Get.toNamed(Routes.ADD_FRIEND);
await Get.toNamed(AppRoutes.bindPartner);
controller.checkAddFriendVisible();
},
child: _TodayGuideBanner(
... ...
... ... @@ -288,7 +288,8 @@ class LoginController extends GetxController {
// 根据引导完成状态决定跳转目标
final userId = me.id ?? 0;
if (_userAccount.hasCompletedOnboarding(userId)) {
if (_userAccount.hasCompletedOnboarding(userId) ||
me.isPassNoviceGuide == 1) {
// 该账号已完成引导,直接进主页
Get.offAllNamed(AppRoutes.home);
} else {
... ...
... ... @@ -22,312 +22,292 @@ class PhoneLoginView extends GetView<LoginController> {
),
child: Scaffold(
resizeToAvoidBottomInset: false,
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, 0.25, 0.75, 1.0],
colors: [
Color(0xFFE6DBFF),
Color(0xFFEDE4FF),
Color(0xFFEEE6FF),
Color(0xFFF9F6FF),
],
),
),
child: Stack(
fit: StackFit.expand,
children: [
// 主体内容
SafeArea(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom + 24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
backgroundColor: Colors.white,
// body: DecoratedBox(
// // decoration: const BoxDecoration(
// // gradient: LinearGradient(
// // begin: Alignment.topCenter,
// // end: Alignment.bottomCenter,
// // // stops: [0.0, 0.25, 0.75, 1.0],
// // colors: [
// // Color(0xFFE6DBFF),
// // Color(0xFFEDE4FF),
// // Color(0xFFEEE6FF),
// // Color(0xFFF9F6FF),
// // ],
// // ),
// ),
body: Stack(
fit: StackFit.expand,
children: [
SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom + 24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Stack(
children: [
// 顶部栏:返回按钮 + 角色插图
Stack(
clipBehavior: Clip.none,
children: [
// 返回按钮
Align(
alignment: Alignment.centerLeft,
child: IconButton(
icon: Icon(
Icons.arrow_back_ios_new_rounded,
color: context.colors.textPrimary,
size: 20,
),
onPressed: Get.back,
),
),
// // 右上角角色插图
// Positioned(
// right: 16,
// top: -8,
// child: Image.asset(
// '',
// width: 113,
// height: 140,
// fit: BoxFit.contain,
// ),
// ),
],
Image.asset(
'assets/images/user_onboarding/bg_phone_login_top.png',
fit: BoxFit.fitWidth,
),
const SizedBox(height: 44),
// 标题区
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
Positioned(
bottom: 67,
left: 32,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.phoneLoginHello,
style: TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.w600,
color: context.colors.textPrimary,
height: 1.4,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
l10n.phoneLoginWelcome,
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
color: context.colors.textPrimary,
height: 1.4,
),
),
],
),
),
)
],
),
const SizedBox(height: 28),
const SizedBox(height: 11),
// 输入区
Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 手机号输入框
Obx(() {
final phoneNotEmpty =
controller.phoneInput.value.isNotEmpty;
return TextField(
controller: controller.phoneController,
keyboardType: TextInputType.phone,
maxLength: 13,
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'[0-9\s]')),
_PhoneTextInputFormatter(),
],
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w400,
),
decoration: InputDecoration(
hintText: l10n.phoneLoginPhoneHint,
hintStyle: TextStyle(
color: context.colors.textTertiary,
fontSize: 16,
),
counterText: '',
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide(
color: context.colors.primary,
width: 1,
),
),
suffixIcon: phoneNotEmpty
? GestureDetector(
onTap:
controller.phoneController.clear,
child: Icon(
Icons.cancel,
color: context.colors.textTertiary,
size: 18,
),
)
: null,
// 输入区
Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 手机号输入框
Obx(() {
final phoneNotEmpty =
controller.phoneInput.value.isNotEmpty;
return TextField(
controller: controller.phoneController,
keyboardType: TextInputType.phone,
maxLength: 13,
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'[0-9\s]')),
_PhoneTextInputFormatter(),
],
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w400,
),
decoration: InputDecoration(
hintText: l10n.phoneLoginPhoneHint,
hintStyle: TextStyle(
color: context.colors.textTertiary,
fontSize: 16,
),
counterText: '',
filled: true,
fillColor: Color(0xFFF3F3F3),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide(
color: context.colors.primary,
width: 1,
),
);
}),
),
suffixIcon: phoneNotEmpty
? GestureDetector(
onTap: controller.phoneController.clear,
child: Icon(
Icons.cancel,
color: context.colors.textTertiary,
size: 18,
),
)
: null,
),
);
}),
const SizedBox(height: 16),
const SizedBox(height: 16),
// 验证码输入框(内嵌发送按钮)
Obx(() {
final countdown =
controller.countdownSeconds.value;
final isSending = controller.isSendingCode.value;
final isCounting = countdown > 0;
final hasSent = controller.hasSentCode.value;
// 验证码输入框(内嵌发送按钮)
Obx(() {
final countdown = controller.countdownSeconds.value;
final isSending = controller.isSendingCode.value;
final isCounting = countdown > 0;
final hasSent = controller.hasSentCode.value;
String btnText = hasSent
? l10n.phoneLoginResend
: l10n.phoneLoginSendCode;
if (isSending) {
btnText = l10n.phoneLoginSending;
} else if (isCounting) {
btnText =
l10n.phoneLoginSentCountdown(countdown);
}
String btnText = hasSent
? l10n.phoneLoginResend
: l10n.phoneLoginSendCode;
if (isSending) {
btnText = l10n.phoneLoginSending;
} else if (isCounting) {
btnText = l10n.phoneLoginSentCountdown(countdown);
}
return TextField(
controller: controller.codeController,
keyboardType: TextInputType.text,
maxLength: 8,
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w400,
return TextField(
controller: controller.codeController,
keyboardType: TextInputType.text,
maxLength: 8,
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w400,
),
decoration: InputDecoration(
hintText: l10n.phoneLoginCodeHint,
hintStyle: TextStyle(
color: context.colors.textTertiary,
fontSize: 16,
),
counterText: '',
filled: true,
fillColor: Color(0xFFF3F3F3),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide(
color: context.colors.primary,
width: 1,
),
decoration: InputDecoration(
hintText: l10n.phoneLoginCodeHint,
hintStyle: TextStyle(
color: context.colors.textTertiary,
fontSize: 16,
),
counterText: '',
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(27),
borderSide: BorderSide(
color: context.colors.primary,
width: 1,
),
),
suffixIcon: Padding(
padding: const EdgeInsets.only(right: 12),
child: TextButton(
onPressed: controller.canRequestCode
? controller.requestVerifyCode
: null,
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
foregroundColor: context.colors.primary,
disabledForegroundColor:
context.colors.textTertiary,
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
child: Text(btnText),
),
suffixIcon: Padding(
padding: const EdgeInsets.only(right: 12),
child: TextButton(
onPressed: controller.canRequestCode
? controller.requestVerifyCode
: null,
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
foregroundColor: context.colors.primary,
disabledForegroundColor:
context.colors.textTertiary,
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
suffixIconConstraints:
const BoxConstraints(minWidth: 0),
child: Text(btnText),
),
);
}),
const SizedBox(height: 56),
// 提示文字
Text(
l10n.phoneLoginAutoRegisterHint,
style: TextStyle(
color: context.colors.textTertiary,
fontSize: 12,
fontWeight: FontWeight.w400,
),
textAlign: TextAlign.center,
suffixIconConstraints:
const BoxConstraints(minWidth: 0),
),
);
}),
const SizedBox(height: 16),
const SizedBox(height: 56),
// 立即登录按钮
Obx(() {
final isLoggingIn = controller.isLoggingIn.value;
return SizedBox(
height: 48,
child: ElevatedButton(
onPressed: controller.canLogin
? controller.login
: null,
style: ElevatedButton.styleFrom(
backgroundColor: context.colors.primary,
foregroundColor: Colors.white,
disabledBackgroundColor: context
.colors.primary
.withValues(alpha: 0.4),
disabledForegroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
child: isLoggingIn
? Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const _SpinningLoader(),
const SizedBox(width: 8),
Text(l10n.phoneLoginLoggingIn),
],
)
: Text(l10n.loginBtn),
),
);
}),
],
// 提示文字
Text(
l10n.phoneLoginAutoRegisterHint,
style: TextStyle(
color: context.colors.textTertiary,
fontSize: 12,
fontWeight: FontWeight.w400,
),
textAlign: TextAlign.center,
),
),
],
const SizedBox(height: 16),
// 立即登录按钮
Obx(() {
final isLoggingIn = controller.isLoggingIn.value;
return SizedBox(
height: 48,
child: ElevatedButton(
onPressed:
controller.canLogin ? controller.login : null,
style: ElevatedButton.styleFrom(
backgroundColor: context.colors.primary,
foregroundColor: Colors.white,
disabledBackgroundColor: context.colors.primary
.withValues(alpha: 0.4),
disabledForegroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
child: isLoggingIn
? Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const _SpinningLoader(),
const SizedBox(width: 8),
Text(l10n.phoneLoginLoggingIn),
],
)
: Text(l10n.loginBtn),
),
);
}),
],
),
),
),
],
),
],
),
),
Positioned(
top: MediaQuery.paddingOf(context).top,
left: 0,
child: IconButton(
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
padding: EdgeInsets.zero,
onPressed: Get.back,
icon: Image.asset(
'assets/images/common/ic_nav_back.webp',
width: 24,
height: 24,
)),
),
],
),
),
);
... ...
import 'package:doublefeel_flutter/app/modules/membership_offer/controllers/membership_detail_controller.dart';
import 'package:doublefeel_flutter/core/network/api/pay_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:get/get.dart';
import '../controllers/membership_offer_controller.dart';
class MembershipDetailBinding extends Bindings {
@override
void dependencies() {
Get.put(
MembershipDetailController(Get.find<VipApi>(), Get.find<PayApi>()),
);
}
}
... ...
import 'package:doublefeel_flutter/app/modules/membership_offer/controllers/membership_offer_controller.dart';
import 'package:doublefeel_flutter/core/network/api/pay_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:get/get.dart';
import '../controllers/membership_offer_controller.dart';
class MembershipOfferBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<MembershipOfferController>(
() => MembershipOfferController(),
Get.put(
MembershipOfferController(Get.find<VipApi>(), Get.find<PayApi>()),
);
}
}
... ...
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/core/network/api/pay_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
import 'package:doublefeel_flutter/data/models/pay/apple_pay_order_response.dart';
import 'package:doublefeel_flutter/data/models/pay/pay_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:get/get.dart';
import '../../../routes/app_pages.dart';
class MembershipDetailController extends GetxController {
MembershipDetailController(this._vipApi, this._payApi);
final VipApi _vipApi;
final PayApi _payApi;
final UserPreferencesStorage _userPreferences =
Get.find<UserPreferencesStorage>();
final PlatformHostApi _platformHostApi = PlatformHostApi();
final isRestoring = false.obs;
final isUnlocking = false.obs;
Future<void> unlock() async {
isUnlocking.value = true;
try {
final orderResult = await _payApi.createOrderByApple(123);
if (orderResult is! AppSuccess<ApplePayOrderResponse>) return;
final orderUuid = orderResult.data.orderInfo?.orderUuid?.trim();
if (orderUuid == null || orderUuid.isEmpty) {
AppToast.show(l10n.purchaseOrderInfoUnavailable);
return;
}
final paymentResult = await _performApplePayment(
productId: 'appleProductId',
orderUuid: orderUuid,
);
await _handleApplePaymentResult(paymentResult);
} finally {
isUnlocking.value = false;
}
}
Future<void> _handleApplePaymentResult(
AppleProductPaymentResult? result,
) async {
if (result?.success == true) {
await _completeSuccessfulPurchase();
return;
}
if (result?.success != false) return;
AppToast.show(_applePaymentFailureMessage(result!));
}
String _applePaymentFailureMessage(AppleProductPaymentResult result) {
final errorCode = result.errorCode;
if (errorCode != null) {
return _localizedApplePaymentErrorCode(errorCode);
}
final errorMessage = _nonEmpty(result.errorMessage);
if (errorMessage == null) return l10n.purchaseApplePaymentFailed;
return _localizedApplePaymentErrorMessage(errorMessage);
}
String _localizedApplePaymentErrorCode(int errorCode) {
return switch (errorCode) {
-1 => l10n.purchaseApplePaymentInvalidOrder,
-2 => l10n.purchaseApplePaymentProductNotFound,
-3 => l10n.purchaseApplePaymentCancelled,
-4 => l10n.purchaseApplePaymentVerificationFailed,
-5 => l10n.purchaseApplePaymentFailed,
_ => l10n.purchaseApplePaymentFailed,
};
}
String _localizedApplePaymentErrorMessage(String errorMessage) {
if (errorMessage == 'missingUUID') {
return l10n.purchaseApplePaymentInvalidOrder;
}
if (errorMessage == 'productNotFound') {
return l10n.purchaseApplePaymentProductNotFound;
}
if (errorMessage == 'userCancelled') {
return l10n.purchaseApplePaymentCancelled;
}
if (errorMessage == 'failedVerification') {
return l10n.purchaseApplePaymentVerificationFailed;
}
if (errorMessage == 'unknown') {
return l10n.purchaseApplePaymentFailed;
}
return errorMessage;
}
Future<void> _completeSuccessfulPurchase() async {
try {
await _refreshVipInfo();
} finally {
if (Get.arguments['from'] == 'onboarding') {
Get.offAllNamed(AppRoutes.home);
} else {
Get.offNamed(Routes.PREMIUM_ACTIVATED);
}
}
}
Future<void> _refreshVipInfo() async {
final result = await _vipApi.getVipInfo();
if (result case AppSuccess(data: final vipInfo)) {
await _userPreferences.updateVipInfo(
UserPreferencesVipInfo.fromVipInfo(vipInfo),
);
}
}
Future<AppleProductPaymentResult?> _performApplePayment({
required String productId,
required String orderUuid,
}) async {
try {
return await _platformHostApi.performApplePayment(productId, orderUuid);
} catch (_) {
return null;
}
}
@override
void onInit() {
super.onInit();
_refreshVipInfo();
}
String? _nonEmpty(String? value) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) return null;
return trimmed;
}
Future<void> restorePurchase() async {
if (isRestoring.value || isUnlocking.value) return;
isRestoring.value = true;
try {
final success = await _performRestore();
if (success) {
await _completeSuccessfulPurchase();
}
} finally {
isRestoring.value = false;
}
}
Future<bool> _performRestore() async {
try {
return await _platformHostApi.performRestore();
} catch (_) {
return false;
}
}
void openContactUs() {
Get.toNamed(Routes.SUBMIT_FEEDBACK);
}
Future<void> manageSubscription() async {
try {
await _platformHostApi.requestAppReview();
} catch (_) {}
}
}
... ...
import 'dart:math';
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/core/network/api/pay_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
import 'package:doublefeel_flutter/data/models/pay/apple_pay_order_response.dart';
import 'package:doublefeel_flutter/data/models/pay/pay_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:get/get.dart';
import '../../../routes/app_pages.dart';
class MembershipOfferController extends GetxController {
void finish() {
Get.offAllNamed(AppRoutes.home);
MembershipOfferController(this._vipApi, this._payApi);
final VipApi _vipApi;
final PayApi _payApi;
final UserPreferencesStorage _userPreferences =
Get.find<UserPreferencesStorage>();
final PlatformHostApi _platformHostApi = PlatformHostApi();
final isUnlocking = false.obs;
Future<void> unlock() async {
if (isUnlocking.value) return;
final productId = yearlyProduct.value?.id;
final appleProductId = yearlyProduct.value?.appleId;
if (productId == null || appleProductId == null || appleProductId.isEmpty) {
AppToast.show(l10n.purchaseProductInfoUnavailable);
return;
}
isUnlocking.value = true;
try {
final orderResult = await _payApi.createOrderByApple(productId);
if (orderResult is! AppSuccess<ApplePayOrderResponse>) {
if (isFromOnboard) {
Get.offAllNamed(AppRoutes.home);
}
return;
}
final orderUuid = orderResult.data.orderInfo?.orderUuid?.trim();
if (orderUuid == null || orderUuid.isEmpty) {
AppToast.show(l10n.purchaseOrderInfoUnavailable);
if (isFromOnboard) {
Get.offAllNamed(AppRoutes.home);
}
return;
}
final paymentResult = await _performApplePayment(
productId: appleProductId,
orderUuid: orderUuid,
);
await _handleApplePaymentResult(paymentResult);
} finally {
isUnlocking.value = false;
}
}
Future<void> _handleApplePaymentResult(
AppleProductPaymentResult? result,
) async {
if (result?.success == true) {
await _completeSuccessfulPurchase();
return;
}
if (result?.success != false) return;
AppToast.show(_applePaymentFailureMessage(result!));
if (isFromOnboard) {
Get.offAllNamed(AppRoutes.home);
}
}
String _applePaymentFailureMessage(AppleProductPaymentResult result) {
final errorCode = result.errorCode;
if (errorCode != null) {
return _localizedApplePaymentErrorCode(errorCode);
}
final errorMessage = _nonEmpty(result.errorMessage);
if (errorMessage == null) return l10n.purchaseApplePaymentFailed;
return _localizedApplePaymentErrorMessage(errorMessage);
}
String _localizedApplePaymentErrorCode(int errorCode) {
return switch (errorCode) {
-1 => l10n.purchaseApplePaymentInvalidOrder,
-2 => l10n.purchaseApplePaymentProductNotFound,
-3 => l10n.purchaseApplePaymentCancelled,
-4 => l10n.purchaseApplePaymentVerificationFailed,
-5 => l10n.purchaseApplePaymentFailed,
_ => l10n.purchaseApplePaymentFailed,
};
}
String _localizedApplePaymentErrorMessage(String errorMessage) {
if (errorMessage == 'missingUUID') {
return l10n.purchaseApplePaymentInvalidOrder;
}
if (errorMessage == 'productNotFound') {
return l10n.purchaseApplePaymentProductNotFound;
}
if (errorMessage == 'userCancelled') {
return l10n.purchaseApplePaymentCancelled;
}
if (errorMessage == 'failedVerification') {
return l10n.purchaseApplePaymentVerificationFailed;
}
if (errorMessage == 'unknown') {
return l10n.purchaseApplePaymentFailed;
}
return errorMessage;
}
Future<void> _completeSuccessfulPurchase() async {
try {
await _refreshVipInfo();
} finally {
if (isFromOnboard) {
Get.offAllNamed(AppRoutes.home);
} else {
Get.offNamed(Routes.PREMIUM_ACTIVATED);
}
}
}
Future<void> _refreshVipInfo() async {
final result = await _vipApi.getVipInfo();
if (result case AppSuccess(data: final vipInfo)) {
await _userPreferences.updateVipInfo(
UserPreferencesVipInfo.fromVipInfo(vipInfo),
);
}
}
Future<AppleProductPaymentResult?> _performApplePayment({
required String productId,
required String orderUuid,
}) async {
try {
return await _platformHostApi.performApplePayment(productId, orderUuid);
} catch (_) {
return null;
}
}
@override
void onInit() {
super.onInit();
isFromOnboard = Get.arguments?['from'] == 'onboarding';
// getProductList();
_refreshVipInfo();
getProductList();
}
var isFromOnboard = false;
final yearlyProduct = Rxn<PayProduct>();
final yearlyProductAppleInfo = Rxn<AppleProductInfo>();
Future<void> getProductList() async {
final result = await _payApi.getProductList(10);
if (result is! AppSuccess<PayProductListResponse>) return;
final products = (result.data.productList ?? const <PayProduct>[])
.where((product) => _nonEmpty(product.appleId) != null)
.toList();
yearlyProduct.value = products
// .firstWhereOrNull((p) => p.appleId == 'com.doublefeel.yearly_v1');
.firstWhereOrNull((p) => p.appleId == 'com.doublefeel.yearly');
if (yearlyProduct.value != null) {
final appleProductId = _nonEmpty(yearlyProduct.value!.appleId);
if (appleProductId == null) return;
yearlyProductAppleInfo.value = await _requestAppleProductInfo(
productId: appleProductId,
baseUnit: yearlyProduct.value!.content?.baseUnit() ?? 1);
}
}
Future<AppleProductInfo?> _requestAppleProductInfo({
required String productId,
required int baseUnit,
}) async {
try {
return await _platformHostApi.requestAppleProductInfo(
productId,
baseUnit,
);
} catch (_) {
return null;
}
}
String? _nonEmpty(String? value) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) return null;
return trimmed;
}
}
... ...
import 'package:doublefeel_flutter/app/modules/home/views/tabs/my_tab.dart';
import 'package:doublefeel_flutter/app/modules/membership_offer/controllers/membership_detail_controller.dart';
import 'package:doublefeel_flutter/app/modules/purchase/widgets/purchase_colors.dart';
import 'package:doublefeel_flutter/app/modules/purchase/widgets/refund_explanation_bottom_sheet.dart';
import 'package:doublefeel_flutter/app/widget/premium_benefit_list_view.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class MembershipDetailView extends GetView<MembershipDetailController> {
const MembershipDetailView({super.key});
@override
Widget build(BuildContext context) {
final userPrefs = Get.find<UserPreferencesStorage>();
return Scaffold(
appBar: AppBar(
leading: IconButton(
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
padding: EdgeInsets.zero,
onPressed: Get.back,
icon: Image.asset(
'assets/images/common/ic_nav_back.webp',
width: 24,
height: 24,
),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 20.0),
child: TextButton(
onPressed: controller.isRestoring.value
? null
: controller.restorePurchase,
style: TextButton.styleFrom(
foregroundColor: PurchaseColors.title,
padding: const EdgeInsets.symmetric(horizontal: 16),
textStyle: const TextStyle(fontSize: 14, height: 1.2),
),
child: controller.isRestoring.value
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(context.l10n.purchaseRestore),
),
),
],
),
extendBodyBehindAppBar: true,
body: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(
bottom: MediaQuery.paddingOf(context).bottom + 16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.asset(
'assets/images/today/bg_stress_state_4.png',
width: Get.width,
fit: BoxFit.cover,
),
Padding(
padding: const EdgeInsets.only(top: 31, left: 18),
child: Text(
'DoubleFeel Pro',
style: TextStyle(
color: Colors.black,
fontSize: 24,
fontWeight: FontWeight.w600,
),
),
),
Padding(
padding: const EdgeInsets.only(top: 8, left: 18),
child: Row(
spacing: 4,
children: [
Container(
width: 20,
height: 20,
clipBehavior: Clip.antiAlias,
padding: EdgeInsets.all(1),
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
color: context.colors.primary),
child: Image.asset('assets/images/common/ic_pro.png'),
),
Obx(() {
final preferences = userPrefs.preferences.value;
final vipInfo = preferences.vipInfo;
return Text(
_vipSubtitle(vipInfo),
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
);
})
],
),
),
Container(
color: Color(0xFFD9D9D9),
height: 1,
width: Get.width,
margin: EdgeInsets.only(
left: 16,
right: 16,
top: 28,
),
),
Padding(
padding: const EdgeInsets.only(
top: 28, left: 16, right: 16, bottom: 16),
child: Text(
'畅享全部高级权益',
style: TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
//todo
PremiumBenefitListView(),
Obx(() {
final userPrefs = Get.find<UserPreferencesStorage>();
final preferences = userPrefs.preferences.value;
final vipInfo = preferences.vipInfo;
return (vipInfo == null || vipInfo.vipType == 0)
? SizedBox()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
top: 28, left: 16, right: 16),
child: Text(
'会员管理',
style: TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
Container(
margin: EdgeInsets.only(left: 16, right: 16, top: 16),
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: ShapeDecoration(
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.all(Radius.circular(16)),
),
),
child: Column(
children: [
SizedBox(
height: 56,
child: Row(
children: [
Text(
'会员类型',
textAlign: TextAlign.center,
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
Spacer(),
Obx(() {
final preferences =
userPrefs.preferences.value;
final vipInfo = preferences.vipInfo;
return Text(
_vipType(vipInfo),
textAlign: TextAlign.center,
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w400,
),
);
})
],
),
),
SizedBox(
height: 56,
child: Row(
children: [
Text(
'有效期至',
textAlign: TextAlign.center,
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
Spacer(),
Obx(() {
final preferences =
userPrefs.preferences.value;
final vipInfo = preferences.vipInfo;
return Text(
_vipExprieDate(vipInfo),
textAlign: TextAlign.center,
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w400,
),
);
})
],
),
),
if (vipInfo?.vipType == 1 ||
vipInfo?.vipType == 2 ||
vipInfo?.vipType == 3 ||
vipInfo?.vipType == 4)
GestureDetector(
onTap: () {
controller.manageSubscription();
},
child: SizedBox(
height: 56,
child: Row(
children: [
Text(
'管理订阅',
textAlign: TextAlign.center,
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
Spacer(),
Image.asset(
'assets/images/common/ic_more_gray.png',
width: 16,
height: 16,
),
],
),
),
),
],
),
),
],
);
}),
SizedBox(
height: 28,
),
_LegalNotes(controller: controller)
],
),
),
);
}
String _vipSubtitle(UserPreferencesVipInfo? vipInfo) {
final endDate = vipInfo?.vipEndDate ?? 0;
if (vipInfo?.isVip != true || endDate <= 0) {
return '立即解锁';
}
if (vipInfo?.isForeverVip ?? false) {
return '终身使用,免费更新';
}
final milliseconds = endDate > 1000000000000 ? endDate : endDate * 1000;
final date = DateTime.fromMillisecondsSinceEpoch(milliseconds);
final month = date.month.toString().padLeft(2, '0');
final day = date.day.toString().padLeft(2, '0');
return '有效期至 ${date.year}-$month-$day';
}
String _vipExprieDate(UserPreferencesVipInfo? vipInfo) {
final endDate = vipInfo?.vipEndDate ?? 0;
if (vipInfo?.isVip != true || endDate <= 0) {
return '立即解锁';
}
if (vipInfo?.isForeverVip ?? false) {
return '永不过期';
}
final milliseconds = endDate > 1000000000000 ? endDate : endDate * 1000;
final date = DateTime.fromMillisecondsSinceEpoch(milliseconds);
final month = date.month.toString().padLeft(2, '0');
final day = date.day.toString().padLeft(2, '0');
return '${date.year}-$month-$day';
}
String _vipType(UserPreferencesVipInfo? vipInfo) {
if (vipInfo == null || vipInfo.isVip != true || vipInfo.vipEndDate <= 0) {
return '立即解锁';
}
if (vipInfo.isForeverVip) {
return '终身会员';
}
switch (vipInfo.vipType) {
case 1:
return '月度会员';
case 2:
return '季度会员';
case 3:
return '年度会员';
case 10:
return '终身会员';
default:
return '';
}
}
}
class _LegalNotes extends StatelessWidget {
const _LegalNotes({required this.controller});
final MembershipDetailController controller;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.purchaseNotesTitle,
style: const TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
const SizedBox(height: 24),
_LegalText(
prefix: '1. ',
text: context.l10n.purchaseNoteSubscription,
linkText: '了解更多',
linkSuffix: '。',
onLinkTap: showRefundExplanationBottomSheet,
),
const SizedBox(height: 12),
_LegalText(
prefix: '2. ',
text: context.l10n.purchaseNoteRestore,
linkText: context.l10n.purchaseRestore,
linkSuffix: '。',
onLinkTap: controller.restorePurchase,
),
const SizedBox(height: 12),
_LegalText(
prefix: '3. ',
text: context.l10n.purchaseNoteContact,
linkText: '联系我们',
linkSuffix: '。',
onLinkTap: controller.openContactUs,
),
],
),
);
}
}
class _LegalText extends StatelessWidget {
const _LegalText({
required this.prefix,
required this.text,
this.linkText,
this.linkSuffix,
this.onLinkTap,
});
final String prefix;
final String text;
final String? linkText;
final String? linkSuffix;
final VoidCallback? onLinkTap;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
prefix,
style: const TextStyle(
color: PurchaseColors.subtitle,
fontSize: 12,
height: 1.4,
),
),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(text: text),
if (linkText case final linkText?)
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onLinkTap,
child: Text(
linkText,
style: const TextStyle(
color: Color(0xFF845EEE),
fontSize: 12,
height: 1.4,
fontWeight: FontWeight.w500,
decoration: TextDecoration.underline,
decorationColor: Color(0xFF845EEE),
),
),
),
),
if (linkSuffix case final linkSuffix?)
TextSpan(text: linkSuffix),
],
),
style: const TextStyle(
color: PurchaseColors.subtitle,
fontSize: 12,
height: 1.4,
),
),
),
],
);
}
}
... ...
import 'package:doublefeel_flutter/app/modules/membership_offer/controllers/membership_offer_controller.dart';
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/pay/pay_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:lottie/lottie.dart';
import '../../user_onboarding/widget/guide_common_scaffold.dart';
import '../../user_onboarding/widget/onboarding_common_widgets.dart';
import '../../user_onboarding/widget/onboarding_illustrations.dart';
import '../controllers/membership_offer_controller.dart';
class MembershipOfferView extends GetView<MembershipOfferController> {
const MembershipOfferView({super.key});
... ... @@ -14,12 +19,14 @@ class MembershipOfferView extends GetView<MembershipOfferController> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return GuideCommonScaffold(
showGradientBg: true,
bottom: OnboardingBottomButton(
label: l10n.continueButton,
label: controller.isFromOnboard ? l10n.continueButton : '免费兑换优惠',
enabled: true,
onPressed: controller.finish,
onPressed: () {
controller.unlock();
},
),
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
... ... @@ -29,71 +36,129 @@ class MembershipOfferView extends GetView<MembershipOfferController> {
0,
140,
),
child: Column(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
OnboardingTitleText(l10n.onboardingMemberTitle),
const SizedBox(height: 12),
OnboardingBodyText(
l10n.onboardingMemberBody,
fontSize: 14,
Lottie.asset('assets/lottie/scatter_flowers.json'),
Column(
mainAxisSize: MainAxisSize.max,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
SizedBox(height: 48),
OnboardingTitleText(l10n.onboardingMemberTitle),
const SizedBox(height: 12),
OnboardingBodyText(
l10n.onboardingMemberBody,
fontSize: 14,
),
],
),
],
),
),
const SizedBox(height: 12),
const GiftIllustration(),
const SizedBox(height: 4),
Text(
l10n.onboardingMemberOriginalPrice,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFFB0B0B6),
fontSize: 16,
decoration: TextDecoration.lineThrough,
decorationColor: Color(0xFFB0B0B6),
letterSpacing: 0,
),
),
const SizedBox(height: 6),
Text(
l10n.onboardingMemberCurrentPrice,
textAlign: TextAlign.center,
style: TextStyle(
color: context.colors.primary,
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: 0,
),
),
const SizedBox(height: 6),
Text(
l10n.onboardingMemberMonthlyPrice,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFFB0B0B6),
fontSize: 12,
letterSpacing: 0,
),
),
const SizedBox(height: 70),
Text(
l10n.onboardingMemberAllOptions,
textAlign: TextAlign.center,
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 12,
decoration: TextDecoration.underline,
decorationColor: context.colors.textPrimary,
letterSpacing: 0,
),
),
const SizedBox(height: 12),
Image.asset(
'assets/images/user_onboarding/ic_membership_box.png'),
const SizedBox(height: 4),
Obx(() {
final yearInfo = controller.yearlyProductAppleInfo.value;
final yearProduct = controller.yearlyProduct.value;
return yearInfo == null || yearProduct == null
? const SizedBox(child: CircularProgressIndicator())
: Column(
children: [
Text(
'原价${_originDisplayPrice(yearInfo)}/年',
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFFB0B0B6),
fontSize: 16,
decoration: TextDecoration.lineThrough,
decorationColor: Color(0xFFB0B0B6),
letterSpacing: 0,
),
),
const SizedBox(height: 6),
Text(
_displayPrice(yearInfo),
textAlign: TextAlign.center,
style: TextStyle(
color: context.colors.primary,
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: 0,
),
),
const SizedBox(height: 6),
Text(
_planSubtitle(yearProduct, yearInfo),
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFFB0B0B6),
fontSize: 12,
letterSpacing: 0,
),
),
],
);
}),
const SizedBox(height: 70),
GestureDetector(
onTap: () {
Get.offNamed(Routes.PURCHASE);
},
child: Text(
l10n.onboardingMemberAllOptions,
textAlign: TextAlign.center,
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 12,
decoration: TextDecoration.underline,
decorationColor: context.colors.textPrimary,
letterSpacing: 0,
),
),
),
],
),
],
),
),
);
}
String _originDisplayPrice(AppleProductInfo appleInfo) {
final actual =
appleInfo.price > 0 ? appleInfo.price : appleInfo.originPrice;
final inflated = actual / 100.0 * 1.2;
// 保留两位小数,单位与 currencyCode 一致
return '${appleInfo.currencyCode}${inflated.toStringAsFixed(2)}';
}
String _displayPrice(AppleProductInfo appleInfo) {
final price = appleInfo.price;
if (price > 0) {
final priceDescription = appleInfo.priceDescription.trim();
if (priceDescription.isNotEmpty) {
return priceDescription;
}
}
return appleInfo.originPriceDescription;
}
String? _nonEmpty(String? value) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) return null;
return trimmed;
}
String _planSubtitle(PayProduct product, AppleProductInfo? appleInfo) {
if (product.content?.isYearProduct() != true) {
return _nonEmpty(product.content?.description) ?? '';
}
final unitPrice = _nonEmpty(appleInfo?.unitPrice);
return unitPrice == null ? '' : l10n.purchaseMonthlyUnitPrice(unitPrice);
}
}
... ...
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:get/get.dart';
import '../controllers/user_onboarding_controller.dart';
... ... @@ -6,7 +7,7 @@ class UserOnboardingBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<UserOnboardingController>(
() => UserOnboardingController(),
() => UserOnboardingController(Get.find<UserApi>()),
);
}
}
... ...
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/local/user_account_storage.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
... ... @@ -16,6 +17,9 @@ class UserOnboardingController extends GetxController {
final selections = <int, Set<int>>{}.obs;
final selectionVersion = 0.obs;
UserOnboardingController(this._userApi);
final UserApi _userApi;
bool hasSelection(int pageIndex) =>
selections[pageIndex]?.isNotEmpty ?? false;
... ... @@ -106,9 +110,72 @@ class UserOnboardingController extends GetxController {
finishOnboarding();
return;
}
try {
report();
} on Exception catch (e) {}
currentPageIndex.value++;
}
void report() {
const page1Labels = [
'stressAnxiety', // index 0
'tired', // index 1
'poorRest', // index 2
'needStimulants', // index 3
'none', // index 4
];
// page 2: 压力目标(单选)
const page2Labels = [
'findSource', // index 0
'reminder', // index 1
'lovedOnes', // index 2
'relax', // index 3
'bodyTalk', // index 4
];
// page 3: 缓解方式(多选)
const page3Labels = [
'sleep', // index 0
'care', // index 1
'exercise', // index 2
'sun', // index 3
'water', // index 4
'meditation', // index 5
];
late List<String> labels;
switch (currentPageIndex.value) {
case 1:
labels = page1Labels;
break;
case 2:
labels = page2Labels;
break;
case 3:
labels = page3Labels;
break;
}
final selected = (selections[currentPageIndex.value] ?? {})
.map((i) => labels[i])
.toList();
var key = '';
switch (currentPageIndex.value) {
case 1:
key = 'what_happened';
break;
case 2:
key = 'want_messages';
break;
case 3:
key = 'vent_ways';
break;
}
if (selected.isNotEmpty && key.isNotEmpty) {
_userApi.updateUserOnboarding({key: selected});
}
}
Future<bool> prepareContinue(int pageIndex) async {
if (!canContinue(pageIndex)) {
return false;
... ... @@ -125,6 +192,7 @@ class UserOnboardingController extends GetxController {
void finishOnboarding() {
// 标记当前账号引导已全部完成
_userApi.updateOwnerUserInfo(isPassNoviceGuide: 1);
final userId =
Get.find<UserPreferencesStorage>().preferences.value.meUserInfo?.id ??
0;
... ...
import 'package:flutter/material.dart';
class OnboardingOptionData {
const OnboardingOptionData({
required this.label,
required this.icon,
required this.iconColor,
required this.iconPath,
this.exclusive = false,
});
final String label;
final IconData icon;
final Color iconColor;
final String iconPath;
final bool exclusive;
}
... ...
... ... @@ -25,28 +25,23 @@ List<Widget> buildUserOnboardingPages(BuildContext context,
options: [
OnboardingOptionData(
label: l10n.onboardingStateStressAnxiety,
icon: Icons.sentiment_very_dissatisfied_rounded,
iconColor: const Color(0xFFFF985D),
iconPath: 'assets/images/user_onboarding/ic_stressful.png',
),
OnboardingOptionData(
label: l10n.onboardingStateTired,
icon: Icons.battery_2_bar_rounded,
iconColor: const Color(0xFFFFC85C),
iconPath: 'assets/images/user_onboarding/ic_tire.png',
),
OnboardingOptionData(
label: l10n.onboardingStatePoorRest,
icon: Icons.bedtime_rounded,
iconColor: const Color(0xFF45C89F),
iconPath: 'assets/images/user_onboarding/ic_restless.png',
),
OnboardingOptionData(
label: l10n.onboardingStateNeedStimulants,
icon: Icons.local_cafe_rounded,
iconColor: const Color(0xFFFF8AB6),
iconPath: 'assets/images/user_onboarding/ic_wine.png',
),
OnboardingOptionData(
label: l10n.onboardingStateNone,
icon: Icons.more_horiz_rounded,
iconColor: context.colors.primary,
iconPath: 'assets/images/user_onboarding/ic_none.png',
exclusive: true,
),
],
... ... @@ -58,28 +53,23 @@ List<Widget> buildUserOnboardingPages(BuildContext context,
options: [
OnboardingOptionData(
label: l10n.onboardingStressGoalSource,
icon: Icons.radar_rounded,
iconColor: const Color(0xFF6D9DFF),
iconPath: 'assets/images/user_onboarding/ic_target.png',
),
OnboardingOptionData(
label: l10n.onboardingStressGoalReminder,
icon: Icons.notifications_rounded,
iconColor: const Color(0xFFFFA63F),
iconPath: 'assets/images/user_onboarding/ic_alarm.png',
),
OnboardingOptionData(
label: l10n.onboardingStressGoalLovedOnes,
icon: Icons.favorite_rounded,
iconColor: const Color(0xFFFF72A9),
iconPath: 'assets/images/user_onboarding/ic_heart.png',
),
OnboardingOptionData(
label: l10n.onboardingStressGoalRelax,
icon: Icons.self_improvement_rounded,
iconColor: const Color(0xFFFFC158),
iconPath: 'assets/images/user_onboarding/ic_relax.png',
),
OnboardingOptionData(
label: l10n.onboardingStressGoalBodyTalk,
icon: Icons.accessibility_new_rounded,
iconColor: const Color(0xFFFFA24E),
iconPath: 'assets/images/user_onboarding/ic_health_green.png',
),
],
),
... ... @@ -90,33 +80,27 @@ List<Widget> buildUserOnboardingPages(BuildContext context,
options: [
OnboardingOptionData(
label: l10n.onboardingReliefSleep,
icon: Icons.nightlight_round,
iconColor: const Color(0xFF8C6DFF),
iconPath: 'assets/images/user_onboarding/ic_sleep.png',
),
OnboardingOptionData(
label: l10n.onboardingReliefCare,
icon: Icons.volunteer_activism_rounded,
iconColor: const Color(0xFFFF72B6),
iconPath: 'assets/images/user_onboarding/ic_care.png',
),
OnboardingOptionData(
label: l10n.onboardingReliefExercise,
icon: Icons.fitness_center_rounded,
iconColor: const Color(0xFF7ACD74),
iconPath: 'assets/images/user_onboarding/ic_step.png',
),
OnboardingOptionData(
label: l10n.onboardingReliefSun,
icon: Icons.wb_sunny_rounded,
iconColor: const Color(0xFFFFC13D),
iconPath: 'assets/images/user_onboarding/ic_sun.png',
),
OnboardingOptionData(
label: l10n.onboardingReliefWater,
icon: Icons.water_drop_rounded,
iconColor: const Color(0xFF76A9FF),
iconPath: 'assets/images/user_onboarding/ic_water.png',
),
OnboardingOptionData(
label: l10n.onboardingReliefMeditation,
icon: Icons.spa_rounded,
iconColor: const Color(0xFF8A78F6),
iconPath: 'assets/images/user_onboarding/ic_meditation.png',
),
],
),
... ... @@ -152,13 +136,10 @@ class _IntroPage extends StatelessWidget {
children: [
OnboardingTitleText(title),
const SizedBox(height: 12),
Opacity(
opacity: 0.30,
child: Container(
width: 375,
height: 384,
decoration: BoxDecoration(color: const Color(0xFFFF0000)),
),
Image.asset(
'assets/images/user_onboarding/bg_onboarding_watch.png',
height: 384,
fit: BoxFit.fitHeight,
),
const SizedBox(height: 12),
OnboardingEmRichText(
... ... @@ -271,20 +252,20 @@ class _HrvIntroPage extends StatelessWidget {
'${l10n.onboardingHrvTitle}\n${l10n.onboardingHrvSubtitle}',
maxWidth: 320,
),
const SizedBox(height: 4),
const SizedBox(height: 57),
Container(
height: 393,
height: 210,
decoration:
BoxDecoration(color: const Color(0xFFFF0000).withAlpha(120)),
),
const SizedBox(height: 17),
const SizedBox(height: 31),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
l10n.onboardingHrvDescription,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFFA0A3BC),
style: TextStyle(
color: context.colors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.35,
... ... @@ -365,18 +346,16 @@ class _HrvResearchPageState extends State<_HrvResearchPage> {
leftCard: ResearchCard(
title: l10n.onboardingResearchFatigue,
isHRVup: false,
backgroundColor: const Color(0xFFFFE2E3),
height: 320,
image: 'assets/images/onboarding/research_fatigue.png',
backgroundColor: const Color(0xFFFFDBEF),
image: 'assets/images/user_onboarding/bg_hrv_down_1.png',
),
rightCard: Padding(
padding: const EdgeInsets.only(bottom: 41),
child: ResearchCard(
title: l10n.onboardingResearchEnergy,
isHRVup: true,
backgroundColor: const Color(0xFFD6F2E5),
height: 320,
image: 'assets/images/onboarding/research_energy.png',
backgroundColor: const Color(0xFFC9EADE),
image: 'assets/images/user_onboarding/bg_hrv_up_1.png',
),
),
),
... ... @@ -384,18 +363,16 @@ class _HrvResearchPageState extends State<_HrvResearchPage> {
leftCard: ResearchCard(
title: l10n.onboardingResearchStress,
isHRVup: false,
backgroundColor: const Color(0xFFFFE2E3),
height: 320,
image: 'assets/images/onboarding/research_fatigue.png',
backgroundColor: const Color(0xFFFFDBEF),
image: 'assets/images/user_onboarding/bg_hrv_down_2.png',
),
rightCard: Padding(
padding: const EdgeInsets.only(bottom: 41),
child: ResearchCard(
title: l10n.onboardingResearchRelaxed,
isHRVup: true,
backgroundColor: const Color(0xFFD6F2E5),
height: 320,
image: 'assets/images/onboarding/research_energy.png',
backgroundColor: const Color(0xFFC9EADE),
image: 'assets/images/user_onboarding/bg_hrv_up_2.png',
),
),
),
... ... @@ -403,18 +380,16 @@ class _HrvResearchPageState extends State<_HrvResearchPage> {
leftCard: ResearchCard(
title: l10n.onboardingResearchSick,
isHRVup: false,
backgroundColor: const Color(0xFFFFE2E3),
height: 320,
image: 'assets/images/onboarding/research_fatigue.png',
backgroundColor: const Color(0xFFFFDBEF),
image: 'assets/images/user_onboarding/bg_hrv_down_3.png',
),
rightCard: Padding(
padding: const EdgeInsets.only(bottom: 41),
child: ResearchCard(
title: l10n.onboardingResearchHealthy,
isHRVup: true,
backgroundColor: const Color(0xFFD6F2E5),
height: 320,
image: 'assets/images/onboarding/research_energy.png',
backgroundColor: const Color(0xFFC9EADE),
image: 'assets/images/user_onboarding/bg_hrv_up_3.png',
),
),
),
... ... @@ -422,18 +397,16 @@ class _HrvResearchPageState extends State<_HrvResearchPage> {
leftCard: ResearchCard(
title: l10n.onboardingResearchPoorSleep,
isHRVup: false,
backgroundColor: const Color(0xFFFFE2E3),
height: 320,
image: 'assets/images/onboarding/research_fatigue.png',
backgroundColor: const Color(0xFFFFDBEF),
image: 'assets/images/user_onboarding/bg_hrv_down_4.png',
),
rightCard: Padding(
padding: const EdgeInsets.only(bottom: 41),
child: ResearchCard(
title: l10n.onboardingResearchGoodSleep,
isHRVup: true,
backgroundColor: const Color(0xFFD6F2E5),
height: 320,
image: 'assets/images/onboarding/research_energy.png',
backgroundColor: const Color(0xFFC9EADE),
image: 'assets/images/user_onboarding/bg_hrv_up_4.png',
),
),
),
... ... @@ -543,14 +516,13 @@ class HealthPermissionPage extends StatelessWidget {
fontSize: 14,
),
),
const SizedBox(height: 58),
Container(
height: 383,
width: 280,
decoration:
BoxDecoration(color: const Color(0xFFFF0000).withAlpha(120)),
const SizedBox(height: 28),
Image.asset(
'assets/images/permission/apple_open_health_kit_cn.png',
height: 295,
fit: BoxFit.fitHeight,
),
const SizedBox(height: 8),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 34),
child: Text(
... ... @@ -594,21 +566,13 @@ class _NotificationPermissionPage extends StatelessWidget {
),
),
const SizedBox(height: 20),
Container(
Image.asset(
'assets/images/user_onboarding/bg_onboarding_notification.png',
height: 383,
decoration:
BoxDecoration(color: const Color(0xFFFF0000).withAlpha(120)),
fit: BoxFit.fitHeight,
),
// const SizedBox(height: 100),
// const OnboardingMotionPlaceholder(
// icon: Icons.notifications_active_rounded,
// height: 250,
// ),
],
),
);
}
}
... ...
... ... @@ -9,12 +9,14 @@ class GuideCommonScaffold extends StatelessWidget {
this.bottom,
this.actions = const [],
this.onBackPressed,
this.showGradientBg = false,
});
final Widget child;
final Widget? bottom;
final List<Widget> actions;
final VoidCallback? onBackPressed;
final bool showGradientBg;
@override
Widget build(BuildContext context) {
... ... @@ -29,7 +31,6 @@ class GuideCommonScaffold extends StatelessWidget {
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: Colors.white,
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
... ... @@ -57,23 +58,24 @@ class GuideCommonScaffold extends StatelessWidget {
body: Stack(
fit: StackFit.expand,
children: [
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment(0.50, -0.00),
end: Alignment(0.50, 1.00),
if (showGradientBg)
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment(0.50, -0.00),
end: Alignment(0.50, 1.00),
colors: [
const Color(0xFFE6DAFF),
const Color(0xFFECE4FF),
const Color(0xFFEEE6FF),
const Color(0xFFF8F6FF)
],
colors: [
const Color(0xFFE6DAFF),
const Color(0xFFECE4FF),
const Color(0xFFEEE6FF),
const Color(0xFFF8F6FF)
],
// stops: [0, 0.33173, 0.74038, 1],
// stops: [0, 0.33173, 0.74038, 1],
),
),
),
),
child,
if (bottom != null)
Positioned(
... ...
... ... @@ -68,7 +68,7 @@ class OnboardingEmRichText extends StatelessWidget {
final normalStyle = const TextStyle(
color: _normalColor,
fontSize: fontSize,
fontWeight: FontWeight.w400,
fontWeight: FontWeight.w600,
);
final emphasisStyle = const TextStyle(
color: _emphasisColor,
... ...
... ... @@ -2,64 +2,12 @@ import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
class OnboardingMotionPlaceholder extends StatelessWidget {
const OnboardingMotionPlaceholder({
super.key,
required this.icon,
required this.height,
});
final IconData icon;
final double height;
@override
Widget build(BuildContext context) {
return SizedBox(
height: height,
child: Center(
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.96, end: 1),
duration: const Duration(milliseconds: 900),
curve: Curves.easeInOut,
builder: (context, scale, child) {
return Transform.scale(scale: scale, child: child);
},
child: Container(
width: 170,
height: 170,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.42),
borderRadius: BorderRadius.circular(32),
border: Border.all(
color: Colors.white.withValues(alpha: 0.6),
),
boxShadow: [
BoxShadow(
color: context.colors.primary.withValues(alpha: 0.1),
blurRadius: 26,
offset: const Offset(0, 12),
),
],
),
child: Icon(
icon,
color: context.colors.primary,
size: 72,
),
),
),
),
);
}
}
class ResearchCard extends StatelessWidget {
const ResearchCard({
super.key,
required this.title,
required this.isHRVup,
required this.backgroundColor,
required this.height,
required this.image,
});
... ... @@ -67,20 +15,20 @@ class ResearchCard extends StatelessWidget {
final bool isHRVup;
final Color backgroundColor;
final double height;
final String image;
@override
Widget build(BuildContext context) {
return Container(
height: height,
height: 320,
width: 171,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(32),
image: DecorationImage(image: AssetImage(image), fit: BoxFit.fill),
),
padding: const EdgeInsets.fromLTRB(12, 24, 12, 28),
child: Stack(
children: [
Image.asset(image),
Positioned(
top: 0,
left: 0,
... ... @@ -126,89 +74,3 @@ class ResearchCard extends StatelessWidget {
);
}
}
class GiftIllustration extends StatelessWidget {
const GiftIllustration({super.key});
@override
Widget build(BuildContext context) {
return Container(
height: 332,
width: double.infinity,
color: const Color(0xFFF17F96),
child: Center(
child: SizedBox(
width: 240,
height: 240,
child: Stack(
alignment: Alignment.center,
children: [
Positioned(
top: 36,
child: Container(
width: 190,
height: 52,
decoration: BoxDecoration(
color: const Color(0xFFFF4F21),
borderRadius: BorderRadius.circular(8),
),
),
),
Positioned(
top: 76,
child: Container(
width: 184,
height: 132,
decoration: BoxDecoration(
color: const Color(0xFFFF4F21),
borderRadius: BorderRadius.circular(12),
),
),
),
Positioned(
top: 72,
bottom: 28,
child: Container(
width: 12,
color: const Color(0xFFFF8D1A),
),
),
Positioned(
top: 44,
child: Transform.rotate(
angle: -0.08,
child: Container(
width: 168,
height: 100,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFFB538A6),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'50%',
style: TextStyle(
color: Color(0xFFFFC38E),
fontSize: 56,
fontWeight: FontWeight.w800,
letterSpacing: 0,
),
),
),
),
),
const Positioned(
top: 0,
child: Icon(
Icons.card_giftcard_rounded,
color: Color(0xFFFF8D1A),
size: 84,
),
),
],
),
),
),
);
}
}
... ...
... ... @@ -82,18 +82,10 @@ class _OptionIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
return Image.asset(
data.iconPath,
width: 48,
height: 48,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFF0F0F6),
),
child: Icon(
data.icon,
color: data.iconColor,
size: 24,
),
);
}
}
... ...
import 'package:doublefeel_flutter/app/modules/friends/bindings/friend_home_binding.dart';
import 'package:doublefeel_flutter/app/modules/friends/views/friend_home_page.dart';
import 'package:doublefeel_flutter/app/modules/membership_offer/bindings/membership_detail_binding.dart';
import 'package:doublefeel_flutter/app/modules/membership_offer/views/membership_detail_view.dart';
import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
import 'package:get/get.dart';
... ... @@ -114,6 +116,11 @@ abstract final class AppPages {
binding: MembershipOfferBinding(),
),
GetPage(
name: Routes.MEMBERSHIP_DETAIL,
page: () => const MembershipDetailView(),
binding: MembershipDetailBinding(),
),
GetPage(
name: AppRoutes.bindPartner,
page: () => const BindPartnerView(),
binding: BindPartnerBinding(),
... ...
... ... @@ -5,6 +5,7 @@ abstract class Routes {
Routes._();
static const USER_ONBOARDING = _Paths.USER_ONBOARDING;
static const MEMBERSHIP_OFFER = _Paths.MEMBERSHIP_OFFER;
static const MEMBERSHIP_DETAIL = _Paths.MEMBERSHIP_DETAIL;
static const LOGIN = _Paths.LOGIN;
static const DEVELOPER_OPTIONS = _Paths.DEVELOPER_OPTIONS;
static const USER_INFORMATION = _Paths.USER_INFORMATION;
... ... @@ -31,6 +32,7 @@ abstract class _Paths {
_Paths._();
static const USER_ONBOARDING = '/user-onboarding';
static const MEMBERSHIP_OFFER = '/membership-offer';
static const MEMBERSHIP_DETAIL = '/membership-detail';
static const LOGIN = '/login';
static const DEVELOPER_OPTIONS = '/developer-options';
static const USER_INFORMATION = '/developer-options/user-information';
... ...
import 'package:doublefeel_flutter/core/error/http_error_handling_policy.dart';
import '../../../data/models/friend/friend_models.dart';
import '../../result/app_result.dart';
import '../../result/safe_call.dart';
... ... @@ -20,6 +22,22 @@ class FriendApi {
);
}
Future<AppResult<FriendItem>> addFriendHandleError(
String uniqueCode, HttpErrorHandlingPolicy policy) {
return safeCall(
call: () async {
final response = await _dioClient.dio.post(
ApiPaths.friends,
data: {'unique_code': uniqueCode},
);
return FriendItem.fromJson(
response.data as Map<String, dynamic>,
);
},
errorHandlingPolicy: policy,
);
}
Future<AppResult<void>> deleteFriend(int friendUserId) {
return safeCall(
call: () async {
... ...
... ... @@ -147,12 +147,12 @@ class HealthApi {
}
Future<AppResult<SleepStatisticsData>> getSleepStatistics(
int dateRangeType,
int startDate, {
int? queryUserId,
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
int dateRangeType,
int startDate, {
int? queryUserId,
HttpErrorHandlingPolicy? errorHandlingPolicy =
HttpErrorHandlingPolicy.defaultPolicy,
}) {
return safeCall(
call: () async {
final response = await _dioClient.dio.get(
... ... @@ -345,8 +345,17 @@ class HealthApi {
.get(ApiPaths.v2ActivityTarget, queryParameters: {
if (friendUserId != null) 'query_user_id': friendUserId,
});
return V2ActivityTarget.fromJson(
response.data as Map<String, dynamic>);
return V2ActivityTarget.fromJson(response.data as Map<String, dynamic>);
},
errorHandlingPolicy: HttpErrorHandlingPolicy.defaultPolicy,
);
}
Future<AppResult<bool>> getHealthDataEverUploaded() {
return safeCall(
call: () async {
final response = await _dioClient.dio.get(ApiPaths.weatherUploadedData);
return response.data['flag'];
},
errorHandlingPolicy: HttpErrorHandlingPolicy.defaultPolicy,
);
... ...
... ... @@ -135,28 +135,42 @@ class UserApi {
);
}
Future<AppResult<UserInfoResponse>> updateOwnerUserInfo({
String? avatar,
String? nickname,
int? persona,
int? enableAddWithUcode
}) {
Future<AppResult<UserInfoResponse>> updateOwnerUserInfo(
{String? avatar,
String? nickname,
int? persona,
int? enableAddWithUcode,
int? isPassNoviceGuide}) {
return safeCall(
call: () async {
final response = await _dioClient.dio.put(
ApiPaths.userInfo,
data: UserInfoUpdateRequest(
avatar: avatar,
nickname: nickname,
persona: persona,
enableAddWithUcode: enableAddWithUcode
).toJson(),
avatar: avatar,
nickname: nickname,
persona: persona,
enableAddWithUcode: enableAddWithUcode,
isPassNoviceGuide: isPassNoviceGuide)
.toJson(),
);
return UserInfoResponse.fromJson(response.data as Map<String, dynamic>);
},
);
}
Future<AppResult<void>> updateUserOnboarding(
Object? data,
) {
return safeCall(
call: () async {
await _dioClient.dio.put(
ApiPaths.userInfo,
data: data,
);
},
);
}
Future<AppResult<void>> bindPartner(String bindCode) {
return safeCall(
call: () async {
... ...
... ... @@ -23,7 +23,8 @@ abstract final class ApiPaths {
'/client/doublefeel/health/data_upload/sleep/';
static const healthActivityTarget =
'/client/doublefeel/health/activity_target/';
static const healthStatsSleep = '/client/doublefeel/health/v2/statistics/sleep/';
static const healthStatsSleep =
'/client/doublefeel/health/v2/statistics/sleep/';
static const healthStatsActivity =
'/client/doublefeel/health/v2/statistics/activity/';
static const healthStatsHrv = '/client/doublefeel/health/v2/statistics/hrv/';
... ... @@ -38,6 +39,8 @@ abstract final class ApiPaths {
'/client/doublefeel/health/v2/realtime_stress/';
static const v2ActivityTarget =
'/client/doublefeel/health/v2/activity_target/';
static const weatherUploadedData =
'/client/doublefeel/health/v2/weather_uploaded_data/';
// Pay
static const paymentProducts = '/client/doublefeel/payment/products/';
... ...
... ... @@ -64,6 +64,15 @@ class UserAccountStorage {
Future<void> markOnboardingCompleted(int userId) =>
_prefs.setInt(_onboardingKey(userId), _kOnboardingCompleted);
/// 首页添加好友引导间隔
Future<void> saveLastAddFriendBannerShowTime(int userId) => _prefs.setInt(
'last_add_friend_banner_show_time_$userId',
DateTime.now().millisecondsSinceEpoch);
/// 首页添加好友引导间隔
int? getLastAddFriendBannerShowTime(int userId) =>
_prefs.getInt('last_add_friend_banner_show_time_$userId');
// ─── Apple Health 上传记录 ────────────────────────────────────────────────
String? appleHealthUploadLocalRecordJson(int userId) =>
... ...
// ─── Responses ───────────────────────────────────────────────────────────────
class FriendListResponse {
const FriendListResponse({this.list = const []});
const FriendListResponse({this.list = const [], this.limit});
final List<FriendItem> list;
final int? limit;
factory FriendListResponse.fromJson(Map<String, dynamic> json) {
return FriendListResponse(
... ... @@ -11,11 +12,13 @@ class FriendListResponse {
?.map((e) => FriendItem.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
limit: json['limit'] as int?,
);
}
Map<String, dynamic> toJson() => {
'list': list.map((e) => e.toJson()).toList(),
'limit': limit,
};
}
... ...
... ... @@ -76,6 +76,7 @@ class UserPreferencesVipInfo {
this.isShare = false,
this.vipStartDate = 0,
this.vipEndDate = 0,
this.vipType = 0,
});
final bool isVip;
... ... @@ -83,6 +84,7 @@ class UserPreferencesVipInfo {
final bool isShare;
final int vipStartDate;
final int vipEndDate;
final int vipType;
factory UserPreferencesVipInfo.fromJson(Map<String, dynamic> json) {
return UserPreferencesVipInfo(
... ... @@ -91,6 +93,7 @@ class UserPreferencesVipInfo {
isShare: (json['is_share'] as bool?) ?? false,
vipStartDate: (json['vip_start_date'] as num?)?.toInt() ?? 0,
vipEndDate: (json['vip_end_date'] as num?)?.toInt() ?? 0,
vipType: (json['vip_type'] as num?)?.toInt() ?? 0,
);
}
... ... @@ -101,6 +104,7 @@ class UserPreferencesVipInfo {
'is_share': isShare,
'vip_start_date': vipStartDate,
'vip_end_date': vipEndDate,
'vip_type': vipType,
};
}
... ... @@ -111,6 +115,7 @@ class UserPreferencesVipInfo {
isShare: info.isShare ?? false,
vipStartDate: info.vipStartDate ?? 0,
vipEndDate: info.vipEndDate ?? 0,
vipType: info.vipType ?? 0,
);
}
}
... ...
... ... @@ -87,19 +87,30 @@ class PushTokenRequest {
}
class UserInfoUpdateRequest {
const UserInfoUpdateRequest({this.avatar, this.nickname, this.persona, this.enableAddWithUcode});
const UserInfoUpdateRequest(
{this.avatar,
this.nickname,
this.persona,
this.enableAddWithUcode,
this.isPassNoviceGuide});
final String? avatar;
final String? nickname;
final int? persona;
final int? enableAddWithUcode;
final int? isPassNoviceGuide;
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (avatar != null) json['avatar'] = avatar;
if (nickname != null) json['nickname'] = nickname;
if (persona != null) json['persona'] = persona;
if (enableAddWithUcode != null) json['enable_add_with_ucode'] = enableAddWithUcode;
if (enableAddWithUcode != null) {
json['enable_add_with_ucode'] = enableAddWithUcode;
}
if (isPassNoviceGuide != null) {
json['is_pass_novice_guide'] = isPassNoviceGuide;
}
return json;
}
}
... ... @@ -193,7 +204,8 @@ class UserInfoResponse {
this.createTime,
this.isBot,
this.uniqueCode,
this.enableAddWithUCode});
this.enableAddWithUCode,
this.isPassNoviceGuide});
final int? id;
final String? pairCode;
... ... @@ -208,6 +220,7 @@ class UserInfoResponse {
final int? isBot;
final String? uniqueCode;
final int? enableAddWithUCode;
final int? isPassNoviceGuide;
factory UserInfoResponse.fromJson(Map<String, dynamic> json) {
return UserInfoResponse(
... ... @@ -224,6 +237,7 @@ class UserInfoResponse {
isBot: json['is_bot'] as int?,
uniqueCode: json['unique_code'] as String?,
enableAddWithUCode: json['enable_add_with_ucode'] as int?,
isPassNoviceGuide: json['is_pass_novice_guide'] as int?,
);
}
... ... @@ -244,6 +258,9 @@ class UserInfoResponse {
if (enableAddWithUCode != null) {
val['enable_add_with_ucode'] = enableAddWithUCode;
}
if (isPassNoviceGuide != null) {
val['is_pass_novice_guide'] = isPassNoviceGuide;
}
return val;
}
}
... ...
... ... @@ -5,6 +5,7 @@ class VipInfo {
this.isShare,
this.vipStartDate,
this.vipEndDate,
this.vipType,
});
final bool? isVip;
... ... @@ -12,6 +13,7 @@ class VipInfo {
final bool? isShare;
final int? vipStartDate;
final int? vipEndDate;
final int? vipType;
factory VipInfo.fromJson(Map<String, dynamic> json) {
return VipInfo(
... ... @@ -20,6 +22,7 @@ class VipInfo {
isShare: json['is_share'] as bool?,
vipStartDate: (json['vip_start_date'] as num?)?.toInt(),
vipEndDate: (json['vip_end_date'] as num?)?.toInt(),
vipType: (json['vip_type'] as num?)?.toInt(),
);
}
... ... @@ -30,6 +33,7 @@ class VipInfo {
if (isShare != null) val['is_share'] = isShare;
if (vipStartDate != null) val['vip_start_date'] = vipStartDate;
if (vipEndDate != null) val['vip_end_date'] = vipEndDate;
if (vipType != null) val['vip_type'] = vipType;
return val;
}
}
... ...
... ... @@ -66,9 +66,6 @@
"onboardingNotificationBody": "After Apple Watch data updates, we can remind you in time and help you act to improve your stress state.",
"onboardingMemberTitle": "Get an annual membership offer",
"onboardingMemberBody": "Start your pressure alert and health companion journey, so love and care are always present.",
"onboardingMemberOriginalPrice": "Original ¥72.00/year",
"onboardingMemberCurrentPrice": "Now ¥60.00/year",
"onboardingMemberMonthlyPrice": "Only ¥5.00/month",
"onboardingMemberAllOptions": "View all purchase options",
"healthCompanionIsNowAvailable": "Health Companion is now available",
"youCanNowViewEachOtherSHrvStressLevelsAndSleepPatternsAndReachOutToCheckInWhenTheOtherPersonSeemsTired": "You can now view each other's HRV, stress levels, and sleep patterns, and reach out to check in when the other person seems tired.",
... ... @@ -93,7 +90,7 @@
"loginTerms": "Terms of Service",
"loginAgreementAnd": " and ",
"loginPrivacy": "Privacy Policy",
"phoneLoginHello": "Hello",
"phoneLoginHello": "Hello",
"phoneLoginWelcome": "Welcome to Double Feel",
"phoneLoginPhoneHint": "Enter phone number",
"phoneLoginSendCode": "Send Code",
... ... @@ -498,4 +495,4 @@
"appReviewFeedbackSendAction": "Send Feedback",
"appReviewFeedbackLaterAction": "Maybe Later",
"appReviewIllustrationPlaceholder": "Illustration Placeholder"
}
}
\ No newline at end of file
... ...
... ... @@ -69,9 +69,6 @@
"onboardingNotificationBody": "AppleWatch数据更新后会及时提醒你,帮助你及时行动,改善压力状态",
"onboardingMemberTitle": "获得年度会员优惠",
"onboardingMemberBody": "开启压力预警与健康陪伴之旅,让爱与关心从不缺席",
"onboardingMemberOriginalPrice": "原价¥72.00/年",
"onboardingMemberCurrentPrice": "现价 ¥60.00/年",
"onboardingMemberMonthlyPrice": "仅相当于¥5.00/月",
"onboardingMemberAllOptions": "查看所有购买选项",
"healthCompanionIsNowAvailable": "健康陪伴已开启",
"youCanNowViewEachOtherSHrvStressLevelsAndSleepPatternsAndReachOutToCheckInWhenTheOtherPersonSeemsTired": "你们现在可以查看彼此的 HRV、压力与睡眠变化,并在对方疲惫时及时送上关心。",
... ... @@ -364,21 +361,72 @@
"reportPeriodMonth": "月",
"reportPeriodYear": "年",
"reportDateYear": "{year}年",
"@reportDateYear": {"placeholders": {"year": {"type": "int"}}},
"@reportDateYear": {
"placeholders": {
"year": {
"type": "int"
}
}
},
"reportDateMonth": "{month}月",
"@reportDateMonth": {"placeholders": {"month": {"type": "int"}}},
"@reportDateMonth": {
"placeholders": {
"month": {
"type": "int"
}
}
},
"reportDateMonthDay": "{month}月{day}日",
"@reportDateMonthDay": {"placeholders": {"month": {"type": "int"}, "day": {"type": "int"}}},
"@reportDateMonthDay": {
"placeholders": {
"month": {
"type": "int"
},
"day": {
"type": "int"
}
}
},
"reportDateYearMonthDay": "{year}年{month}月{day}日",
"@reportDateYearMonthDay": {"placeholders": {"year": {"type": "int"}, "month": {"type": "int"}, "day": {"type": "int"}}},
"@reportDateYearMonthDay": {
"placeholders": {
"year": {
"type": "int"
},
"month": {
"type": "int"
},
"day": {
"type": "int"
}
}
},
"reportDatePickerTitle": "选择时间",
"reportDatePickerConfirm": "确定",
"reportDatePickerYearOption": "{year} 年",
"@reportDatePickerYearOption": {"placeholders": {"year": {"type": "int"}}},
"@reportDatePickerYearOption": {
"placeholders": {
"year": {
"type": "int"
}
}
},
"reportDatePickerMonthOption": "{month} 月",
"@reportDatePickerMonthOption": {"placeholders": {"month": {"type": "int"}}},
"@reportDatePickerMonthOption": {
"placeholders": {
"month": {
"type": "int"
}
}
},
"reportDatePickerDayOption": "{day} 日",
"@reportDatePickerDayOption": {"placeholders": {"day": {"type": "int"}}},
"@reportDatePickerDayOption": {
"placeholders": {
"day": {
"type": "int"
}
}
},
"reportWaitingForData": "等待数据",
"reportNoData": "暂无数据",
"reportNoDataToday": "暂无本日数据",
... ... @@ -395,14 +443,41 @@
"reportWeekdaySaturday": "六",
"reportWeekdaySunday": "日",
"reportDateWithWeekday": "{date} 星期{weekday}",
"@reportDateWithWeekday": {"placeholders": {"date": {"type": "String"}, "weekday": {"type": "String"}}},
"@reportDateWithWeekday": {
"placeholders": {
"date": {
"type": "String"
},
"weekday": {
"type": "String"
}
}
},
"reportOtherPossessiveTitle": "Ta的{title}",
"@reportOtherPossessiveTitle": {"placeholders": {"title": {"type": "String"}}},
"@reportOtherPossessiveTitle": {
"placeholders": {
"title": {
"type": "String"
}
}
},
"reportOtherTitle": "Ta{title}",
"@reportOtherTitle": {"placeholders": {"title": {"type": "String"}}},
"@reportOtherTitle": {
"placeholders": {
"title": {
"type": "String"
}
}
},
"reportTrend": "趋势",
"reportExampleTitle": "{title}(示例)",
"@reportExampleTitle": {"placeholders": {"title": {"type": "String"}}},
"@reportExampleTitle": {
"placeholders": {
"title": {
"type": "String"
}
}
},
"hrvStressExcellent": "状态优秀",
"hrvStressNormal": "状态正常",
"hrvStressAttention": "注意压力",
... ... @@ -427,9 +502,21 @@
"hrvComparedLastWeekUnavailable": "比上周少-天",
"hrvSameAsLastWeek": "与上周持平",
"hrvMoreDaysThanLastWeek": "比上周多{count}天",
"@hrvMoreDaysThanLastWeek": {"placeholders": {"count": {"type": "int"}}},
"@hrvMoreDaysThanLastWeek": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"hrvFewerDaysThanLastWeek": "比上周少{count}天",
"@hrvFewerDaysThanLastWeek": {"placeholders": {"count": {"type": "int"}}},
"@hrvFewerDaysThanLastWeek": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"hrvUnlockNow": "立即解锁",
"activityTotalBurn": "活动总消耗",
"activityExerciseTotalDuration": "锻炼总时长",
... ... @@ -463,13 +550,46 @@
"sleepPreviousWeek": "上周",
"sleepPreviousMonth": "上月",
"sleepComparedPercent": "比{period}{value}%",
"@sleepComparedPercent": {"placeholders": {"period": {"type": "String"}, "value": {"type": "String"}}},
"@sleepComparedPercent": {
"placeholders": {
"period": {
"type": "String"
},
"value": {
"type": "String"
}
}
},
"sleepDurationValue": "{hours}小时{minutes}分钟",
"@sleepDurationValue": {"placeholders": {"hours": {"type": "int"}, "minutes": {"type": "int"}}},
"@sleepDurationValue": {
"placeholders": {
"hours": {
"type": "int"
},
"minutes": {
"type": "int"
}
}
},
"sleepQualityScore": "{level}:{score}分",
"@sleepQualityScore": {"placeholders": {"level": {"type": "String"}, "score": {"type": "int"}}},
"@sleepQualityScore": {
"placeholders": {
"level": {
"type": "String"
},
"score": {
"type": "int"
}
}
},
"sleepFellAsleepAt": "{time}入睡",
"@sleepFellAsleepAt": {"placeholders": {"time": {"type": "String"}}},
"@sleepFellAsleepAt": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"sleepAverage": "平均",
"sleepTarget": "目标",
"sleepHighest": "最高",
... ... @@ -482,17 +602,53 @@
"friendsLimitReached": "好友数量已达上限",
"friendsAddCloseContact": "添加亲密联系人",
"friendsAddCloseContactWithCount": "添加亲密联系人({count}/{max})",
"@friendsAddCloseContactWithCount": {"placeholders": {"count": {"type": "int"}, "max": {"type": "int"}}},
"@friendsAddCloseContactWithCount": {
"placeholders": {
"count": {
"type": "int"
},
"max": {
"type": "int"
}
}
},
"friendsMe": "我",
"friendsRemarkedDisplayName": "{remark}({name})",
"@friendsRemarkedDisplayName": {"placeholders": {"remark": {"type": "String"}, "name": {"type": "String"}}},
"@friendsRemarkedDisplayName": {
"placeholders": {
"remark": {
"type": "String"
},
"name": {
"type": "String"
}
}
},
"friendsRemarkSuffix": "({remark})",
"@friendsRemarkSuffix": {"placeholders": {"remark": {"type": "String"}}},
"@friendsRemarkSuffix": {
"placeholders": {
"remark": {
"type": "String"
}
}
},
"friendsUnknownFriend": "未知好友",
"friendsUpdatedAt": "更新于{time}",
"@friendsUpdatedAt": {"placeholders": {"time": {"type": "String"}}},
"@friendsUpdatedAt": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"friendsStepCount": "{count}步",
"@friendsStepCount": {"placeholders": {"count": {"type": "int"}}},
"@friendsStepCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"friendsStressAttention": "注意压力",
"friendsWaitingForData": "等待数据",
"friendsSleepQuality": "睡眠质量",
... ... @@ -508,7 +664,13 @@
"friendsSelectAndSync": "选择并同步至表盘",
"friendsBack": "返回",
"friendsTrendTitle": "{name}的趋势",
"@friendsTrendTitle": {"placeholders": {"name": {"type": "String"}}},
"@friendsTrendTitle": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"friendsAddAction": "添加",
"friendsEnterId": "输入ID",
"friendsPromptGotIt": "我知道了",
... ... @@ -522,7 +684,13 @@
"friendsEditRemarkHint": "请输入昵称",
"friendsSave": "保存",
"friendsDeleteConfirmTitle": "确认要和{name}解除好友关系吗?",
"@friendsDeleteConfirmTitle": {"placeholders": {"name": {"type": "String"}}},
"@friendsDeleteConfirmTitle": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"friendsDeleteConfirmMessage": "解除后你将无法查看对方的情绪、健康状态",
"friendsDeleteConfirmAction": "确认解除",
"privacySettingsTitle": "隐私设置",
... ... @@ -612,4 +780,4 @@
"appReviewFeedbackSendAction": "发送反馈",
"appReviewFeedbackLaterAction": "稍后再说",
"appReviewIllustrationPlaceholder": "插图占位"
}
}
\ No newline at end of file
... ...
... ... @@ -62,7 +62,8 @@ import 'app_localizations_zh.dart';
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
... ... @@ -70,7 +71,8 @@ abstract class AppLocalizations {
return Localizations.of<AppLocalizations>(context, AppLocalizations);
}
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
... ... @@ -82,7 +84,8 @@ abstract class AppLocalizations {
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
... ... @@ -491,24 +494,6 @@ abstract class AppLocalizations {
/// **'开启压力预警与健康陪伴之旅,让爱与关心从不缺席'**
String get onboardingMemberBody;
/// No description provided for @onboardingMemberOriginalPrice.
///
/// In zh, this message translates to:
/// **'原价¥72.00/年'**
String get onboardingMemberOriginalPrice;
/// No description provided for @onboardingMemberCurrentPrice.
///
/// In zh, this message translates to:
/// **'现价 ¥60.00/年'**
String get onboardingMemberCurrentPrice;
/// No description provided for @onboardingMemberMonthlyPrice.
///
/// In zh, this message translates to:
/// **'仅相当于¥5.00/月'**
String get onboardingMemberMonthlyPrice;
/// No description provided for @onboardingMemberAllOptions.
///
/// In zh, this message translates to:
... ... @@ -525,7 +510,8 @@ abstract class AppLocalizations {
///
/// In zh, this message translates to:
/// **'你们现在可以查看彼此的 HRV、压力与睡眠变化,并在对方疲惫时及时送上关心。'**
String get youCanNowViewEachOtherSHrvStressLevelsAndSleepPatternsAndReachOutToCheckInWhenTheOtherPersonSeemsTired;
String
get youCanNowViewEachOtherSHrvStressLevelsAndSleepPatternsAndReachOutToCheckInWhenTheOtherPersonSeemsTired;
/// No description provided for @bindPartnerTitle.
///
... ... @@ -3084,7 +3070,8 @@ abstract class AppLocalizations {
String get appReviewIllustrationPlaceholder;
}
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
... ... @@ -3093,25 +3080,25 @@ class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations>
}
@override
bool isSupported(Locale locale) => <String>['en', 'zh'].contains(locale.languageCode);
bool isSupported(Locale locale) =>
<String>['en', 'zh'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en': return AppLocalizationsEn();
case 'zh': return AppLocalizationsZh();
case 'en':
return AppLocalizationsEn();
case 'zh':
return AppLocalizationsZh();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.'
);
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.');
}
... ...