Commit 9a6eb289e699acb68d4870cc64441c9546c953b4

Authored by 刘宏哲
1 parent d9a755aa

feat(app): add burial points

... ... @@ -25,7 +25,7 @@ class ActivityBurnReportView extends StatelessWidget {
final ActivityBurnReportLogic logic;
final bool isVip;
final VoidCallback onSubscribe;
final ValueChanged<String> onSubscribe;
@override
Widget build(BuildContext context) {
... ... @@ -47,7 +47,7 @@ class ActivityBurnReportView extends StatelessWidget {
lockedPeriods: isVip
? const {}
: const {ReportPeriod.week, ReportPeriod.month},
onLockedPeriodTap: (_) => onSubscribe(),
onLockedPeriodTap: (_) => onSubscribe('非会员切换月周-活动消耗'),
),
),
const SizedBox(height: 8),
... ...
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
import 'package:get/get.dart';
import '../../health_trend/controllers/health_trend_analytics.dart';
import '../../health_trend/controllers/health_trend_control.dart';
import '../../report_common/models/health_report_query.dart';
import '../../report_common/models/report_period.dart';
... ... @@ -32,6 +33,7 @@ class FriendTrendController extends GetxController with HealthTrendControl {
}
final FriendItem? friendItem;
bool _isPageReady = false;
HealthReportQuery get query => HealthReportQuery(
targetUserId: friendItem?.friendUserId,
... ... @@ -44,4 +46,27 @@ class FriendTrendController extends GetxController with HealthTrendControl {
period: periodForType(typeIndex),
date: dateForType(typeIndex),
);
@override
void onReady() {
super.onReady();
_isPageReady = true;
_trackEnterPage();
}
@override
void changeType(int index) {
final previousIndex = selectedTypeIndex.value;
super.changeType(index);
if (_isPageReady && selectedTypeIndex.value != previousIndex) {
_trackEnterPage();
}
}
void _trackEnterPage() {
HealthTrendAnalytics.trackEnterPage(
selectedTypeIndex.value,
userRole: '好友的',
);
}
}
... ...
... ... @@ -8,6 +8,7 @@ import 'package:doublefeel_flutter/core/error/app_error.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/services/thinking_data_service.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
... ... @@ -43,6 +44,7 @@ class FriendsController extends GetxController {
final isSelfHealthLoading = false.obs;
Future<void>? _friendsRequest;
Future<void>? _selfHealthRequest;
bool _isPageVisible = false;
bool get isFull => friends.length >= maxFriends;
... ... @@ -52,6 +54,16 @@ class FriendsController extends GetxController {
unawaited(refreshData());
}
void markPageVisible() {
if (_isPageVisible) return;
_isPageVisible = true;
ta.track('enter_doublefeel_friend_page');
}
void markPageHidden() {
_isPageVisible = false;
}
Future<void> refreshData() {
return Future.wait([loadFriends(), loadSelfHealth()]);
}
... ...
... ... @@ -246,7 +246,7 @@ class FriendsTab extends GetView<FriendsController> {
final String name = type.toString();
await Get.toNamed(
AppRoutes.bindPartner,
arguments: {IntentKeys.keyFrom: name},
arguments: {IntentKeys.keyFrom: name, IntentKeys.channelType: '好友页'},
);
await controller.loadFriends();
}
... ...
import 'package:doublefeel_flutter/core/services/thinking_data_service.dart';
class HealthTrendAnalytics {
const HealthTrendAnalytics._();
static void trackEnterPage(int typeIndex, {required String userRole}) {
final eventName = switch (typeIndex) {
1 => 'enter_doublefeel_activity_cost_page',
2 => 'enter_doublefeel_sleep_report_page',
_ => 'enter_doublefeel_hrv_page',
};
ta.track(
eventName,
properties: {'user_role': userRole},
);
}
}
... ...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../../core/constants/intent_keys.dart';
import '../../../../data/local/user_preferences_storage.dart';
import '../../../routes/app_pages.dart';
import '../../activity_burn_report/controllers/activity_burn_report_logic.dart';
... ... @@ -99,7 +100,10 @@ class _HealthTrendContentState extends State<HealthTrendContent>
final preferences = userPrefs.preferences.value;
final vipInfo = preferences.vipInfo;
final isVip = vipInfo?.isVip ?? false;
void openPurchase() => Get.toNamed(Routes.PURCHASE);
void openPurchase(String channelType) => Get.toNamed(
Routes.PURCHASE,
arguments: {IntentKeys.channelType: channelType},
);
return HealthReportSubjectScope(
isSelf: widget.query.isSelf,
... ... @@ -158,7 +162,7 @@ class _HrvTrendSection extends StatefulWidget {
final HealthReportQuery query;
final bool isVip;
final VoidCallback onSubscribe;
final ValueChanged<String> onSubscribe;
final ValueChanged<ReportPeriod> onPeriodChanged;
final ValueChanged<DateTime> onDateChanged;
... ... @@ -231,7 +235,7 @@ class _ActivityBurnTrendSection extends StatefulWidget {
final HealthReportQuery query;
final bool isVip;
final VoidCallback onSubscribe;
final ValueChanged<String> onSubscribe;
final ValueChanged<ReportPeriod> onPeriodChanged;
final ValueChanged<DateTime> onDateChanged;
... ... @@ -307,7 +311,7 @@ class _SleepTrendSection extends StatefulWidget {
final HealthReportQuery query;
final bool isVip;
final VoidCallback onSubscribe;
final ValueChanged<String> onSubscribe;
final ValueChanged<ReportPeriod> onPeriodChanged;
final ValueChanged<DateTime> onDateChanged;
... ...
import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart';
import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.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/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/local/local_storage.dart';
... ... @@ -20,6 +20,7 @@ import 'trend/trend_controller.dart';
class HomeController extends GetxController {
static const trendTabIndex = 1;
static const friendsTabIndex = 2;
static const _mainChannel = MethodChannel('doublefeel_flutter_main_channel');
final AppReviewPromptLogic _appReviewPromptLogic = AppReviewPromptLogic(
... ... @@ -106,7 +107,18 @@ class HomeController extends GetxController {
}
void changeTab(int index) {
final previousIndex = selectedIndex.value;
selectedIndex.value = index;
if (index == trendTabIndex && previousIndex != trendTabIndex) {
Get.find<TrendController>().markPageVisible();
} else if (previousIndex == trendTabIndex && index != trendTabIndex) {
Get.find<TrendController>().markPageHidden();
}
if (index == friendsTabIndex && previousIndex != friendsTabIndex) {
Get.find<FriendsController>().markPageVisible();
} else if (previousIndex == friendsTabIndex && index != friendsTabIndex) {
Get.find<FriendsController>().markPageHidden();
}
}
void openTrend(TrendType type, {DateTime? date, ReportPeriod? period}) {
... ... @@ -199,21 +211,16 @@ class HomeController extends GetxController {
}
break;
case 'trend':
changeTab(trendTabIndex);
final type = params['type'];
if (type != null) {
SchedulerBinding.instance.addPostFrameCallback((_) {
TrendType? trendType = getTrendType(type);
if (trendType != null) {
openTrend(
trendType,
);
}
});
final trendType = type == null ? null : getTrendType(type);
if (trendType != null) {
openTrend(trendType);
} else {
changeTab(trendTabIndex);
}
break;
case 'friends':
changeTab(2);
changeTab(friendsTabIndex);
break;
case 'my':
changeTab(3);
... ...
import 'package:get/get.dart';
import '../../../health_trend/controllers/health_trend_analytics.dart';
import '../../../health_trend/controllers/health_trend_control.dart';
import '../../../report_common/models/health_report_query.dart';
import '../../../report_common/models/report_period.dart';
... ... @@ -15,6 +16,8 @@ enum TrendType {
/// 趋势页顶层 Controller,仅负责:
/// 顶层 HRV / 活动 / 睡眠 类型切换 (selectedTypeIndex)
class TrendController extends GetxController with HealthTrendControl {
bool _isPageVisible = false;
// 当前查看的用户。null 表示查看自己;非 null 表示查看指定用户。
final targetUserId = RxnInt();
... ... @@ -30,6 +33,15 @@ class TrendController extends GetxController with HealthTrendControl {
date: dateForType(typeIndex),
);
@override
void changeType(int index) {
final previousIndex = selectedTypeIndex.value;
super.changeType(index);
if (_isPageVisible && selectedTypeIndex.value != previousIndex) {
_trackEnterPage();
}
}
void selectType(TrendType type, {DateTime? date, ReportPeriod? period}) {
changeType(type.tabIndex);
if (period != null) {
... ... @@ -44,4 +56,21 @@ class TrendController extends GetxController with HealthTrendControl {
if (userId == targetUserId.value) return;
targetUserId.value = userId;
}
void markPageVisible() {
if (_isPageVisible) return;
_isPageVisible = true;
_trackEnterPage();
}
void markPageHidden() {
_isPageVisible = false;
}
void _trackEnterPage() {
HealthTrendAnalytics.trackEnterPage(
selectedTypeIndex.value,
userRole: '我的',
);
}
}
... ...
... ... @@ -22,7 +22,7 @@ class HrvReportView extends StatelessWidget {
final HrvReportLogic logic;
final bool isVip;
final VoidCallback onSubscribe;
final ValueChanged<String> onSubscribe;
@override
Widget build(BuildContext context) {
... ... @@ -44,20 +44,24 @@ class HrvReportView extends StatelessWidget {
lockedPeriods: isVip
? const {}
: const {ReportPeriod.month, ReportPeriod.year},
onLockedPeriodTap: (_) => onSubscribe(),
onLockedPeriodTap: (_) => onSubscribe('非会员切换年月-HRV心率'),
),
),
const SizedBox(height: 8),
Obx(
() => ReportDateSwitcher(
label: logic.dateLabel,
onPrevious: isVip ? logic.previousDay : onSubscribe,
onNext: isVip ? logic.nextDay : onSubscribe,
onPrevious: isVip
? logic.previousDay
: () => onSubscribe('非会员切换日期-HRV心率'),
onNext: isVip
? logic.nextDay
: () => onSubscribe('非会员切换日期-HRV心率'),
canPrevious: logic.canGoPrevious,
canNext: logic.canGoNext,
onTapLabel: isVip
? () => _showDatePicker(context)
: onSubscribe,
: () => onSubscribe('非会员切换日期-HRV心率'),
),
),
],
... ... @@ -82,7 +86,7 @@ class HrvReportView extends StatelessWidget {
HrvWeekReportView(
report: logic.weeklyReport.value,
showExample: !isVip,
onSubscribe: onSubscribe,
onSubscribe: () => onSubscribe('解锁每日压力-HRV心率'),
),
ReportBottomSlogan(bottomSpacing: isVip ? 16 : 88),
],
... ... @@ -100,7 +104,9 @@ class HrvReportView extends StatelessWidget {
right: 0,
bottom: MediaQuery.paddingOf(context).bottom + 24,
child: Center(
child: _UnlockButton(onPressed: onSubscribe),
child: _UnlockButton(
onPressed: () => onSubscribe('解锁每日压力-HRV心率'),
),
),
)
: const SizedBox.shrink(),
... ...
... ... @@ -2,9 +2,11 @@ import 'dart:async';
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/constants/app_const.dart';
import 'package:doublefeel_flutter/core/constants/intent_keys.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/thinking_data_service.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';
... ... @@ -15,6 +17,8 @@ import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../widgets/refund_explanation_bottom_sheet.dart';
class PurchasePlan {
const PurchasePlan({
required this.title,
... ... @@ -54,19 +58,29 @@ class PurchaseController extends GetxController {
final isRestoring = false.obs;
bool isFromOnboard = false;
String channelType = '';
@override
void onInit() {
super.onInit();
final args = Get.arguments;
if (args is Map) {
isFromOnboard = args['from'] == 'onboarding';
isFromOnboard = args[IntentKeys.keyFrom] == 'onboarding';
channelType = _nonEmpty(_stringArgument(args, IntentKeys.channelType)) ??
_nonEmpty(_stringArgument(args, 'channelType')) ??
(isFromOnboard ? '新手引导' : '');
}
scrollController.addListener(_handleScroll);
unawaited(loadProductList());
}
@override
void onReady() {
super.onReady();
_trackEnterVipCenter();
}
@override
void onClose() {
planScrollController.dispose();
scrollController
... ... @@ -108,6 +122,7 @@ class PurchaseController extends GetxController {
Future<void> restorePurchase() async {
if (isRestoring.value || isUnlocking.value) return;
_trackClickVipCenter('恢复购买');
isRestoring.value = true;
try {
final success = await _performRestore();
... ... @@ -122,6 +137,7 @@ class PurchaseController extends GetxController {
Future<void> unlock() async {
if (isUnlocking.value || isRestoring.value) return;
_trackClickVipCenter('立即购买');
final plan = _selectedPlan;
final productId = plan?.product?.id;
final appleProductId = plan?.appleProductId;
... ... @@ -228,6 +244,7 @@ class PurchaseController extends GetxController {
AppleProductPaymentResult? result,
) async {
if (result?.success == true) {
_trackSuccessPayOrder();
await _completeSuccessfulPurchase();
return;
}
... ... @@ -349,7 +366,18 @@ class PurchaseController extends GetxController {
return trimmed;
}
String? _stringArgument(Map args, String key) {
final value = args[key];
return value is String ? value : null;
}
void openRefundExplanation() {
_trackClickVipCenter('了解更多');
showRefundExplanationBottomSheet();
}
void openContactUs() {
_trackClickVipCenter('联系我们');
Get.toNamed(Routes.SUBMIT_FEEDBACK);
}
... ... @@ -366,4 +394,36 @@ class PurchaseController extends GetxController {
parameters: {'url': AppConst.privacyPolicy},
);
}
Map<String, dynamic> get _baseAnalyticsProperties =>
{'channel_type': channelType};
void _trackEnterVipCenter() {
ta.track(
'enter_doublefeel_vip_center',
properties: _baseAnalyticsProperties,
);
}
void _trackClickVipCenter(String clickContent) {
ta.track(
'click_doublefeel_vip_center',
properties: {
..._baseAnalyticsProperties,
'click_content': clickContent,
},
);
}
void _trackSuccessPayOrder() {
final plan = _selectedPlan;
ta.track(
'success_doublefeel_pay_order',
properties: {
..._baseAnalyticsProperties,
'product_type': plan?.product?.appleId,
'price': plan?.product?.price,
},
);
}
}
... ...
... ... @@ -10,7 +10,6 @@ import '../purchase_route_args.dart';
import '../widgets/purchase_bottom_bar.dart';
import '../widgets/purchase_colors.dart';
import '../widgets/purchase_plan_card.dart';
import '../widgets/refund_explanation_bottom_sheet.dart';
class PurchaseView extends GetView<PurchaseController> {
const PurchaseView({super.key});
... ... @@ -18,79 +17,79 @@ class PurchaseView extends GetView<PurchaseController> {
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !controller.isFromOnboard,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) controller.onBackPressed();
},
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: PurchaseColors.background,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: PurchaseColors.background,
body: Stack(
children: [
Positioned.fill(
child: SingleChildScrollView(
controller: controller.scrollController,
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 170),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _HeroHeader(),
Padding(
padding: const EdgeInsets.only(left: 18, top: 9),
child: Text(
context.l10n.purchaseHeroTitle,
style: const TextStyle(
color: Colors.black,
fontSize: 24,
fontWeight: FontWeight.w600,
height: 1.2,
canPop: !controller.isFromOnboard,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) controller.onBackPressed();
},
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: PurchaseColors.background,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: PurchaseColors.background,
body: Stack(
children: [
Positioned.fill(
child: SingleChildScrollView(
controller: controller.scrollController,
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 170),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _HeroHeader(),
Padding(
padding: const EdgeInsets.only(left: 18, top: 9),
child: Text(
context.l10n.purchaseHeroTitle,
style: const TextStyle(
color: Colors.black,
fontSize: 24,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
),
),
const SizedBox(height: 26),
_PlanScroller(controller: controller),
const SizedBox(height: 22),
_SectionTitle(context.l10n.purchaseBenefitsTitle),
const SizedBox(height: 14),
const PremiumBenefitListView(),
const SizedBox(height: 24),
_LegalNotes(controller: controller),
],
),
const SizedBox(height: 26),
_PlanScroller(controller: controller),
const SizedBox(height: 22),
_SectionTitle(context.l10n.purchaseBenefitsTitle),
const SizedBox(height: 14),
const PremiumBenefitListView(),
const SizedBox(height: 24),
_LegalNotes(controller: controller),
],
),
),
),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: _PurchaseNavigationBar(controller: controller),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Obx(
() => PurchaseBottomBar(
label: context.l10n.purchaseUnlockNow,
loading: controller.isUnlocking.value,
onPressed: controller.unlock,
onTermsTap: controller.openUserTerms,
onPrivacyTap: controller.openPrivacyPolicy,
Positioned(
left: 0,
right: 0,
top: 0,
child: _PurchaseNavigationBar(controller: controller),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Obx(
() => PurchaseBottomBar(
label: context.l10n.purchaseUnlockNow,
loading: controller.isUnlocking.value,
onPressed: controller.unlock,
onTermsTap: controller.openUserTerms,
onPrivacyTap: controller.openPrivacyPolicy,
),
),
),
],
),
],
),
),
));
),
));
}
}
... ... @@ -256,7 +255,7 @@ class _LegalNotes extends StatelessWidget {
text: context.l10n.purchaseNoteSubscription,
linkText: '了解更多',
linkSuffix: '。',
onLinkTap: showRefundExplanationBottomSheet,
onLinkTap: controller.openRefundExplanation,
),
const SizedBox(height: 12),
_LegalText(
... ...
... ... @@ -26,7 +26,7 @@ class SleepReportView extends StatelessWidget {
final SleepReportLogic logic;
final bool isVip;
final VoidCallback onSubscribe;
final ValueChanged<String> onSubscribe;
@override
Widget build(BuildContext context) {
... ... @@ -48,7 +48,7 @@ class SleepReportView extends StatelessWidget {
lockedPeriods: isVip
? const {}
: const {ReportPeriod.week, ReportPeriod.month},
onLockedPeriodTap: (_) => onSubscribe(),
onLockedPeriodTap: (_) => onSubscribe('非会员切换月周-睡眠报告'),
),
),
const SizedBox(height: 8),
... ...
abstract final class IntentKeys {
static const String keyFrom = "from";
static const String channelType = "channel_type";
}
... ...