Showing
13 changed files
with
259 additions
and
99 deletions
| @@ -25,7 +25,7 @@ class ActivityBurnReportView extends StatelessWidget { | @@ -25,7 +25,7 @@ class ActivityBurnReportView extends StatelessWidget { | ||
| 25 | 25 | ||
| 26 | final ActivityBurnReportLogic logic; | 26 | final ActivityBurnReportLogic logic; |
| 27 | final bool isVip; | 27 | final bool isVip; |
| 28 | - final VoidCallback onSubscribe; | 28 | + final ValueChanged<String> onSubscribe; |
| 29 | 29 | ||
| 30 | @override | 30 | @override |
| 31 | Widget build(BuildContext context) { | 31 | Widget build(BuildContext context) { |
| @@ -47,7 +47,7 @@ class ActivityBurnReportView extends StatelessWidget { | @@ -47,7 +47,7 @@ class ActivityBurnReportView extends StatelessWidget { | ||
| 47 | lockedPeriods: isVip | 47 | lockedPeriods: isVip |
| 48 | ? const {} | 48 | ? const {} |
| 49 | : const {ReportPeriod.week, ReportPeriod.month}, | 49 | : const {ReportPeriod.week, ReportPeriod.month}, |
| 50 | - onLockedPeriodTap: (_) => onSubscribe(), | 50 | + onLockedPeriodTap: (_) => onSubscribe('非会员切换月周-活动消耗'), |
| 51 | ), | 51 | ), |
| 52 | ), | 52 | ), |
| 53 | const SizedBox(height: 8), | 53 | const SizedBox(height: 8), |
| 1 | import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'; | 1 | import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'; |
| 2 | import 'package:get/get.dart'; | 2 | import 'package:get/get.dart'; |
| 3 | 3 | ||
| 4 | +import '../../health_trend/controllers/health_trend_analytics.dart'; | ||
| 4 | import '../../health_trend/controllers/health_trend_control.dart'; | 5 | import '../../health_trend/controllers/health_trend_control.dart'; |
| 5 | import '../../report_common/models/health_report_query.dart'; | 6 | import '../../report_common/models/health_report_query.dart'; |
| 6 | import '../../report_common/models/report_period.dart'; | 7 | import '../../report_common/models/report_period.dart'; |
| @@ -32,6 +33,7 @@ class FriendTrendController extends GetxController with HealthTrendControl { | @@ -32,6 +33,7 @@ class FriendTrendController extends GetxController with HealthTrendControl { | ||
| 32 | } | 33 | } |
| 33 | 34 | ||
| 34 | final FriendItem? friendItem; | 35 | final FriendItem? friendItem; |
| 36 | + bool _isPageReady = false; | ||
| 35 | 37 | ||
| 36 | HealthReportQuery get query => HealthReportQuery( | 38 | HealthReportQuery get query => HealthReportQuery( |
| 37 | targetUserId: friendItem?.friendUserId, | 39 | targetUserId: friendItem?.friendUserId, |
| @@ -44,4 +46,27 @@ class FriendTrendController extends GetxController with HealthTrendControl { | @@ -44,4 +46,27 @@ class FriendTrendController extends GetxController with HealthTrendControl { | ||
| 44 | period: periodForType(typeIndex), | 46 | period: periodForType(typeIndex), |
| 45 | date: dateForType(typeIndex), | 47 | date: dateForType(typeIndex), |
| 46 | ); | 48 | ); |
| 49 | + | ||
| 50 | + @override | ||
| 51 | + void onReady() { | ||
| 52 | + super.onReady(); | ||
| 53 | + _isPageReady = true; | ||
| 54 | + _trackEnterPage(); | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + @override | ||
| 58 | + void changeType(int index) { | ||
| 59 | + final previousIndex = selectedTypeIndex.value; | ||
| 60 | + super.changeType(index); | ||
| 61 | + if (_isPageReady && selectedTypeIndex.value != previousIndex) { | ||
| 62 | + _trackEnterPage(); | ||
| 63 | + } | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + void _trackEnterPage() { | ||
| 67 | + HealthTrendAnalytics.trackEnterPage( | ||
| 68 | + selectedTypeIndex.value, | ||
| 69 | + userRole: '好友的', | ||
| 70 | + ); | ||
| 71 | + } | ||
| 47 | } | 72 | } |
| @@ -8,6 +8,7 @@ import 'package:doublefeel_flutter/core/error/app_error.dart'; | @@ -8,6 +8,7 @@ import 'package:doublefeel_flutter/core/error/app_error.dart'; | ||
| 8 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; | 8 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; |
| 9 | import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; | 9 | import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; |
| 10 | import 'package:doublefeel_flutter/core/network/api/health_api.dart'; | 10 | import 'package:doublefeel_flutter/core/network/api/health_api.dart'; |
| 11 | +import 'package:doublefeel_flutter/core/services/thinking_data_service.dart'; | ||
| 11 | import 'package:doublefeel_flutter/core/util/app_toast.dart'; | 12 | import 'package:doublefeel_flutter/core/util/app_toast.dart'; |
| 12 | import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; | 13 | import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; |
| 13 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 14 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| @@ -43,6 +44,7 @@ class FriendsController extends GetxController { | @@ -43,6 +44,7 @@ class FriendsController extends GetxController { | ||
| 43 | final isSelfHealthLoading = false.obs; | 44 | final isSelfHealthLoading = false.obs; |
| 44 | Future<void>? _friendsRequest; | 45 | Future<void>? _friendsRequest; |
| 45 | Future<void>? _selfHealthRequest; | 46 | Future<void>? _selfHealthRequest; |
| 47 | + bool _isPageVisible = false; | ||
| 46 | 48 | ||
| 47 | bool get isFull => friends.length >= maxFriends; | 49 | bool get isFull => friends.length >= maxFriends; |
| 48 | 50 | ||
| @@ -52,6 +54,16 @@ class FriendsController extends GetxController { | @@ -52,6 +54,16 @@ class FriendsController extends GetxController { | ||
| 52 | unawaited(refreshData()); | 54 | unawaited(refreshData()); |
| 53 | } | 55 | } |
| 54 | 56 | ||
| 57 | + void markPageVisible() { | ||
| 58 | + if (_isPageVisible) return; | ||
| 59 | + _isPageVisible = true; | ||
| 60 | + ta.track('enter_doublefeel_friend_page'); | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + void markPageHidden() { | ||
| 64 | + _isPageVisible = false; | ||
| 65 | + } | ||
| 66 | + | ||
| 55 | Future<void> refreshData() { | 67 | Future<void> refreshData() { |
| 56 | return Future.wait([loadFriends(), loadSelfHealth()]); | 68 | return Future.wait([loadFriends(), loadSelfHealth()]); |
| 57 | } | 69 | } |
| @@ -246,7 +246,7 @@ class FriendsTab extends GetView<FriendsController> { | @@ -246,7 +246,7 @@ class FriendsTab extends GetView<FriendsController> { | ||
| 246 | final String name = type.toString(); | 246 | final String name = type.toString(); |
| 247 | await Get.toNamed( | 247 | await Get.toNamed( |
| 248 | AppRoutes.bindPartner, | 248 | AppRoutes.bindPartner, |
| 249 | - arguments: {IntentKeys.keyFrom: name}, | 249 | + arguments: {IntentKeys.keyFrom: name, IntentKeys.channelType: '好友页'}, |
| 250 | ); | 250 | ); |
| 251 | await controller.loadFriends(); | 251 | await controller.loadFriends(); |
| 252 | } | 252 | } |
| 1 | +import 'package:doublefeel_flutter/core/services/thinking_data_service.dart'; | ||
| 2 | + | ||
| 3 | +class HealthTrendAnalytics { | ||
| 4 | + const HealthTrendAnalytics._(); | ||
| 5 | + | ||
| 6 | + static void trackEnterPage(int typeIndex, {required String userRole}) { | ||
| 7 | + final eventName = switch (typeIndex) { | ||
| 8 | + 1 => 'enter_doublefeel_activity_cost_page', | ||
| 9 | + 2 => 'enter_doublefeel_sleep_report_page', | ||
| 10 | + _ => 'enter_doublefeel_hrv_page', | ||
| 11 | + }; | ||
| 12 | + ta.track( | ||
| 13 | + eventName, | ||
| 14 | + properties: {'user_role': userRole}, | ||
| 15 | + ); | ||
| 16 | + } | ||
| 17 | +} |
| 1 | import 'package:flutter/material.dart'; | 1 | import 'package:flutter/material.dart'; |
| 2 | import 'package:get/get.dart'; | 2 | import 'package:get/get.dart'; |
| 3 | 3 | ||
| 4 | +import '../../../../core/constants/intent_keys.dart'; | ||
| 4 | import '../../../../data/local/user_preferences_storage.dart'; | 5 | import '../../../../data/local/user_preferences_storage.dart'; |
| 5 | import '../../../routes/app_pages.dart'; | 6 | import '../../../routes/app_pages.dart'; |
| 6 | import '../../activity_burn_report/controllers/activity_burn_report_logic.dart'; | 7 | import '../../activity_burn_report/controllers/activity_burn_report_logic.dart'; |
| @@ -99,7 +100,10 @@ class _HealthTrendContentState extends State<HealthTrendContent> | @@ -99,7 +100,10 @@ class _HealthTrendContentState extends State<HealthTrendContent> | ||
| 99 | final preferences = userPrefs.preferences.value; | 100 | final preferences = userPrefs.preferences.value; |
| 100 | final vipInfo = preferences.vipInfo; | 101 | final vipInfo = preferences.vipInfo; |
| 101 | final isVip = vipInfo?.isVip ?? false; | 102 | final isVip = vipInfo?.isVip ?? false; |
| 102 | - void openPurchase() => Get.toNamed(Routes.PURCHASE); | 103 | + void openPurchase(String channelType) => Get.toNamed( |
| 104 | + Routes.PURCHASE, | ||
| 105 | + arguments: {IntentKeys.channelType: channelType}, | ||
| 106 | + ); | ||
| 103 | 107 | ||
| 104 | return HealthReportSubjectScope( | 108 | return HealthReportSubjectScope( |
| 105 | isSelf: widget.query.isSelf, | 109 | isSelf: widget.query.isSelf, |
| @@ -158,7 +162,7 @@ class _HrvTrendSection extends StatefulWidget { | @@ -158,7 +162,7 @@ class _HrvTrendSection extends StatefulWidget { | ||
| 158 | 162 | ||
| 159 | final HealthReportQuery query; | 163 | final HealthReportQuery query; |
| 160 | final bool isVip; | 164 | final bool isVip; |
| 161 | - final VoidCallback onSubscribe; | 165 | + final ValueChanged<String> onSubscribe; |
| 162 | final ValueChanged<ReportPeriod> onPeriodChanged; | 166 | final ValueChanged<ReportPeriod> onPeriodChanged; |
| 163 | final ValueChanged<DateTime> onDateChanged; | 167 | final ValueChanged<DateTime> onDateChanged; |
| 164 | 168 | ||
| @@ -231,7 +235,7 @@ class _ActivityBurnTrendSection extends StatefulWidget { | @@ -231,7 +235,7 @@ class _ActivityBurnTrendSection extends StatefulWidget { | ||
| 231 | 235 | ||
| 232 | final HealthReportQuery query; | 236 | final HealthReportQuery query; |
| 233 | final bool isVip; | 237 | final bool isVip; |
| 234 | - final VoidCallback onSubscribe; | 238 | + final ValueChanged<String> onSubscribe; |
| 235 | final ValueChanged<ReportPeriod> onPeriodChanged; | 239 | final ValueChanged<ReportPeriod> onPeriodChanged; |
| 236 | final ValueChanged<DateTime> onDateChanged; | 240 | final ValueChanged<DateTime> onDateChanged; |
| 237 | 241 | ||
| @@ -307,7 +311,7 @@ class _SleepTrendSection extends StatefulWidget { | @@ -307,7 +311,7 @@ class _SleepTrendSection extends StatefulWidget { | ||
| 307 | 311 | ||
| 308 | final HealthReportQuery query; | 312 | final HealthReportQuery query; |
| 309 | final bool isVip; | 313 | final bool isVip; |
| 310 | - final VoidCallback onSubscribe; | 314 | + final ValueChanged<String> onSubscribe; |
| 311 | final ValueChanged<ReportPeriod> onPeriodChanged; | 315 | final ValueChanged<ReportPeriod> onPeriodChanged; |
| 312 | final ValueChanged<DateTime> onDateChanged; | 316 | final ValueChanged<DateTime> onDateChanged; |
| 313 | 317 |
| 1 | import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart'; | 1 | import 'package:doublefeel_flutter/app/modules/friends/controllers/friend_trend_controller.dart'; |
| 2 | +import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart'; | ||
| 2 | import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; | 3 | import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; |
| 3 | -import 'package:doublefeel_flutter/core/network/api/user_api.dart'; | ||
| 4 | import 'package:doublefeel_flutter/core/result/app_result.dart'; | 4 | import 'package:doublefeel_flutter/core/result/app_result.dart'; |
| 5 | import 'package:doublefeel_flutter/core/services/user_state_service.dart'; | 5 | import 'package:doublefeel_flutter/core/services/user_state_service.dart'; |
| 6 | import 'package:doublefeel_flutter/data/local/local_storage.dart'; | 6 | import 'package:doublefeel_flutter/data/local/local_storage.dart'; |
| @@ -20,6 +20,7 @@ import 'trend/trend_controller.dart'; | @@ -20,6 +20,7 @@ import 'trend/trend_controller.dart'; | ||
| 20 | 20 | ||
| 21 | class HomeController extends GetxController { | 21 | class HomeController extends GetxController { |
| 22 | static const trendTabIndex = 1; | 22 | static const trendTabIndex = 1; |
| 23 | + static const friendsTabIndex = 2; | ||
| 23 | static const _mainChannel = MethodChannel('doublefeel_flutter_main_channel'); | 24 | static const _mainChannel = MethodChannel('doublefeel_flutter_main_channel'); |
| 24 | 25 | ||
| 25 | final AppReviewPromptLogic _appReviewPromptLogic = AppReviewPromptLogic( | 26 | final AppReviewPromptLogic _appReviewPromptLogic = AppReviewPromptLogic( |
| @@ -106,7 +107,18 @@ class HomeController extends GetxController { | @@ -106,7 +107,18 @@ class HomeController extends GetxController { | ||
| 106 | } | 107 | } |
| 107 | 108 | ||
| 108 | void changeTab(int index) { | 109 | void changeTab(int index) { |
| 110 | + final previousIndex = selectedIndex.value; | ||
| 109 | selectedIndex.value = index; | 111 | selectedIndex.value = index; |
| 112 | + if (index == trendTabIndex && previousIndex != trendTabIndex) { | ||
| 113 | + Get.find<TrendController>().markPageVisible(); | ||
| 114 | + } else if (previousIndex == trendTabIndex && index != trendTabIndex) { | ||
| 115 | + Get.find<TrendController>().markPageHidden(); | ||
| 116 | + } | ||
| 117 | + if (index == friendsTabIndex && previousIndex != friendsTabIndex) { | ||
| 118 | + Get.find<FriendsController>().markPageVisible(); | ||
| 119 | + } else if (previousIndex == friendsTabIndex && index != friendsTabIndex) { | ||
| 120 | + Get.find<FriendsController>().markPageHidden(); | ||
| 121 | + } | ||
| 110 | } | 122 | } |
| 111 | 123 | ||
| 112 | void openTrend(TrendType type, {DateTime? date, ReportPeriod? period}) { | 124 | void openTrend(TrendType type, {DateTime? date, ReportPeriod? period}) { |
| @@ -199,21 +211,16 @@ class HomeController extends GetxController { | @@ -199,21 +211,16 @@ class HomeController extends GetxController { | ||
| 199 | } | 211 | } |
| 200 | break; | 212 | break; |
| 201 | case 'trend': | 213 | case 'trend': |
| 202 | - changeTab(trendTabIndex); | ||
| 203 | final type = params['type']; | 214 | final type = params['type']; |
| 204 | - if (type != null) { | ||
| 205 | - SchedulerBinding.instance.addPostFrameCallback((_) { | ||
| 206 | - TrendType? trendType = getTrendType(type); | ||
| 207 | - if (trendType != null) { | ||
| 208 | - openTrend( | ||
| 209 | - trendType, | ||
| 210 | - ); | ||
| 211 | - } | ||
| 212 | - }); | 215 | + final trendType = type == null ? null : getTrendType(type); |
| 216 | + if (trendType != null) { | ||
| 217 | + openTrend(trendType); | ||
| 218 | + } else { | ||
| 219 | + changeTab(trendTabIndex); | ||
| 213 | } | 220 | } |
| 214 | break; | 221 | break; |
| 215 | case 'friends': | 222 | case 'friends': |
| 216 | - changeTab(2); | 223 | + changeTab(friendsTabIndex); |
| 217 | break; | 224 | break; |
| 218 | case 'my': | 225 | case 'my': |
| 219 | changeTab(3); | 226 | changeTab(3); |
| 1 | import 'package:get/get.dart'; | 1 | import 'package:get/get.dart'; |
| 2 | 2 | ||
| 3 | +import '../../../health_trend/controllers/health_trend_analytics.dart'; | ||
| 3 | import '../../../health_trend/controllers/health_trend_control.dart'; | 4 | import '../../../health_trend/controllers/health_trend_control.dart'; |
| 4 | import '../../../report_common/models/health_report_query.dart'; | 5 | import '../../../report_common/models/health_report_query.dart'; |
| 5 | import '../../../report_common/models/report_period.dart'; | 6 | import '../../../report_common/models/report_period.dart'; |
| @@ -15,6 +16,8 @@ enum TrendType { | @@ -15,6 +16,8 @@ enum TrendType { | ||
| 15 | /// 趋势页顶层 Controller,仅负责: | 16 | /// 趋势页顶层 Controller,仅负责: |
| 16 | /// 顶层 HRV / 活动 / 睡眠 类型切换 (selectedTypeIndex) | 17 | /// 顶层 HRV / 活动 / 睡眠 类型切换 (selectedTypeIndex) |
| 17 | class TrendController extends GetxController with HealthTrendControl { | 18 | class TrendController extends GetxController with HealthTrendControl { |
| 19 | + bool _isPageVisible = false; | ||
| 20 | + | ||
| 18 | // 当前查看的用户。null 表示查看自己;非 null 表示查看指定用户。 | 21 | // 当前查看的用户。null 表示查看自己;非 null 表示查看指定用户。 |
| 19 | final targetUserId = RxnInt(); | 22 | final targetUserId = RxnInt(); |
| 20 | 23 | ||
| @@ -30,6 +33,15 @@ class TrendController extends GetxController with HealthTrendControl { | @@ -30,6 +33,15 @@ class TrendController extends GetxController with HealthTrendControl { | ||
| 30 | date: dateForType(typeIndex), | 33 | date: dateForType(typeIndex), |
| 31 | ); | 34 | ); |
| 32 | 35 | ||
| 36 | + @override | ||
| 37 | + void changeType(int index) { | ||
| 38 | + final previousIndex = selectedTypeIndex.value; | ||
| 39 | + super.changeType(index); | ||
| 40 | + if (_isPageVisible && selectedTypeIndex.value != previousIndex) { | ||
| 41 | + _trackEnterPage(); | ||
| 42 | + } | ||
| 43 | + } | ||
| 44 | + | ||
| 33 | void selectType(TrendType type, {DateTime? date, ReportPeriod? period}) { | 45 | void selectType(TrendType type, {DateTime? date, ReportPeriod? period}) { |
| 34 | changeType(type.tabIndex); | 46 | changeType(type.tabIndex); |
| 35 | if (period != null) { | 47 | if (period != null) { |
| @@ -44,4 +56,21 @@ class TrendController extends GetxController with HealthTrendControl { | @@ -44,4 +56,21 @@ class TrendController extends GetxController with HealthTrendControl { | ||
| 44 | if (userId == targetUserId.value) return; | 56 | if (userId == targetUserId.value) return; |
| 45 | targetUserId.value = userId; | 57 | targetUserId.value = userId; |
| 46 | } | 58 | } |
| 59 | + | ||
| 60 | + void markPageVisible() { | ||
| 61 | + if (_isPageVisible) return; | ||
| 62 | + _isPageVisible = true; | ||
| 63 | + _trackEnterPage(); | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + void markPageHidden() { | ||
| 67 | + _isPageVisible = false; | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + void _trackEnterPage() { | ||
| 71 | + HealthTrendAnalytics.trackEnterPage( | ||
| 72 | + selectedTypeIndex.value, | ||
| 73 | + userRole: '我的', | ||
| 74 | + ); | ||
| 75 | + } | ||
| 47 | } | 76 | } |
| @@ -22,7 +22,7 @@ class HrvReportView extends StatelessWidget { | @@ -22,7 +22,7 @@ class HrvReportView extends StatelessWidget { | ||
| 22 | 22 | ||
| 23 | final HrvReportLogic logic; | 23 | final HrvReportLogic logic; |
| 24 | final bool isVip; | 24 | final bool isVip; |
| 25 | - final VoidCallback onSubscribe; | 25 | + final ValueChanged<String> onSubscribe; |
| 26 | 26 | ||
| 27 | @override | 27 | @override |
| 28 | Widget build(BuildContext context) { | 28 | Widget build(BuildContext context) { |
| @@ -44,20 +44,24 @@ class HrvReportView extends StatelessWidget { | @@ -44,20 +44,24 @@ class HrvReportView extends StatelessWidget { | ||
| 44 | lockedPeriods: isVip | 44 | lockedPeriods: isVip |
| 45 | ? const {} | 45 | ? const {} |
| 46 | : const {ReportPeriod.month, ReportPeriod.year}, | 46 | : const {ReportPeriod.month, ReportPeriod.year}, |
| 47 | - onLockedPeriodTap: (_) => onSubscribe(), | 47 | + onLockedPeriodTap: (_) => onSubscribe('非会员切换年月-HRV心率'), |
| 48 | ), | 48 | ), |
| 49 | ), | 49 | ), |
| 50 | const SizedBox(height: 8), | 50 | const SizedBox(height: 8), |
| 51 | Obx( | 51 | Obx( |
| 52 | () => ReportDateSwitcher( | 52 | () => ReportDateSwitcher( |
| 53 | label: logic.dateLabel, | 53 | label: logic.dateLabel, |
| 54 | - onPrevious: isVip ? logic.previousDay : onSubscribe, | ||
| 55 | - onNext: isVip ? logic.nextDay : onSubscribe, | 54 | + onPrevious: isVip |
| 55 | + ? logic.previousDay | ||
| 56 | + : () => onSubscribe('非会员切换日期-HRV心率'), | ||
| 57 | + onNext: isVip | ||
| 58 | + ? logic.nextDay | ||
| 59 | + : () => onSubscribe('非会员切换日期-HRV心率'), | ||
| 56 | canPrevious: logic.canGoPrevious, | 60 | canPrevious: logic.canGoPrevious, |
| 57 | canNext: logic.canGoNext, | 61 | canNext: logic.canGoNext, |
| 58 | onTapLabel: isVip | 62 | onTapLabel: isVip |
| 59 | ? () => _showDatePicker(context) | 63 | ? () => _showDatePicker(context) |
| 60 | - : onSubscribe, | 64 | + : () => onSubscribe('非会员切换日期-HRV心率'), |
| 61 | ), | 65 | ), |
| 62 | ), | 66 | ), |
| 63 | ], | 67 | ], |
| @@ -82,7 +86,7 @@ class HrvReportView extends StatelessWidget { | @@ -82,7 +86,7 @@ class HrvReportView extends StatelessWidget { | ||
| 82 | HrvWeekReportView( | 86 | HrvWeekReportView( |
| 83 | report: logic.weeklyReport.value, | 87 | report: logic.weeklyReport.value, |
| 84 | showExample: !isVip, | 88 | showExample: !isVip, |
| 85 | - onSubscribe: onSubscribe, | 89 | + onSubscribe: () => onSubscribe('解锁每日压力-HRV心率'), |
| 86 | ), | 90 | ), |
| 87 | ReportBottomSlogan(bottomSpacing: isVip ? 16 : 88), | 91 | ReportBottomSlogan(bottomSpacing: isVip ? 16 : 88), |
| 88 | ], | 92 | ], |
| @@ -100,7 +104,9 @@ class HrvReportView extends StatelessWidget { | @@ -100,7 +104,9 @@ class HrvReportView extends StatelessWidget { | ||
| 100 | right: 0, | 104 | right: 0, |
| 101 | bottom: MediaQuery.paddingOf(context).bottom + 24, | 105 | bottom: MediaQuery.paddingOf(context).bottom + 24, |
| 102 | child: Center( | 106 | child: Center( |
| 103 | - child: _UnlockButton(onPressed: onSubscribe), | 107 | + child: _UnlockButton( |
| 108 | + onPressed: () => onSubscribe('解锁每日压力-HRV心率'), | ||
| 109 | + ), | ||
| 104 | ), | 110 | ), |
| 105 | ) | 111 | ) |
| 106 | : const SizedBox.shrink(), | 112 | : const SizedBox.shrink(), |
| @@ -2,9 +2,11 @@ import 'dart:async'; | @@ -2,9 +2,11 @@ import 'dart:async'; | ||
| 2 | 2 | ||
| 3 | import 'package:doublefeel_flutter/app/routes/app_pages.dart'; | 3 | import 'package:doublefeel_flutter/app/routes/app_pages.dart'; |
| 4 | import 'package:doublefeel_flutter/core/constants/app_const.dart'; | 4 | import 'package:doublefeel_flutter/core/constants/app_const.dart'; |
| 5 | +import 'package:doublefeel_flutter/core/constants/intent_keys.dart'; | ||
| 5 | import 'package:doublefeel_flutter/core/network/api/pay_api.dart'; | 6 | import 'package:doublefeel_flutter/core/network/api/pay_api.dart'; |
| 6 | import 'package:doublefeel_flutter/core/network/api/vip_api.dart'; | 7 | import 'package:doublefeel_flutter/core/network/api/vip_api.dart'; |
| 7 | import 'package:doublefeel_flutter/core/result/app_result.dart'; | 8 | import 'package:doublefeel_flutter/core/result/app_result.dart'; |
| 9 | +import 'package:doublefeel_flutter/core/services/thinking_data_service.dart'; | ||
| 8 | import 'package:doublefeel_flutter/core/util/app_toast.dart'; | 10 | import 'package:doublefeel_flutter/core/util/app_toast.dart'; |
| 9 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; | 11 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; |
| 10 | import 'package:doublefeel_flutter/data/models/local/user_preferences.dart'; | 12 | import 'package:doublefeel_flutter/data/models/local/user_preferences.dart'; |
| @@ -15,6 +17,8 @@ import 'package:doublefeel_flutter/pigeon/platform_api.g.dart'; | @@ -15,6 +17,8 @@ import 'package:doublefeel_flutter/pigeon/platform_api.g.dart'; | ||
| 15 | import 'package:flutter/material.dart'; | 17 | import 'package:flutter/material.dart'; |
| 16 | import 'package:get/get.dart'; | 18 | import 'package:get/get.dart'; |
| 17 | 19 | ||
| 20 | +import '../widgets/refund_explanation_bottom_sheet.dart'; | ||
| 21 | + | ||
| 18 | class PurchasePlan { | 22 | class PurchasePlan { |
| 19 | const PurchasePlan({ | 23 | const PurchasePlan({ |
| 20 | required this.title, | 24 | required this.title, |
| @@ -54,19 +58,29 @@ class PurchaseController extends GetxController { | @@ -54,19 +58,29 @@ class PurchaseController extends GetxController { | ||
| 54 | final isRestoring = false.obs; | 58 | final isRestoring = false.obs; |
| 55 | 59 | ||
| 56 | bool isFromOnboard = false; | 60 | bool isFromOnboard = false; |
| 61 | + String channelType = ''; | ||
| 57 | 62 | ||
| 58 | @override | 63 | @override |
| 59 | void onInit() { | 64 | void onInit() { |
| 60 | super.onInit(); | 65 | super.onInit(); |
| 61 | final args = Get.arguments; | 66 | final args = Get.arguments; |
| 62 | if (args is Map) { | 67 | if (args is Map) { |
| 63 | - isFromOnboard = args['from'] == 'onboarding'; | 68 | + isFromOnboard = args[IntentKeys.keyFrom] == 'onboarding'; |
| 69 | + channelType = _nonEmpty(_stringArgument(args, IntentKeys.channelType)) ?? | ||
| 70 | + _nonEmpty(_stringArgument(args, 'channelType')) ?? | ||
| 71 | + (isFromOnboard ? '新手引导' : ''); | ||
| 64 | } | 72 | } |
| 65 | scrollController.addListener(_handleScroll); | 73 | scrollController.addListener(_handleScroll); |
| 66 | unawaited(loadProductList()); | 74 | unawaited(loadProductList()); |
| 67 | } | 75 | } |
| 68 | 76 | ||
| 69 | @override | 77 | @override |
| 78 | + void onReady() { | ||
| 79 | + super.onReady(); | ||
| 80 | + _trackEnterVipCenter(); | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + @override | ||
| 70 | void onClose() { | 84 | void onClose() { |
| 71 | planScrollController.dispose(); | 85 | planScrollController.dispose(); |
| 72 | scrollController | 86 | scrollController |
| @@ -108,6 +122,7 @@ class PurchaseController extends GetxController { | @@ -108,6 +122,7 @@ class PurchaseController extends GetxController { | ||
| 108 | Future<void> restorePurchase() async { | 122 | Future<void> restorePurchase() async { |
| 109 | if (isRestoring.value || isUnlocking.value) return; | 123 | if (isRestoring.value || isUnlocking.value) return; |
| 110 | 124 | ||
| 125 | + _trackClickVipCenter('恢复购买'); | ||
| 111 | isRestoring.value = true; | 126 | isRestoring.value = true; |
| 112 | try { | 127 | try { |
| 113 | final success = await _performRestore(); | 128 | final success = await _performRestore(); |
| @@ -122,6 +137,7 @@ class PurchaseController extends GetxController { | @@ -122,6 +137,7 @@ class PurchaseController extends GetxController { | ||
| 122 | Future<void> unlock() async { | 137 | Future<void> unlock() async { |
| 123 | if (isUnlocking.value || isRestoring.value) return; | 138 | if (isUnlocking.value || isRestoring.value) return; |
| 124 | 139 | ||
| 140 | + _trackClickVipCenter('立即购买'); | ||
| 125 | final plan = _selectedPlan; | 141 | final plan = _selectedPlan; |
| 126 | final productId = plan?.product?.id; | 142 | final productId = plan?.product?.id; |
| 127 | final appleProductId = plan?.appleProductId; | 143 | final appleProductId = plan?.appleProductId; |
| @@ -228,6 +244,7 @@ class PurchaseController extends GetxController { | @@ -228,6 +244,7 @@ class PurchaseController extends GetxController { | ||
| 228 | AppleProductPaymentResult? result, | 244 | AppleProductPaymentResult? result, |
| 229 | ) async { | 245 | ) async { |
| 230 | if (result?.success == true) { | 246 | if (result?.success == true) { |
| 247 | + _trackSuccessPayOrder(); | ||
| 231 | await _completeSuccessfulPurchase(); | 248 | await _completeSuccessfulPurchase(); |
| 232 | return; | 249 | return; |
| 233 | } | 250 | } |
| @@ -349,7 +366,18 @@ class PurchaseController extends GetxController { | @@ -349,7 +366,18 @@ class PurchaseController extends GetxController { | ||
| 349 | return trimmed; | 366 | return trimmed; |
| 350 | } | 367 | } |
| 351 | 368 | ||
| 369 | + String? _stringArgument(Map args, String key) { | ||
| 370 | + final value = args[key]; | ||
| 371 | + return value is String ? value : null; | ||
| 372 | + } | ||
| 373 | + | ||
| 374 | + void openRefundExplanation() { | ||
| 375 | + _trackClickVipCenter('了解更多'); | ||
| 376 | + showRefundExplanationBottomSheet(); | ||
| 377 | + } | ||
| 378 | + | ||
| 352 | void openContactUs() { | 379 | void openContactUs() { |
| 380 | + _trackClickVipCenter('联系我们'); | ||
| 353 | Get.toNamed(Routes.SUBMIT_FEEDBACK); | 381 | Get.toNamed(Routes.SUBMIT_FEEDBACK); |
| 354 | } | 382 | } |
| 355 | 383 | ||
| @@ -366,4 +394,36 @@ class PurchaseController extends GetxController { | @@ -366,4 +394,36 @@ class PurchaseController extends GetxController { | ||
| 366 | parameters: {'url': AppConst.privacyPolicy}, | 394 | parameters: {'url': AppConst.privacyPolicy}, |
| 367 | ); | 395 | ); |
| 368 | } | 396 | } |
| 397 | + | ||
| 398 | + Map<String, dynamic> get _baseAnalyticsProperties => | ||
| 399 | + {'channel_type': channelType}; | ||
| 400 | + | ||
| 401 | + void _trackEnterVipCenter() { | ||
| 402 | + ta.track( | ||
| 403 | + 'enter_doublefeel_vip_center', | ||
| 404 | + properties: _baseAnalyticsProperties, | ||
| 405 | + ); | ||
| 406 | + } | ||
| 407 | + | ||
| 408 | + void _trackClickVipCenter(String clickContent) { | ||
| 409 | + ta.track( | ||
| 410 | + 'click_doublefeel_vip_center', | ||
| 411 | + properties: { | ||
| 412 | + ..._baseAnalyticsProperties, | ||
| 413 | + 'click_content': clickContent, | ||
| 414 | + }, | ||
| 415 | + ); | ||
| 416 | + } | ||
| 417 | + | ||
| 418 | + void _trackSuccessPayOrder() { | ||
| 419 | + final plan = _selectedPlan; | ||
| 420 | + ta.track( | ||
| 421 | + 'success_doublefeel_pay_order', | ||
| 422 | + properties: { | ||
| 423 | + ..._baseAnalyticsProperties, | ||
| 424 | + 'product_type': plan?.product?.appleId, | ||
| 425 | + 'price': plan?.product?.price, | ||
| 426 | + }, | ||
| 427 | + ); | ||
| 428 | + } | ||
| 369 | } | 429 | } |
| @@ -10,7 +10,6 @@ import '../purchase_route_args.dart'; | @@ -10,7 +10,6 @@ import '../purchase_route_args.dart'; | ||
| 10 | import '../widgets/purchase_bottom_bar.dart'; | 10 | import '../widgets/purchase_bottom_bar.dart'; |
| 11 | import '../widgets/purchase_colors.dart'; | 11 | import '../widgets/purchase_colors.dart'; |
| 12 | import '../widgets/purchase_plan_card.dart'; | 12 | import '../widgets/purchase_plan_card.dart'; |
| 13 | -import '../widgets/refund_explanation_bottom_sheet.dart'; | ||
| 14 | 13 | ||
| 15 | class PurchaseView extends GetView<PurchaseController> { | 14 | class PurchaseView extends GetView<PurchaseController> { |
| 16 | const PurchaseView({super.key}); | 15 | const PurchaseView({super.key}); |
| @@ -18,79 +17,79 @@ class PurchaseView extends GetView<PurchaseController> { | @@ -18,79 +17,79 @@ class PurchaseView extends GetView<PurchaseController> { | ||
| 18 | @override | 17 | @override |
| 19 | Widget build(BuildContext context) { | 18 | Widget build(BuildContext context) { |
| 20 | return PopScope( | 19 | return PopScope( |
| 21 | - canPop: !controller.isFromOnboard, | ||
| 22 | - onPopInvokedWithResult: (didPop, _) { | ||
| 23 | - if (!didPop) controller.onBackPressed(); | ||
| 24 | - }, | ||
| 25 | - child: AnnotatedRegion<SystemUiOverlayStyle>( | ||
| 26 | - value: const SystemUiOverlayStyle( | ||
| 27 | - statusBarColor: Colors.transparent, | ||
| 28 | - statusBarIconBrightness: Brightness.dark, | ||
| 29 | - statusBarBrightness: Brightness.light, | ||
| 30 | - systemNavigationBarColor: PurchaseColors.background, | ||
| 31 | - systemNavigationBarIconBrightness: Brightness.dark, | ||
| 32 | - ), | ||
| 33 | - child: Scaffold( | ||
| 34 | - backgroundColor: PurchaseColors.background, | ||
| 35 | - body: Stack( | ||
| 36 | - children: [ | ||
| 37 | - Positioned.fill( | ||
| 38 | - child: SingleChildScrollView( | ||
| 39 | - controller: controller.scrollController, | ||
| 40 | - physics: const ClampingScrollPhysics(), | ||
| 41 | - padding: const EdgeInsets.only(bottom: 170), | ||
| 42 | - child: Column( | ||
| 43 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 44 | - children: [ | ||
| 45 | - const _HeroHeader(), | ||
| 46 | - Padding( | ||
| 47 | - padding: const EdgeInsets.only(left: 18, top: 9), | ||
| 48 | - child: Text( | ||
| 49 | - context.l10n.purchaseHeroTitle, | ||
| 50 | - style: const TextStyle( | ||
| 51 | - color: Colors.black, | ||
| 52 | - fontSize: 24, | ||
| 53 | - fontWeight: FontWeight.w600, | ||
| 54 | - height: 1.2, | 20 | + canPop: !controller.isFromOnboard, |
| 21 | + onPopInvokedWithResult: (didPop, _) { | ||
| 22 | + if (!didPop) controller.onBackPressed(); | ||
| 23 | + }, | ||
| 24 | + child: AnnotatedRegion<SystemUiOverlayStyle>( | ||
| 25 | + value: const SystemUiOverlayStyle( | ||
| 26 | + statusBarColor: Colors.transparent, | ||
| 27 | + statusBarIconBrightness: Brightness.dark, | ||
| 28 | + statusBarBrightness: Brightness.light, | ||
| 29 | + systemNavigationBarColor: PurchaseColors.background, | ||
| 30 | + systemNavigationBarIconBrightness: Brightness.dark, | ||
| 31 | + ), | ||
| 32 | + child: Scaffold( | ||
| 33 | + backgroundColor: PurchaseColors.background, | ||
| 34 | + body: Stack( | ||
| 35 | + children: [ | ||
| 36 | + Positioned.fill( | ||
| 37 | + child: SingleChildScrollView( | ||
| 38 | + controller: controller.scrollController, | ||
| 39 | + physics: const ClampingScrollPhysics(), | ||
| 40 | + padding: const EdgeInsets.only(bottom: 170), | ||
| 41 | + child: Column( | ||
| 42 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 43 | + children: [ | ||
| 44 | + const _HeroHeader(), | ||
| 45 | + Padding( | ||
| 46 | + padding: const EdgeInsets.only(left: 18, top: 9), | ||
| 47 | + child: Text( | ||
| 48 | + context.l10n.purchaseHeroTitle, | ||
| 49 | + style: const TextStyle( | ||
| 50 | + color: Colors.black, | ||
| 51 | + fontSize: 24, | ||
| 52 | + fontWeight: FontWeight.w600, | ||
| 53 | + height: 1.2, | ||
| 54 | + ), | ||
| 55 | + ), | ||
| 55 | ), | 56 | ), |
| 56 | - ), | 57 | + const SizedBox(height: 26), |
| 58 | + _PlanScroller(controller: controller), | ||
| 59 | + const SizedBox(height: 22), | ||
| 60 | + _SectionTitle(context.l10n.purchaseBenefitsTitle), | ||
| 61 | + const SizedBox(height: 14), | ||
| 62 | + const PremiumBenefitListView(), | ||
| 63 | + const SizedBox(height: 24), | ||
| 64 | + _LegalNotes(controller: controller), | ||
| 65 | + ], | ||
| 57 | ), | 66 | ), |
| 58 | - const SizedBox(height: 26), | ||
| 59 | - _PlanScroller(controller: controller), | ||
| 60 | - const SizedBox(height: 22), | ||
| 61 | - _SectionTitle(context.l10n.purchaseBenefitsTitle), | ||
| 62 | - const SizedBox(height: 14), | ||
| 63 | - const PremiumBenefitListView(), | ||
| 64 | - const SizedBox(height: 24), | ||
| 65 | - _LegalNotes(controller: controller), | ||
| 66 | - ], | 67 | + ), |
| 67 | ), | 68 | ), |
| 68 | - ), | ||
| 69 | - ), | ||
| 70 | - Positioned( | ||
| 71 | - left: 0, | ||
| 72 | - right: 0, | ||
| 73 | - top: 0, | ||
| 74 | - child: _PurchaseNavigationBar(controller: controller), | ||
| 75 | - ), | ||
| 76 | - Positioned( | ||
| 77 | - left: 0, | ||
| 78 | - right: 0, | ||
| 79 | - bottom: 0, | ||
| 80 | - child: Obx( | ||
| 81 | - () => PurchaseBottomBar( | ||
| 82 | - label: context.l10n.purchaseUnlockNow, | ||
| 83 | - loading: controller.isUnlocking.value, | ||
| 84 | - onPressed: controller.unlock, | ||
| 85 | - onTermsTap: controller.openUserTerms, | ||
| 86 | - onPrivacyTap: controller.openPrivacyPolicy, | 69 | + Positioned( |
| 70 | + left: 0, | ||
| 71 | + right: 0, | ||
| 72 | + top: 0, | ||
| 73 | + child: _PurchaseNavigationBar(controller: controller), | ||
| 87 | ), | 74 | ), |
| 88 | - ), | 75 | + Positioned( |
| 76 | + left: 0, | ||
| 77 | + right: 0, | ||
| 78 | + bottom: 0, | ||
| 79 | + child: Obx( | ||
| 80 | + () => PurchaseBottomBar( | ||
| 81 | + label: context.l10n.purchaseUnlockNow, | ||
| 82 | + loading: controller.isUnlocking.value, | ||
| 83 | + onPressed: controller.unlock, | ||
| 84 | + onTermsTap: controller.openUserTerms, | ||
| 85 | + onPrivacyTap: controller.openPrivacyPolicy, | ||
| 86 | + ), | ||
| 87 | + ), | ||
| 88 | + ), | ||
| 89 | + ], | ||
| 89 | ), | 90 | ), |
| 90 | - ], | ||
| 91 | - ), | ||
| 92 | - ), | ||
| 93 | - )); | 91 | + ), |
| 92 | + )); | ||
| 94 | } | 93 | } |
| 95 | } | 94 | } |
| 96 | 95 | ||
| @@ -256,7 +255,7 @@ class _LegalNotes extends StatelessWidget { | @@ -256,7 +255,7 @@ class _LegalNotes extends StatelessWidget { | ||
| 256 | text: context.l10n.purchaseNoteSubscription, | 255 | text: context.l10n.purchaseNoteSubscription, |
| 257 | linkText: '了解更多', | 256 | linkText: '了解更多', |
| 258 | linkSuffix: '。', | 257 | linkSuffix: '。', |
| 259 | - onLinkTap: showRefundExplanationBottomSheet, | 258 | + onLinkTap: controller.openRefundExplanation, |
| 260 | ), | 259 | ), |
| 261 | const SizedBox(height: 12), | 260 | const SizedBox(height: 12), |
| 262 | _LegalText( | 261 | _LegalText( |
| @@ -26,7 +26,7 @@ class SleepReportView extends StatelessWidget { | @@ -26,7 +26,7 @@ class SleepReportView extends StatelessWidget { | ||
| 26 | 26 | ||
| 27 | final SleepReportLogic logic; | 27 | final SleepReportLogic logic; |
| 28 | final bool isVip; | 28 | final bool isVip; |
| 29 | - final VoidCallback onSubscribe; | 29 | + final ValueChanged<String> onSubscribe; |
| 30 | 30 | ||
| 31 | @override | 31 | @override |
| 32 | Widget build(BuildContext context) { | 32 | Widget build(BuildContext context) { |
| @@ -48,7 +48,7 @@ class SleepReportView extends StatelessWidget { | @@ -48,7 +48,7 @@ class SleepReportView extends StatelessWidget { | ||
| 48 | lockedPeriods: isVip | 48 | lockedPeriods: isVip |
| 49 | ? const {} | 49 | ? const {} |
| 50 | : const {ReportPeriod.week, ReportPeriod.month}, | 50 | : const {ReportPeriod.week, ReportPeriod.month}, |
| 51 | - onLockedPeriodTap: (_) => onSubscribe(), | 51 | + onLockedPeriodTap: (_) => onSubscribe('非会员切换月周-睡眠报告'), |
| 52 | ), | 52 | ), |
| 53 | ), | 53 | ), |
| 54 | const SizedBox(height: 8), | 54 | const SizedBox(height: 8), |
-
Please register or login to post a comment