Showing
25 changed files
with
858 additions
and
336 deletions
assets/images/common/ic_trend_pro.webp
0 → 100644
No preview for this file type
| @@ -5,6 +5,7 @@ import '../../report_common/models/report_period.dart'; | @@ -5,6 +5,7 @@ import '../../report_common/models/report_period.dart'; | ||
| 5 | import '../../report_common/widgets/report_bottom_slogan.dart'; | 5 | import '../../report_common/widgets/report_bottom_slogan.dart'; |
| 6 | import '../../report_common/widgets/report_date_picker_sheet.dart'; | 6 | import '../../report_common/widgets/report_date_picker_sheet.dart'; |
| 7 | import '../../report_common/widgets/report_date_switcher.dart'; | 7 | import '../../report_common/widgets/report_date_switcher.dart'; |
| 8 | +import '../../report_common/widgets/report_loading_indicator.dart'; | ||
| 8 | import '../../report_common/widgets/report_period_tab_bar.dart'; | 9 | import '../../report_common/widgets/report_period_tab_bar.dart'; |
| 9 | import '../controllers/activity_burn_report_logic.dart'; | 10 | import '../controllers/activity_burn_report_logic.dart'; |
| 10 | import '../models/activity_burn_report_models.dart'; | 11 | import '../models/activity_burn_report_models.dart'; |
| @@ -18,9 +19,13 @@ class ActivityBurnReportView extends StatelessWidget { | @@ -18,9 +19,13 @@ class ActivityBurnReportView extends StatelessWidget { | ||
| 18 | const ActivityBurnReportView({ | 19 | const ActivityBurnReportView({ |
| 19 | super.key, | 20 | super.key, |
| 20 | required this.logic, | 21 | required this.logic, |
| 22 | + required this.isVip, | ||
| 23 | + required this.onSubscribe, | ||
| 21 | }); | 24 | }); |
| 22 | 25 | ||
| 23 | final ActivityBurnReportLogic logic; | 26 | final ActivityBurnReportLogic logic; |
| 27 | + final bool isVip; | ||
| 28 | + final VoidCallback onSubscribe; | ||
| 24 | 29 | ||
| 25 | @override | 30 | @override |
| 26 | Widget build(BuildContext context) { | 31 | Widget build(BuildContext context) { |
| @@ -39,6 +44,10 @@ class ActivityBurnReportView extends StatelessWidget { | @@ -39,6 +44,10 @@ class ActivityBurnReportView extends StatelessWidget { | ||
| 39 | () => ReportPeriodTabBar( | 44 | () => ReportPeriodTabBar( |
| 40 | selectedPeriod: logic.selectedPeriod.value, | 45 | selectedPeriod: logic.selectedPeriod.value, |
| 41 | onChanged: logic.selectPeriod, | 46 | onChanged: logic.selectPeriod, |
| 47 | + lockedPeriods: isVip | ||
| 48 | + ? const {} | ||
| 49 | + : const {ReportPeriod.week, ReportPeriod.month}, | ||
| 50 | + onLockedPeriodTap: (_) => onSubscribe(), | ||
| 42 | ), | 51 | ), |
| 43 | ), | 52 | ), |
| 44 | const SizedBox(height: 8), | 53 | const SizedBox(height: 8), |
| @@ -47,50 +56,39 @@ class ActivityBurnReportView extends StatelessWidget { | @@ -47,50 +56,39 @@ class ActivityBurnReportView extends StatelessWidget { | ||
| 47 | ), | 56 | ), |
| 48 | ), | 57 | ), |
| 49 | Expanded( | 58 | Expanded( |
| 50 | - child: CustomScrollView( | ||
| 51 | - physics: const ClampingScrollPhysics(), | ||
| 52 | - slivers: [ | ||
| 53 | - SliverPadding( | ||
| 54 | - padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), | ||
| 55 | - sliver: SliverList( | ||
| 56 | - delegate: SliverChildListDelegate([ | ||
| 57 | - Obx( | ||
| 58 | - () { | 59 | + child: Obx( |
| 60 | + () { | ||
| 61 | + if (logic.isLoading.value) { | ||
| 62 | + return const ReportLoadingIndicator(); | ||
| 63 | + } | ||
| 64 | + return CustomScrollView( | ||
| 65 | + physics: const ClampingScrollPhysics(), | ||
| 66 | + slivers: [ | ||
| 67 | + SliverPadding( | ||
| 68 | + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), | ||
| 69 | + sliver: SliverList( | ||
| 70 | + delegate: SliverChildListDelegate([ | ||
| 59 | if (logic.selectedPeriod.value == | 71 | if (logic.selectedPeriod.value == |
| 60 | - ReportPeriod.week) { | ||
| 61 | - return ActivityBurnWeekReportView( | 72 | + ReportPeriod.week) |
| 73 | + ActivityBurnWeekReportView( | ||
| 62 | report: logic.weeklyReport.value, | 74 | report: logic.weeklyReport.value, |
| 63 | weekStart: logic.weekStart, | 75 | weekStart: logic.weekStart, |
| 64 | - ); | ||
| 65 | - } | ||
| 66 | - if (logic.selectedPeriod.value == | ||
| 67 | - ReportPeriod.month) { | ||
| 68 | - return ActivityBurnMonthReportView( | 76 | + ) |
| 77 | + else if (logic.selectedPeriod.value == | ||
| 78 | + ReportPeriod.month) | ||
| 79 | + ActivityBurnMonthReportView( | ||
| 69 | report: logic.monthlyReport.value, | 80 | report: logic.monthlyReport.value, |
| 70 | monthStart: logic.monthStart, | 81 | monthStart: logic.monthStart, |
| 71 | - ); | ||
| 72 | - } | ||
| 73 | - | ||
| 74 | - final report = logic.report.value; | ||
| 75 | - return Column( | ||
| 76 | - children: [ | ||
| 77 | - ActivityBurnRing(report: report), | ||
| 78 | - const SizedBox(height: 24), | ||
| 79 | - ActivityBurnSummaryCard(report: report), | ||
| 80 | - const SizedBox(height: 12), | ||
| 81 | - ActivityBurnHeartRateZoneCard( | ||
| 82 | - summary: report?.heartRate ?? | ||
| 83 | - const ActivityBurnHeartRateSummary(), | ||
| 84 | - ), | ||
| 85 | - ], | ||
| 86 | - ); | ||
| 87 | - }, | 82 | + ) |
| 83 | + else | ||
| 84 | + _DailyReportContent(report: logic.report.value), | ||
| 85 | + const ReportBottomSlogan(), | ||
| 86 | + ]), | ||
| 88 | ), | 87 | ), |
| 89 | - const ReportBottomSlogan(), | ||
| 90 | - ]), | ||
| 91 | - ), | ||
| 92 | - ), | ||
| 93 | - ], | 88 | + ), |
| 89 | + ], | ||
| 90 | + ); | ||
| 91 | + }, | ||
| 94 | ), | 92 | ), |
| 95 | ), | 93 | ), |
| 96 | ], | 94 | ], |
| @@ -101,6 +99,27 @@ class ActivityBurnReportView extends StatelessWidget { | @@ -101,6 +99,27 @@ class ActivityBurnReportView extends StatelessWidget { | ||
| 101 | } | 99 | } |
| 102 | } | 100 | } |
| 103 | 101 | ||
| 102 | +class _DailyReportContent extends StatelessWidget { | ||
| 103 | + const _DailyReportContent({required this.report}); | ||
| 104 | + | ||
| 105 | + final ActivityBurnReport? report; | ||
| 106 | + | ||
| 107 | + @override | ||
| 108 | + Widget build(BuildContext context) { | ||
| 109 | + return Column( | ||
| 110 | + children: [ | ||
| 111 | + ActivityBurnRing(report: report), | ||
| 112 | + const SizedBox(height: 24), | ||
| 113 | + ActivityBurnSummaryCard(report: report), | ||
| 114 | + const SizedBox(height: 12), | ||
| 115 | + ActivityBurnHeartRateZoneCard( | ||
| 116 | + summary: report?.heartRate ?? const ActivityBurnHeartRateSummary(), | ||
| 117 | + ), | ||
| 118 | + ], | ||
| 119 | + ); | ||
| 120 | + } | ||
| 121 | +} | ||
| 122 | + | ||
| 104 | class _DateSwitcher extends StatelessWidget { | 123 | class _DateSwitcher extends StatelessWidget { |
| 105 | const _DateSwitcher({required this.logic}); | 124 | const _DateSwitcher({required this.logic}); |
| 106 | 125 |
| @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; | @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; | ||
| 5 | import 'package:intl/intl.dart'; | 5 | import 'package:intl/intl.dart'; |
| 6 | 6 | ||
| 7 | import '../../../../r.dart'; | 7 | import '../../../../r.dart'; |
| 8 | +import '../../report_common/widgets/health_report_subject_scope.dart'; | ||
| 8 | import '../models/activity_burn_report_models.dart'; | 9 | import '../models/activity_burn_report_models.dart'; |
| 9 | 10 | ||
| 10 | class ActivityBurnHeartRateZoneCard extends StatelessWidget { | 11 | class ActivityBurnHeartRateZoneCard extends StatelessWidget { |
| @@ -41,7 +42,10 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | @@ -41,7 +42,10 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { | ||
| 41 | crossAxisAlignment: CrossAxisAlignment.start, | 42 | crossAxisAlignment: CrossAxisAlignment.start, |
| 42 | children: [ | 43 | children: [ |
| 43 | Text( | 44 | Text( |
| 44 | - summary.hasData ? '心率区间' : '实时心率', | 45 | + HealthReportSubjectScope.titleOf( |
| 46 | + context, | ||
| 47 | + summary.hasData ? '心率区间' : '实时心率', | ||
| 48 | + ), | ||
| 45 | style: TextStyle( | 49 | style: TextStyle( |
| 46 | color: _h1, | 50 | color: _h1, |
| 47 | fontSize: 16, | 51 | fontSize: 16, |
| @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; | @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; | ||
| 3 | import 'package:intl/intl.dart'; | 3 | import 'package:intl/intl.dart'; |
| 4 | 4 | ||
| 5 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; | 5 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; |
| 6 | +import '../../report_common/widgets/health_report_subject_scope.dart'; | ||
| 6 | import '../../report_common/widgets/report_month_calendar_grid.dart'; | 7 | import '../../report_common/widgets/report_month_calendar_grid.dart'; |
| 7 | import '../models/activity_burn_report_models.dart'; | 8 | import '../models/activity_burn_report_models.dart'; |
| 8 | import 'activity_burn_ring.dart'; | 9 | import 'activity_burn_ring.dart'; |
| @@ -52,9 +53,9 @@ class _MonthlySummary extends StatelessWidget { | @@ -52,9 +53,9 @@ class _MonthlySummary extends StatelessWidget { | ||
| 52 | child: Column( | 53 | child: Column( |
| 53 | crossAxisAlignment: CrossAxisAlignment.start, | 54 | crossAxisAlignment: CrossAxisAlignment.start, |
| 54 | children: [ | 55 | children: [ |
| 55 | - const Text( | ||
| 56 | - '活动总消耗', | ||
| 57 | - style: TextStyle( | 56 | + Text( |
| 57 | + HealthReportSubjectScope.titleOf(context, '活动总消耗'), | ||
| 58 | + style: const TextStyle( | ||
| 58 | color: ActivityBurnMonthReportView._active, | 59 | color: ActivityBurnMonthReportView._active, |
| 59 | fontSize: 12, | 60 | fontSize: 12, |
| 60 | fontWeight: FontWeight.w600, | 61 | fontWeight: FontWeight.w600, |
| @@ -101,14 +102,14 @@ class _MonthlySummary extends StatelessWidget { | @@ -101,14 +102,14 @@ class _MonthlySummary extends StatelessWidget { | ||
| 101 | Row( | 102 | Row( |
| 102 | children: [ | 103 | children: [ |
| 103 | _SummaryMetric( | 104 | _SummaryMetric( |
| 104 | - title: '锻炼总时长', | 105 | + title: HealthReportSubjectScope.titleOf(context, '锻炼总时长'), |
| 105 | value: hasData ? report.totalExerciseMinutes.toString() : '-', | 106 | value: hasData ? report.totalExerciseMinutes.toString() : '-', |
| 106 | unit: '分钟', | 107 | unit: '分钟', |
| 107 | color: ActivityBurnMonthReportView._exercise, | 108 | color: ActivityBurnMonthReportView._exercise, |
| 108 | ), | 109 | ), |
| 109 | const SizedBox(width: 34), | 110 | const SizedBox(width: 34), |
| 110 | _SummaryMetric( | 111 | _SummaryMetric( |
| 111 | - title: '站立总时长', | 112 | + title: HealthReportSubjectScope.titleOf(context, '站立总时长'), |
| 112 | value: hasData ? report.totalStandHours.toString() : '-', | 113 | value: hasData ? report.totalStandHours.toString() : '-', |
| 113 | unit: '小时', | 114 | unit: '小时', |
| 114 | color: ActivityBurnMonthReportView._stand, | 115 | color: ActivityBurnMonthReportView._stand, |
| @@ -416,9 +417,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | @@ -416,9 +417,9 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { | ||
| 416 | child: Column( | 417 | child: Column( |
| 417 | crossAxisAlignment: CrossAxisAlignment.start, | 418 | crossAxisAlignment: CrossAxisAlignment.start, |
| 418 | children: [ | 419 | children: [ |
| 419 | - const Text( | ||
| 420 | - '热量消耗趋势', | ||
| 421 | - style: TextStyle( | 420 | + Text( |
| 421 | + HealthReportSubjectScope.titleOf(context, '热量消耗趋势'), | ||
| 422 | + style: const TextStyle( | ||
| 422 | color: ActivityBurnMonthReportView._h1, | 423 | color: ActivityBurnMonthReportView._h1, |
| 423 | fontSize: 16, | 424 | fontSize: 16, |
| 424 | fontWeight: FontWeight.w600, | 425 | fontWeight: FontWeight.w600, |
| @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; | @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; | ||
| 3 | import 'package:intl/intl.dart'; | 3 | import 'package:intl/intl.dart'; |
| 4 | 4 | ||
| 5 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; | 5 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; |
| 6 | +import '../../report_common/widgets/health_report_subject_scope.dart'; | ||
| 6 | import '../models/activity_burn_report_models.dart'; | 7 | import '../models/activity_burn_report_models.dart'; |
| 7 | import 'activity_burn_ring.dart'; | 8 | import 'activity_burn_ring.dart'; |
| 8 | 9 | ||
| @@ -51,9 +52,9 @@ class _WeeklySummary extends StatelessWidget { | @@ -51,9 +52,9 @@ class _WeeklySummary extends StatelessWidget { | ||
| 51 | child: Column( | 52 | child: Column( |
| 52 | crossAxisAlignment: CrossAxisAlignment.start, | 53 | crossAxisAlignment: CrossAxisAlignment.start, |
| 53 | children: [ | 54 | children: [ |
| 54 | - const Text( | ||
| 55 | - '活动总消耗', | ||
| 56 | - style: TextStyle( | 55 | + Text( |
| 56 | + HealthReportSubjectScope.titleOf(context, '活动总消耗'), | ||
| 57 | + style: const TextStyle( | ||
| 57 | color: ActivityBurnWeekReportView._active, | 58 | color: ActivityBurnWeekReportView._active, |
| 58 | fontSize: 12, | 59 | fontSize: 12, |
| 59 | fontWeight: FontWeight.w600, | 60 | fontWeight: FontWeight.w600, |
| @@ -102,7 +103,7 @@ class _WeeklySummary extends StatelessWidget { | @@ -102,7 +103,7 @@ class _WeeklySummary extends StatelessWidget { | ||
| 102 | Row( | 103 | Row( |
| 103 | children: [ | 104 | children: [ |
| 104 | _SummaryMetric( | 105 | _SummaryMetric( |
| 105 | - title: '锻炼总时长', | 106 | + title: HealthReportSubjectScope.titleOf(context, '锻炼总时长'), |
| 106 | value: report.hasData | 107 | value: report.hasData |
| 107 | ? report.totalExerciseMinutes.toString() | 108 | ? report.totalExerciseMinutes.toString() |
| 108 | : '-', | 109 | : '-', |
| @@ -111,7 +112,7 @@ class _WeeklySummary extends StatelessWidget { | @@ -111,7 +112,7 @@ class _WeeklySummary extends StatelessWidget { | ||
| 111 | ), | 112 | ), |
| 112 | const SizedBox(width: 34), | 113 | const SizedBox(width: 34), |
| 113 | _SummaryMetric( | 114 | _SummaryMetric( |
| 114 | - title: '站立总时长', | 115 | + title: HealthReportSubjectScope.titleOf(context, '站立总时长'), |
| 115 | value: report.hasData ? report.totalStandHours.toString() : '-', | 116 | value: report.hasData ? report.totalStandHours.toString() : '-', |
| 116 | unit: '小时', | 117 | unit: '小时', |
| 117 | color: ActivityBurnWeekReportView._stand, | 118 | color: ActivityBurnWeekReportView._stand, |
| @@ -393,9 +394,9 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | @@ -393,9 +394,9 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { | ||
| 393 | child: Column( | 394 | child: Column( |
| 394 | crossAxisAlignment: CrossAxisAlignment.start, | 395 | crossAxisAlignment: CrossAxisAlignment.start, |
| 395 | children: [ | 396 | children: [ |
| 396 | - const Text( | ||
| 397 | - '热量消耗趋势', | ||
| 398 | - style: TextStyle( | 397 | + Text( |
| 398 | + HealthReportSubjectScope.titleOf(context, '热量消耗趋势'), | ||
| 399 | + style: const TextStyle( | ||
| 399 | color: ActivityBurnWeekReportView._h1, | 400 | color: ActivityBurnWeekReportView._h1, |
| 400 | fontSize: 16, | 401 | fontSize: 16, |
| 401 | fontWeight: FontWeight.w600, | 402 | fontWeight: FontWeight.w600, |
| 1 | -import 'package:get/get.dart'; | ||
| 2 | - | ||
| 3 | -import '../controllers/friends_controller.dart'; | ||
| 4 | -import '../controllers/select_friend_controller.dart'; | ||
| 5 | - | ||
| 6 | -class SelectFriendBinding extends Bindings { | ||
| 7 | - @override | ||
| 8 | - void dependencies() { | ||
| 9 | - if (!Get.isRegistered<FriendsController>()) { | ||
| 10 | - Get.lazyPut<FriendsController>(() => FriendsController(), fenix: true); | ||
| 11 | - } | ||
| 12 | - Get.put(SelectFriendController(Get.find<FriendsController>())); | ||
| 13 | - } | ||
| 14 | -} |
| 1 | -import 'dart:async'; | ||
| 2 | - | ||
| 3 | import 'package:get/get.dart'; | 1 | import 'package:get/get.dart'; |
| 4 | 2 | ||
| 5 | import 'friends_controller.dart'; | 3 | import 'friends_controller.dart'; |
| 6 | 4 | ||
| 7 | -class SelectFriendController extends GetxController { | ||
| 8 | - SelectFriendController(this._friendsController); | 5 | +class SelectFriendLogic { |
| 6 | + SelectFriendLogic(this._friendsController); | ||
| 9 | 7 | ||
| 10 | final FriendsController _friendsController; | 8 | final FriendsController _friendsController; |
| 11 | final selectedIndex = (-1).obs; | 9 | final selectedIndex = (-1).obs; |
| 12 | 10 | ||
| 13 | List<FriendHealthData> get friends => _friendsController.friends; | 11 | List<FriendHealthData> get friends => _friendsController.friends; |
| 14 | 12 | ||
| 15 | - @override | ||
| 16 | - void onInit() { | ||
| 17 | - super.onInit(); | ||
| 18 | - unawaited(_prepareFriends()); | ||
| 19 | - } | ||
| 20 | - | ||
| 21 | - Future<void> _prepareFriends() async { | 13 | + Future<void> initialize() async { |
| 22 | if (friends.isEmpty) { | 14 | if (friends.isEmpty) { |
| 23 | await _friendsController.loadFriends(); | 15 | await _friendsController.loadFriends(); |
| 24 | } | 16 | } |
| @@ -33,9 +25,9 @@ class SelectFriendController extends GetxController { | @@ -33,9 +25,9 @@ class SelectFriendController extends GetxController { | ||
| 33 | selectedIndex.value = index; | 25 | selectedIndex.value = index; |
| 34 | } | 26 | } |
| 35 | 27 | ||
| 36 | - Future<void> syncSelectedFriendToWatchFace() async { | 28 | + bool syncSelectedFriendToWatchFace() { |
| 37 | final index = selectedIndex.value; | 29 | final index = selectedIndex.value; |
| 38 | - if (index < 0 || index >= friends.length) return; | 30 | + if (index < 0 || index >= friends.length) return false; |
| 39 | 31 | ||
| 40 | final updated = List.generate( | 32 | final updated = List.generate( |
| 41 | friends.length, | 33 | friends.length, |
| @@ -47,6 +39,8 @@ class SelectFriendController extends GetxController { | @@ -47,6 +39,8 @@ class SelectFriendController extends GetxController { | ||
| 47 | _friendsController.friends.assignAll(updated); | 39 | _friendsController.friends.assignAll(updated); |
| 48 | 40 | ||
| 49 | // TODO: Submit selected watch-face friend to the friend API when available. | 41 | // TODO: Submit selected watch-face friend to the friend API when available. |
| 50 | - Get.back<void>(); | 42 | + return true; |
| 51 | } | 43 | } |
| 44 | + | ||
| 45 | + void dispose() {} | ||
| 52 | } | 46 | } |
| @@ -7,6 +7,7 @@ import 'package:get/get.dart'; | @@ -7,6 +7,7 @@ import 'package:get/get.dart'; | ||
| 7 | import '../controllers/friends_controller.dart'; | 7 | import '../controllers/friends_controller.dart'; |
| 8 | import '../controllers/friend_trend_controller.dart'; | 8 | import '../controllers/friend_trend_controller.dart'; |
| 9 | import '../widgets/friend_health_card.dart'; | 9 | import '../widgets/friend_health_card.dart'; |
| 10 | +import 'select_friend_view.dart'; | ||
| 10 | 11 | ||
| 11 | class FriendsTab extends GetView<FriendsController> { | 12 | class FriendsTab extends GetView<FriendsController> { |
| 12 | const FriendsTab({super.key}); | 13 | const FriendsTab({super.key}); |
| @@ -145,9 +146,26 @@ class FriendsTab extends GetView<FriendsController> { | @@ -145,9 +146,26 @@ class FriendsTab extends GetView<FriendsController> { | ||
| 145 | case FriendCardAction.remove: | 146 | case FriendCardAction.remove: |
| 146 | controller.showDeleteConfirmDialog(friend); | 147 | controller.showDeleteConfirmDialog(friend); |
| 147 | case FriendCardAction.watchFace: | 148 | case FriendCardAction.watchFace: |
| 148 | - Get.toNamed(Routes.SELECT_FRIEND); | 149 | + _showSelectFriendSheet(); |
| 149 | } | 150 | } |
| 150 | } | 151 | } |
| 152 | + | ||
| 153 | + void _showSelectFriendSheet() { | ||
| 154 | + final context = Get.context; | ||
| 155 | + if (context == null) return; | ||
| 156 | + | ||
| 157 | + final mediaQuery = MediaQuery.of(context); | ||
| 158 | + Get.bottomSheet<void>( | ||
| 159 | + SizedBox( | ||
| 160 | + height: mediaQuery.size.height - mediaQuery.viewPadding.top, | ||
| 161 | + child: SelectFriendView(friendsController: controller), | ||
| 162 | + ), | ||
| 163 | + barrierColor: Colors.black.withValues(alpha: 0.7), | ||
| 164 | + enableDrag: true, | ||
| 165 | + isScrollControlled: true, | ||
| 166 | + persistent: false, | ||
| 167 | + ); | ||
| 168 | + } | ||
| 151 | } | 169 | } |
| 152 | 170 | ||
| 153 | class _FriendsHeader extends StatelessWidget { | 171 | class _FriendsHeader extends StatelessWidget { |
| @@ -3,83 +3,102 @@ import 'package:doublefeel_flutter/core/util/size_extensions.dart'; | @@ -3,83 +3,102 @@ import 'package:doublefeel_flutter/core/util/size_extensions.dart'; | ||
| 3 | import 'package:flutter/material.dart'; | 3 | import 'package:flutter/material.dart'; |
| 4 | import 'package:get/get.dart'; | 4 | import 'package:get/get.dart'; |
| 5 | 5 | ||
| 6 | -import '../controllers/select_friend_controller.dart'; | 6 | +import '../controllers/friends_controller.dart'; |
| 7 | +import '../controllers/select_friend_logic.dart'; | ||
| 7 | import '../widgets/friend_health_card.dart'; | 8 | import '../widgets/friend_health_card.dart'; |
| 8 | 9 | ||
| 9 | -class SelectFriendView extends GetView<SelectFriendController> { | ||
| 10 | - const SelectFriendView({super.key}); | 10 | +class SelectFriendView extends StatefulWidget { |
| 11 | + const SelectFriendView({super.key, required this.friendsController}); | ||
| 12 | + | ||
| 13 | + final FriendsController friendsController; | ||
| 14 | + | ||
| 15 | + @override | ||
| 16 | + State<SelectFriendView> createState() => _SelectFriendViewState(); | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +class _SelectFriendViewState extends State<SelectFriendView> { | ||
| 20 | + late final SelectFriendLogic _logic; | ||
| 21 | + | ||
| 22 | + @override | ||
| 23 | + void initState() { | ||
| 24 | + super.initState(); | ||
| 25 | + _logic = SelectFriendLogic(widget.friendsController); | ||
| 26 | + _logic.initialize(); | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + @override | ||
| 30 | + void dispose() { | ||
| 31 | + _logic.dispose(); | ||
| 32 | + super.dispose(); | ||
| 33 | + } | ||
| 11 | 34 | ||
| 12 | @override | 35 | @override |
| 13 | Widget build(BuildContext context) { | 36 | Widget build(BuildContext context) { |
| 14 | - return Scaffold( | ||
| 15 | - backgroundColor: const Color(0xFF000000).withValues(alpha: 0.70), | ||
| 16 | - body: Column( | 37 | + return Container( |
| 38 | + width: double.infinity, | ||
| 39 | + decoration: BoxDecoration( | ||
| 40 | + color: const Color(0xFFF5F2FF), | ||
| 41 | + borderRadius: BorderRadius.vertical(top: Radius.circular(12.dp)), | ||
| 42 | + ), | ||
| 43 | + child: Stack( | ||
| 17 | children: [ | 44 | children: [ |
| 18 | - SizedBox(height: MediaQuery.viewPaddingOf(context).top + 8.dp), | ||
| 19 | - Expanded( | ||
| 20 | - child: Container( | ||
| 21 | - width: double.infinity, | ||
| 22 | - decoration: BoxDecoration( | ||
| 23 | - color: const Color(0xFFF5F2FF), | ||
| 24 | - borderRadius: BorderRadius.vertical( | ||
| 25 | - top: Radius.circular(12.dp), | ||
| 26 | - ), | ||
| 27 | - ), | ||
| 28 | - child: Stack( | ||
| 29 | - children: [ | ||
| 30 | - Column( | ||
| 31 | - children: [ | ||
| 32 | - _SelectFriendHeader(onClose: () => Get.back<void>()), | ||
| 33 | - Expanded( | ||
| 34 | - child: Obx( | ||
| 35 | - () => ListView.separated( | ||
| 36 | - padding: EdgeInsets.fromLTRB( | ||
| 37 | - 16.dp, | ||
| 38 | - 0, | ||
| 39 | - 16.dp, | ||
| 40 | - 118.dp + MediaQuery.viewPaddingOf(context).bottom, | ||
| 41 | - ), | ||
| 42 | - itemBuilder: (context, index) { | ||
| 43 | - final friend = controller.friends[index]; | ||
| 44 | - final isSelected = | ||
| 45 | - controller.selectedIndex.value == index; | ||
| 46 | - return FriendHealthCard( | ||
| 47 | - name: friend.name, | ||
| 48 | - updatedAt: friend.updatedAt, | ||
| 49 | - sleepQualityScore: friend.sleepQualityScore, | ||
| 50 | - steps: friend.steps, | ||
| 51 | - statusText: friend.statusText, | ||
| 52 | - stressValue: friend.stressValue, | ||
| 53 | - isOnWatchFace: friend.isOnWatchFace, | ||
| 54 | - isSelected: isSelected, | ||
| 55 | - showCheckbox: true, | ||
| 56 | - onTap: () => controller.selectFriendAt(index), | ||
| 57 | - ); | ||
| 58 | - }, | ||
| 59 | - separatorBuilder: (_, __) => | ||
| 60 | - SizedBox(height: 12.dp), | ||
| 61 | - itemCount: controller.friends.length, | ||
| 62 | - ), | ||
| 63 | - ), | ||
| 64 | - ), | ||
| 65 | - ], | ||
| 66 | - ), | ||
| 67 | - Positioned( | ||
| 68 | - left: 49.dp, | ||
| 69 | - right: 49.dp, | ||
| 70 | - bottom: 36.dp + MediaQuery.viewPaddingOf(context).bottom, | ||
| 71 | - child: _SyncButton( | ||
| 72 | - onTap: controller.syncSelectedFriendToWatchFace, | 45 | + Column( |
| 46 | + children: [ | ||
| 47 | + _SelectFriendHeader(onClose: () => Get.back<void>()), | ||
| 48 | + Expanded( | ||
| 49 | + child: Obx(() { | ||
| 50 | + if (widget.friendsController.isLoading.value && | ||
| 51 | + _logic.friends.isEmpty) { | ||
| 52 | + return const Center(child: CircularProgressIndicator()); | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + final selectedIndex = _logic.selectedIndex.value; | ||
| 56 | + return ListView.separated( | ||
| 57 | + padding: EdgeInsets.fromLTRB( | ||
| 58 | + 16.dp, | ||
| 59 | + 0, | ||
| 60 | + 16.dp, | ||
| 61 | + 118.dp + MediaQuery.viewPaddingOf(context).bottom, | ||
| 73 | ), | 62 | ), |
| 74 | - ), | ||
| 75 | - ], | 63 | + itemBuilder: (context, index) { |
| 64 | + final friend = _logic.friends[index]; | ||
| 65 | + final isSelected = selectedIndex == index; | ||
| 66 | + return FriendHealthCard( | ||
| 67 | + name: friend.name, | ||
| 68 | + updatedAt: friend.updatedAt, | ||
| 69 | + sleepQualityScore: friend.sleepQualityScore, | ||
| 70 | + steps: friend.steps, | ||
| 71 | + statusText: friend.statusText, | ||
| 72 | + stressValue: friend.stressValue, | ||
| 73 | + isOnWatchFace: friend.isOnWatchFace, | ||
| 74 | + isSelected: isSelected, | ||
| 75 | + showCheckbox: true, | ||
| 76 | + onTap: () => _logic.selectFriendAt(index), | ||
| 77 | + ); | ||
| 78 | + }, | ||
| 79 | + separatorBuilder: (_, __) => SizedBox(height: 12.dp), | ||
| 80 | + itemCount: _logic.friends.length, | ||
| 81 | + ); | ||
| 82 | + }), | ||
| 76 | ), | 83 | ), |
| 77 | - ), | 84 | + ], |
| 85 | + ), | ||
| 86 | + Positioned( | ||
| 87 | + left: 49.dp, | ||
| 88 | + right: 49.dp, | ||
| 89 | + bottom: 36.dp + MediaQuery.viewPaddingOf(context).bottom, | ||
| 90 | + child: _SyncButton(onTap: _syncSelectedFriend), | ||
| 78 | ), | 91 | ), |
| 79 | ], | 92 | ], |
| 80 | ), | 93 | ), |
| 81 | ); | 94 | ); |
| 82 | } | 95 | } |
| 96 | + | ||
| 97 | + void _syncSelectedFriend() { | ||
| 98 | + if (_logic.syncSelectedFriendToWatchFace()) { | ||
| 99 | + Get.back<void>(); | ||
| 100 | + } | ||
| 101 | + } | ||
| 83 | } | 102 | } |
| 84 | 103 | ||
| 85 | class _SelectFriendHeader extends StatelessWidget { | 104 | class _SelectFriendHeader extends StatelessWidget { |
| 1 | import 'package:flutter/material.dart'; | 1 | import 'package:flutter/material.dart'; |
| 2 | +import 'package:get/get.dart'; | ||
| 2 | 3 | ||
| 4 | +import '../../../../data/local/user_preferences_storage.dart'; | ||
| 5 | +import '../../../routes/app_pages.dart'; | ||
| 3 | import '../../activity_burn_report/controllers/activity_burn_report_logic.dart'; | 6 | import '../../activity_burn_report/controllers/activity_burn_report_logic.dart'; |
| 4 | import '../../activity_burn_report/views/activity_burn_report_view.dart'; | 7 | import '../../activity_burn_report/views/activity_burn_report_view.dart'; |
| 5 | import '../../home/widgets/trend/trend_type_tab_bar.dart'; | 8 | import '../../home/widgets/trend/trend_type_tab_bar.dart'; |
| 6 | import '../../hrv_report/controllers/hrv_report_logic.dart'; | 9 | import '../../hrv_report/controllers/hrv_report_logic.dart'; |
| 7 | import '../../hrv_report/views/hrv_report_view.dart'; | 10 | import '../../hrv_report/views/hrv_report_view.dart'; |
| 8 | import '../../report_common/models/health_report_query.dart'; | 11 | import '../../report_common/models/health_report_query.dart'; |
| 12 | +import '../../report_common/widgets/health_report_subject_scope.dart'; | ||
| 9 | import '../../sleep_report/controllers/sleep_report_logic.dart'; | 13 | import '../../sleep_report/controllers/sleep_report_logic.dart'; |
| 10 | import '../../sleep_report/views/sleep_report_view.dart'; | 14 | import '../../sleep_report/views/sleep_report_view.dart'; |
| 11 | 15 | ||
| @@ -61,34 +65,63 @@ class _HealthTrendContentState extends State<HealthTrendContent> | @@ -61,34 +65,63 @@ class _HealthTrendContentState extends State<HealthTrendContent> | ||
| 61 | 65 | ||
| 62 | @override | 66 | @override |
| 63 | Widget build(BuildContext context) { | 67 | Widget build(BuildContext context) { |
| 64 | - return Column( | ||
| 65 | - children: [ | ||
| 66 | - TrendTypeTabBar(tabController: _tabController), | ||
| 67 | - Expanded( | ||
| 68 | - child: TabBarView( | ||
| 69 | - controller: _tabController, | ||
| 70 | - children: [ | ||
| 71 | - _KeepAliveWrapper( | ||
| 72 | - child: _HrvTrendSection(query: widget.query), | 68 | + final userPrefs = Get.find<UserPreferencesStorage>(); |
| 69 | + return Obx(() { | ||
| 70 | + final preferences = userPrefs.preferences.value; | ||
| 71 | + final vipInfo = preferences.vipInfo; | ||
| 72 | + final isVip = vipInfo?.isVip ?? false; | ||
| 73 | + void openPurchase() => Get.toNamed(Routes.PURCHASE); | ||
| 74 | + | ||
| 75 | + return HealthReportSubjectScope( | ||
| 76 | + isSelf: widget.query.isSelf, | ||
| 77 | + child: Column( | ||
| 78 | + children: [ | ||
| 79 | + TrendTypeTabBar(tabController: _tabController), | ||
| 80 | + Expanded( | ||
| 81 | + child: TabBarView( | ||
| 82 | + controller: _tabController, | ||
| 83 | + children: [ | ||
| 84 | + _KeepAliveWrapper( | ||
| 85 | + child: _HrvTrendSection( | ||
| 86 | + query: widget.query, | ||
| 87 | + isVip: isVip, | ||
| 88 | + onSubscribe: openPurchase, | ||
| 89 | + ), | ||
| 90 | + ), | ||
| 91 | + _KeepAliveWrapper( | ||
| 92 | + child: _ActivityBurnTrendSection( | ||
| 93 | + query: widget.query, | ||
| 94 | + isVip: isVip, | ||
| 95 | + onSubscribe: openPurchase, | ||
| 96 | + ), | ||
| 97 | + ), | ||
| 98 | + _KeepAliveWrapper( | ||
| 99 | + child: _SleepTrendSection( | ||
| 100 | + query: widget.query, | ||
| 101 | + isVip: isVip, | ||
| 102 | + onSubscribe: openPurchase, | ||
| 103 | + ), | ||
| 104 | + ), | ||
| 105 | + ], | ||
| 73 | ), | 106 | ), |
| 74 | - _KeepAliveWrapper( | ||
| 75 | - child: _ActivityBurnTrendSection(query: widget.query), | ||
| 76 | - ), | ||
| 77 | - _KeepAliveWrapper( | ||
| 78 | - child: _SleepTrendSection(query: widget.query), | ||
| 79 | - ), | ||
| 80 | - ], | ||
| 81 | - ), | 107 | + ), |
| 108 | + ], | ||
| 82 | ), | 109 | ), |
| 83 | - ], | ||
| 84 | - ); | 110 | + ); |
| 111 | + }); | ||
| 85 | } | 112 | } |
| 86 | } | 113 | } |
| 87 | 114 | ||
| 88 | class _HrvTrendSection extends StatefulWidget { | 115 | class _HrvTrendSection extends StatefulWidget { |
| 89 | - const _HrvTrendSection({required this.query}); | 116 | + const _HrvTrendSection({ |
| 117 | + required this.query, | ||
| 118 | + required this.isVip, | ||
| 119 | + required this.onSubscribe, | ||
| 120 | + }); | ||
| 90 | 121 | ||
| 91 | final HealthReportQuery query; | 122 | final HealthReportQuery query; |
| 123 | + final bool isVip; | ||
| 124 | + final VoidCallback onSubscribe; | ||
| 92 | 125 | ||
| 93 | @override | 126 | @override |
| 94 | State<_HrvTrendSection> createState() => _HrvTrendSectionState(); | 127 | State<_HrvTrendSection> createState() => _HrvTrendSectionState(); |
| @@ -119,13 +152,23 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> { | @@ -119,13 +152,23 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> { | ||
| 119 | } | 152 | } |
| 120 | 153 | ||
| 121 | @override | 154 | @override |
| 122 | - Widget build(BuildContext context) => HrvReportView(logic: _logic); | 155 | + Widget build(BuildContext context) => HrvReportView( |
| 156 | + logic: _logic, | ||
| 157 | + isVip: widget.isVip, | ||
| 158 | + onSubscribe: widget.onSubscribe, | ||
| 159 | + ); | ||
| 123 | } | 160 | } |
| 124 | 161 | ||
| 125 | class _ActivityBurnTrendSection extends StatefulWidget { | 162 | class _ActivityBurnTrendSection extends StatefulWidget { |
| 126 | - const _ActivityBurnTrendSection({required this.query}); | 163 | + const _ActivityBurnTrendSection({ |
| 164 | + required this.query, | ||
| 165 | + required this.isVip, | ||
| 166 | + required this.onSubscribe, | ||
| 167 | + }); | ||
| 127 | 168 | ||
| 128 | final HealthReportQuery query; | 169 | final HealthReportQuery query; |
| 170 | + final bool isVip; | ||
| 171 | + final VoidCallback onSubscribe; | ||
| 129 | 172 | ||
| 130 | @override | 173 | @override |
| 131 | State<_ActivityBurnTrendSection> createState() => | 174 | State<_ActivityBurnTrendSection> createState() => |
| @@ -159,13 +202,23 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { | @@ -159,13 +202,23 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { | ||
| 159 | } | 202 | } |
| 160 | 203 | ||
| 161 | @override | 204 | @override |
| 162 | - Widget build(BuildContext context) => ActivityBurnReportView(logic: _logic); | 205 | + Widget build(BuildContext context) => ActivityBurnReportView( |
| 206 | + logic: _logic, | ||
| 207 | + isVip: widget.isVip, | ||
| 208 | + onSubscribe: widget.onSubscribe, | ||
| 209 | + ); | ||
| 163 | } | 210 | } |
| 164 | 211 | ||
| 165 | class _SleepTrendSection extends StatefulWidget { | 212 | class _SleepTrendSection extends StatefulWidget { |
| 166 | - const _SleepTrendSection({required this.query}); | 213 | + const _SleepTrendSection({ |
| 214 | + required this.query, | ||
| 215 | + required this.isVip, | ||
| 216 | + required this.onSubscribe, | ||
| 217 | + }); | ||
| 167 | 218 | ||
| 168 | final HealthReportQuery query; | 219 | final HealthReportQuery query; |
| 220 | + final bool isVip; | ||
| 221 | + final VoidCallback onSubscribe; | ||
| 169 | 222 | ||
| 170 | @override | 223 | @override |
| 171 | State<_SleepTrendSection> createState() => _SleepTrendSectionState(); | 224 | State<_SleepTrendSection> createState() => _SleepTrendSectionState(); |
| @@ -198,7 +251,11 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | @@ -198,7 +251,11 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | ||
| 198 | } | 251 | } |
| 199 | 252 | ||
| 200 | @override | 253 | @override |
| 201 | - Widget build(BuildContext context) => SleepReportView(logic: _logic); | 254 | + Widget build(BuildContext context) => SleepReportView( |
| 255 | + logic: _logic, | ||
| 256 | + isVip: widget.isVip, | ||
| 257 | + onSubscribe: widget.onSubscribe, | ||
| 258 | + ); | ||
| 202 | } | 259 | } |
| 203 | 260 | ||
| 204 | class _KeepAliveWrapper extends StatefulWidget { | 261 | class _KeepAliveWrapper extends StatefulWidget { |
| 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 '../../../../r.dart'; | ||
| 4 | import '../../report_common/models/report_period.dart'; | 5 | import '../../report_common/models/report_period.dart'; |
| 5 | import '../../report_common/widgets/report_bottom_slogan.dart'; | 6 | import '../../report_common/widgets/report_bottom_slogan.dart'; |
| 6 | import '../../report_common/widgets/report_date_picker_sheet.dart'; | 7 | import '../../report_common/widgets/report_date_picker_sheet.dart'; |
| 7 | import '../../report_common/widgets/report_date_switcher.dart'; | 8 | import '../../report_common/widgets/report_date_switcher.dart'; |
| 9 | +import '../../report_common/widgets/report_loading_indicator.dart'; | ||
| 8 | import '../controllers/hrv_report_logic.dart'; | 10 | import '../controllers/hrv_report_logic.dart'; |
| 9 | import '../widgets/hrv_week_report_view.dart'; | 11 | import '../widgets/hrv_week_report_view.dart'; |
| 10 | import '../widgets/hrv_year_report_view.dart'; | 12 | import '../widgets/hrv_year_report_view.dart'; |
| 11 | 13 | ||
| 12 | class HrvReportView extends StatelessWidget { | 14 | class HrvReportView extends StatelessWidget { |
| 13 | - const HrvReportView({super.key, required this.logic}); | 15 | + const HrvReportView({ |
| 16 | + super.key, | ||
| 17 | + required this.logic, | ||
| 18 | + required this.isVip, | ||
| 19 | + required this.onSubscribe, | ||
| 20 | + }); | ||
| 14 | 21 | ||
| 15 | final HrvReportLogic logic; | 22 | final HrvReportLogic logic; |
| 23 | + final bool isVip; | ||
| 24 | + final VoidCallback onSubscribe; | ||
| 16 | 25 | ||
| 17 | @override | 26 | @override |
| 18 | Widget build(BuildContext context) { | 27 | Widget build(BuildContext context) { |
| 19 | return ColoredBox( | 28 | return ColoredBox( |
| 20 | color: Colors.transparent, | 29 | color: Colors.transparent, |
| 21 | - child: Column( | 30 | + child: Stack( |
| 22 | children: [ | 31 | children: [ |
| 23 | - Padding( | ||
| 24 | - padding: const EdgeInsets.symmetric(horizontal: 16), | ||
| 25 | - child: Column( | ||
| 26 | - children: [ | ||
| 27 | - const SizedBox(height: 8), | ||
| 28 | - Obx( | ||
| 29 | - () => _HrvPeriodBar( | ||
| 30 | - selectedPeriod: logic.selectedPeriod.value, | ||
| 31 | - onChanged: logic.selectPeriod, | ||
| 32 | - ), | 32 | + Column( |
| 33 | + children: [ | ||
| 34 | + Padding( | ||
| 35 | + padding: const EdgeInsets.symmetric(horizontal: 16), | ||
| 36 | + child: Column( | ||
| 37 | + children: [ | ||
| 38 | + const SizedBox(height: 8), | ||
| 39 | + Obx( | ||
| 40 | + () => _HrvPeriodBar( | ||
| 41 | + selectedPeriod: logic.selectedPeriod.value, | ||
| 42 | + onChanged: logic.selectPeriod, | ||
| 43 | + lockedPeriods: isVip | ||
| 44 | + ? const {} | ||
| 45 | + : const {ReportPeriod.month, ReportPeriod.year}, | ||
| 46 | + onLockedPeriodTap: (_) => onSubscribe(), | ||
| 47 | + ), | ||
| 48 | + ), | ||
| 49 | + const SizedBox(height: 8), | ||
| 50 | + Obx( | ||
| 51 | + () => ReportDateSwitcher( | ||
| 52 | + label: logic.dateLabel, | ||
| 53 | + onPrevious: isVip ? logic.previousDay : onSubscribe, | ||
| 54 | + onNext: isVip ? logic.nextDay : onSubscribe, | ||
| 55 | + onTapLabel: isVip | ||
| 56 | + ? () => _showDatePicker(context) | ||
| 57 | + : onSubscribe, | ||
| 58 | + ), | ||
| 59 | + ), | ||
| 60 | + ], | ||
| 33 | ), | 61 | ), |
| 34 | - const SizedBox(height: 8), | ||
| 35 | - Obx(() { | ||
| 36 | - print("------, ${logic.dateLabel}"); | ||
| 37 | - return ReportDateSwitcher( | ||
| 38 | - label: logic.dateLabel, | ||
| 39 | - onPrevious: logic.previousDay, | ||
| 40 | - onNext: logic.nextDay, | ||
| 41 | - onTapLabel: () => _showDatePicker(context), | 62 | + ), |
| 63 | + Expanded( | ||
| 64 | + child: Obx( | ||
| 65 | + () { | ||
| 66 | + if (logic.isLoading.value) { | ||
| 67 | + return const ReportLoadingIndicator(); | ||
| 68 | + } | ||
| 69 | + return ListView( | ||
| 70 | + physics: const ClampingScrollPhysics(), | ||
| 71 | + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), | ||
| 72 | + children: [ | ||
| 73 | + if (logic.selectedPeriod.value == ReportPeriod.year) | ||
| 74 | + HrvYearReportView(report: logic.yearlyReport.value) | ||
| 75 | + else if (logic.selectedPeriod.value == | ||
| 76 | + ReportPeriod.month) | ||
| 77 | + HrvMonthReportView(report: logic.monthlyReport.value) | ||
| 78 | + else | ||
| 79 | + HrvWeekReportView( | ||
| 80 | + report: logic.weeklyReport.value, | ||
| 81 | + showExample: !isVip, | ||
| 82 | + onSubscribe: onSubscribe, | ||
| 83 | + ), | ||
| 84 | + const ReportBottomSlogan(), | ||
| 85 | + ], | ||
| 42 | ); | 86 | ); |
| 43 | }, | 87 | }, |
| 44 | ), | 88 | ), |
| 45 | - ], | ||
| 46 | - ), | ||
| 47 | - ), | ||
| 48 | - Expanded( | ||
| 49 | - child: Obx( | ||
| 50 | - () => ListView( | ||
| 51 | - physics: const ClampingScrollPhysics(), | ||
| 52 | - padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), | ||
| 53 | - children: [ | ||
| 54 | - if (logic.selectedPeriod.value == ReportPeriod.year) | ||
| 55 | - HrvYearReportView(report: logic.yearlyReport.value) | ||
| 56 | - else if (logic.selectedPeriod.value == ReportPeriod.month) | ||
| 57 | - HrvMonthReportView(report: logic.monthlyReport.value) | ||
| 58 | - else | ||
| 59 | - HrvWeekReportView(report: logic.weeklyReport.value), | ||
| 60 | - const ReportBottomSlogan(), | ||
| 61 | - ], | ||
| 62 | ), | 89 | ), |
| 63 | - ), | 90 | + ], |
| 64 | ), | 91 | ), |
| 92 | + if (!isVip) | ||
| 93 | + Obx( | ||
| 94 | + () => logic.selectedPeriod.value == ReportPeriod.week | ||
| 95 | + ? Positioned( | ||
| 96 | + left: 0, | ||
| 97 | + right: 0, | ||
| 98 | + bottom: MediaQuery.paddingOf(context).bottom + 88, | ||
| 99 | + child: Center( | ||
| 100 | + child: _UnlockButton(onPressed: onSubscribe), | ||
| 101 | + ), | ||
| 102 | + ) | ||
| 103 | + : const SizedBox.shrink(), | ||
| 104 | + ), | ||
| 65 | ], | 105 | ], |
| 66 | ), | 106 | ), |
| 67 | ); | 107 | ); |
| @@ -78,14 +118,61 @@ class HrvReportView extends StatelessWidget { | @@ -78,14 +118,61 @@ class HrvReportView extends StatelessWidget { | ||
| 78 | } | 118 | } |
| 79 | } | 119 | } |
| 80 | 120 | ||
| 121 | +class _UnlockButton extends StatelessWidget { | ||
| 122 | + const _UnlockButton({required this.onPressed}); | ||
| 123 | + | ||
| 124 | + final VoidCallback onPressed; | ||
| 125 | + | ||
| 126 | + @override | ||
| 127 | + Widget build(BuildContext context) { | ||
| 128 | + return SizedBox( | ||
| 129 | + width: 240, | ||
| 130 | + height: 48, | ||
| 131 | + child: ElevatedButton( | ||
| 132 | + onPressed: onPressed, | ||
| 133 | + style: ElevatedButton.styleFrom( | ||
| 134 | + elevation: 0, | ||
| 135 | + shadowColor: Colors.transparent, | ||
| 136 | + backgroundColor: const Color(0xFFFFDF51), | ||
| 137 | + foregroundColor: const Color(0xFF0F0F11), | ||
| 138 | + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), | ||
| 139 | + shape: RoundedRectangleBorder( | ||
| 140 | + borderRadius: BorderRadius.circular(24), | ||
| 141 | + ), | ||
| 142 | + ), | ||
| 143 | + child: Row( | ||
| 144 | + mainAxisSize: MainAxisSize.min, | ||
| 145 | + children: [ | ||
| 146 | + Image.asset( | ||
| 147 | + R.assetsImagesProIcon, | ||
| 148 | + width: 24, | ||
| 149 | + height: 24, | ||
| 150 | + color: const Color(0xFF0F0F11), | ||
| 151 | + ), | ||
| 152 | + const SizedBox(width: 8), | ||
| 153 | + const Text( | ||
| 154 | + '立即解锁', | ||
| 155 | + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), | ||
| 156 | + ), | ||
| 157 | + ], | ||
| 158 | + ), | ||
| 159 | + ), | ||
| 160 | + ); | ||
| 161 | + } | ||
| 162 | +} | ||
| 163 | + | ||
| 81 | class _HrvPeriodBar extends StatelessWidget { | 164 | class _HrvPeriodBar extends StatelessWidget { |
| 82 | const _HrvPeriodBar({ | 165 | const _HrvPeriodBar({ |
| 83 | required this.selectedPeriod, | 166 | required this.selectedPeriod, |
| 84 | required this.onChanged, | 167 | required this.onChanged, |
| 168 | + required this.lockedPeriods, | ||
| 169 | + required this.onLockedPeriodTap, | ||
| 85 | }); | 170 | }); |
| 86 | 171 | ||
| 87 | final ReportPeriod selectedPeriod; | 172 | final ReportPeriod selectedPeriod; |
| 88 | final ValueChanged<ReportPeriod> onChanged; | 173 | final ValueChanged<ReportPeriod> onChanged; |
| 174 | + final Set<ReportPeriod> lockedPeriods; | ||
| 175 | + final ValueChanged<ReportPeriod> onLockedPeriodTap; | ||
| 89 | 176 | ||
| 90 | @override | 177 | @override |
| 91 | Widget build(BuildContext context) { | 178 | Widget build(BuildContext context) { |
| @@ -106,7 +193,13 @@ class _HrvPeriodBar extends StatelessWidget { | @@ -106,7 +193,13 @@ class _HrvPeriodBar extends StatelessWidget { | ||
| 106 | Expanded( | 193 | Expanded( |
| 107 | child: GestureDetector( | 194 | child: GestureDetector( |
| 108 | behavior: HitTestBehavior.opaque, | 195 | behavior: HitTestBehavior.opaque, |
| 109 | - onTap: () => onChanged(item.period), | 196 | + onTap: () { |
| 197 | + if (lockedPeriods.contains(item.period)) { | ||
| 198 | + onLockedPeriodTap(item.period); | ||
| 199 | + } else { | ||
| 200 | + onChanged(item.period); | ||
| 201 | + } | ||
| 202 | + }, | ||
| 110 | child: Container( | 203 | child: Container( |
| 111 | alignment: Alignment.center, | 204 | alignment: Alignment.center, |
| 112 | decoration: BoxDecoration( | 205 | decoration: BoxDecoration( |
| @@ -117,15 +210,30 @@ class _HrvPeriodBar extends StatelessWidget { | @@ -117,15 +210,30 @@ class _HrvPeriodBar extends StatelessWidget { | ||
| 117 | selectedPeriod == item.period ? 26 : 8, | 210 | selectedPeriod == item.period ? 26 : 8, |
| 118 | ), | 211 | ), |
| 119 | ), | 212 | ), |
| 120 | - child: Text( | ||
| 121 | - item.label, | ||
| 122 | - style: TextStyle( | ||
| 123 | - color: selectedPeriod == item.period | ||
| 124 | - ? const Color(0xFF0F0F11) | ||
| 125 | - : const Color(0xFF78787D), | ||
| 126 | - fontSize: 14, | ||
| 127 | - fontWeight: FontWeight.w500, | ||
| 128 | - ), | 213 | + child: Row( |
| 214 | + mainAxisAlignment: MainAxisAlignment.center, | ||
| 215 | + children: [ | ||
| 216 | + if (lockedPeriods.contains(item.period)) ...[ | ||
| 217 | + Image.asset( | ||
| 218 | + R.assetsImagesLockIcon, | ||
| 219 | + width: 14, | ||
| 220 | + height: 14, | ||
| 221 | + color: const Color(0xFF845EEE), | ||
| 222 | + colorBlendMode: BlendMode.srcIn, | ||
| 223 | + ), | ||
| 224 | + const SizedBox(width: 2), | ||
| 225 | + ], | ||
| 226 | + Text( | ||
| 227 | + item.label, | ||
| 228 | + style: TextStyle( | ||
| 229 | + color: selectedPeriod == item.period | ||
| 230 | + ? const Color(0xFF0F0F11) | ||
| 231 | + : const Color(0xFF78787D), | ||
| 232 | + fontSize: 14, | ||
| 233 | + fontWeight: FontWeight.w500, | ||
| 234 | + ), | ||
| 235 | + ), | ||
| 236 | + ], | ||
| 129 | ), | 237 | ), |
| 130 | ), | 238 | ), |
| 131 | ), | 239 | ), |
| 1 | +import 'package:doublefeel_flutter/core/util/size_extensions.dart'; | ||
| 1 | import 'package:fl_chart/fl_chart.dart'; | 2 | import 'package:fl_chart/fl_chart.dart'; |
| 2 | import 'package:flutter/material.dart'; | 3 | import 'package:flutter/material.dart'; |
| 3 | 4 | ||
| 5 | +import '../../../../r.dart'; | ||
| 4 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; | 6 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; |
| 7 | +import '../../report_common/widgets/health_report_subject_scope.dart'; | ||
| 5 | import '../models/hrv_report_models.dart'; | 8 | import '../models/hrv_report_models.dart'; |
| 6 | 9 | ||
| 7 | class HrvWeekReportView extends StatelessWidget { | 10 | class HrvWeekReportView extends StatelessWidget { |
| 8 | - const HrvWeekReportView({super.key, required this.report}); | 11 | + const HrvWeekReportView({ |
| 12 | + super.key, | ||
| 13 | + required this.report, | ||
| 14 | + this.showExample = false, | ||
| 15 | + this.onSubscribe, | ||
| 16 | + }); | ||
| 9 | 17 | ||
| 10 | final WeeklyHrvReport? report; | 18 | final WeeklyHrvReport? report; |
| 19 | + final bool showExample; | ||
| 20 | + final VoidCallback? onSubscribe; | ||
| 11 | 21 | ||
| 12 | @override | 22 | @override |
| 13 | Widget build(BuildContext context) { | 23 | Widget build(BuildContext context) { |
| 14 | - final data = report ?? WeeklyHrvReport.empty(DateTime(2026, 5, 4)); | ||
| 15 | - return _HrvPeriodReportView(report: data, isMonth: false); | 24 | + final now = DateTime.now(); |
| 25 | + final today = DateTime(now.year, now.month, now.day); | ||
| 26 | + final data = report ?? | ||
| 27 | + WeeklyHrvReport.empty( | ||
| 28 | + today.subtract(Duration(days: today.weekday - 1)), | ||
| 29 | + ); | ||
| 30 | + return _HrvPeriodReportView( | ||
| 31 | + report: data, | ||
| 32 | + isMonth: false, | ||
| 33 | + showExample: showExample, | ||
| 34 | + onSubscribe: onSubscribe, | ||
| 35 | + ); | ||
| 16 | } | 36 | } |
| 17 | } | 37 | } |
| 18 | 38 | ||
| @@ -23,44 +43,70 @@ class HrvMonthReportView extends StatelessWidget { | @@ -23,44 +43,70 @@ class HrvMonthReportView extends StatelessWidget { | ||
| 23 | 43 | ||
| 24 | @override | 44 | @override |
| 25 | Widget build(BuildContext context) { | 45 | Widget build(BuildContext context) { |
| 26 | - final data = report ?? MonthlyHrvReport.empty(DateTime(2026, 5)); | 46 | + final now = DateTime.now(); |
| 47 | + final data = | ||
| 48 | + report ?? MonthlyHrvReport.empty(DateTime(now.year, now.month)); | ||
| 27 | return _HrvPeriodReportView(report: data, isMonth: true); | 49 | return _HrvPeriodReportView(report: data, isMonth: true); |
| 28 | } | 50 | } |
| 29 | } | 51 | } |
| 30 | 52 | ||
| 31 | class _HrvPeriodReportView extends StatelessWidget { | 53 | class _HrvPeriodReportView extends StatelessWidget { |
| 32 | - const _HrvPeriodReportView({required this.report, required this.isMonth}); | 54 | + const _HrvPeriodReportView({ |
| 55 | + required this.report, | ||
| 56 | + required this.isMonth, | ||
| 57 | + this.showExample = false, | ||
| 58 | + this.onSubscribe, | ||
| 59 | + }); | ||
| 33 | 60 | ||
| 34 | final HrvPeriodReport report; | 61 | final HrvPeriodReport report; |
| 35 | final bool isMonth; | 62 | final bool isMonth; |
| 63 | + final bool showExample; | ||
| 64 | + final VoidCallback? onSubscribe; | ||
| 36 | 65 | ||
| 37 | @override | 66 | @override |
| 38 | Widget build(BuildContext context) { | 67 | Widget build(BuildContext context) { |
| 39 | return Column( | 68 | return Column( |
| 40 | children: [ | 69 | children: [ |
| 41 | - _DailyStressCard(report: report, isMonth: isMonth), | 70 | + _DailyStressCard( |
| 71 | + report: report, | ||
| 72 | + isMonth: isMonth, | ||
| 73 | + showExample: showExample, | ||
| 74 | + onTap: showExample ? onSubscribe : null, | ||
| 75 | + ), | ||
| 42 | const SizedBox(height: 12), | 76 | const SizedBox(height: 12), |
| 43 | - _DistributionCard(report: report), | 77 | + _DistributionCard( |
| 78 | + report: report, | ||
| 79 | + showExample: showExample, | ||
| 80 | + onTap: showExample ? onSubscribe : null, | ||
| 81 | + ), | ||
| 44 | ], | 82 | ], |
| 45 | ); | 83 | ); |
| 46 | } | 84 | } |
| 47 | } | 85 | } |
| 48 | 86 | ||
| 49 | class _DailyStressCard extends StatelessWidget { | 87 | class _DailyStressCard extends StatelessWidget { |
| 50 | - const _DailyStressCard({required this.report, required this.isMonth}); | 88 | + const _DailyStressCard({ |
| 89 | + required this.report, | ||
| 90 | + required this.isMonth, | ||
| 91 | + required this.showExample, | ||
| 92 | + this.onTap, | ||
| 93 | + }); | ||
| 51 | 94 | ||
| 52 | final HrvPeriodReport report; | 95 | final HrvPeriodReport report; |
| 53 | final bool isMonth; | 96 | final bool isMonth; |
| 97 | + final bool showExample; | ||
| 98 | + final VoidCallback? onTap; | ||
| 54 | 99 | ||
| 55 | @override | 100 | @override |
| 56 | Widget build(BuildContext context) { | 101 | Widget build(BuildContext context) { |
| 57 | return _Card( | 102 | return _Card( |
| 103 | + onTap: onTap, | ||
| 58 | child: Column( | 104 | child: Column( |
| 59 | crossAxisAlignment: CrossAxisAlignment.start, | 105 | crossAxisAlignment: CrossAxisAlignment.start, |
| 60 | children: [ | 106 | children: [ |
| 61 | - const Text( | ||
| 62 | - '每日压力趋势', | ||
| 63 | - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), | 107 | + _ReportTitle( |
| 108 | + title: HealthReportSubjectScope.titleOf(context, '每日压力趋势'), | ||
| 109 | + showExample: showExample, | ||
| 64 | ), | 110 | ), |
| 65 | const SizedBox(height: 12), | 111 | const SizedBox(height: 12), |
| 66 | SizedBox( | 112 | SizedBox( |
| @@ -325,18 +371,27 @@ class _TrendMetric extends StatelessWidget { | @@ -325,18 +371,27 @@ class _TrendMetric extends StatelessWidget { | ||
| 325 | } | 371 | } |
| 326 | 372 | ||
| 327 | class _DistributionCard extends StatelessWidget { | 373 | class _DistributionCard extends StatelessWidget { |
| 328 | - const _DistributionCard({required this.report}); | 374 | + const _DistributionCard({ |
| 375 | + required this.report, | ||
| 376 | + required this.showExample, | ||
| 377 | + this.onTap, | ||
| 378 | + }); | ||
| 329 | 379 | ||
| 330 | final HrvPeriodReport report; | 380 | final HrvPeriodReport report; |
| 381 | + final bool showExample; | ||
| 382 | + final VoidCallback? onTap; | ||
| 331 | 383 | ||
| 332 | @override | 384 | @override |
| 333 | Widget build(BuildContext context) { | 385 | Widget build(BuildContext context) { |
| 334 | return _Card( | 386 | return _Card( |
| 387 | + onTap: onTap, | ||
| 335 | child: Column( | 388 | child: Column( |
| 336 | crossAxisAlignment: CrossAxisAlignment.start, | 389 | crossAxisAlignment: CrossAxisAlignment.start, |
| 337 | children: [ | 390 | children: [ |
| 338 | - const Text('压力分布', | ||
| 339 | - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), | 391 | + _ReportTitle( |
| 392 | + title: HealthReportSubjectScope.titleOf(context, '压力分布'), | ||
| 393 | + showExample: showExample, | ||
| 394 | + ), | ||
| 340 | const SizedBox(height: 17), | 395 | const SizedBox(height: 17), |
| 341 | Row( | 396 | Row( |
| 342 | crossAxisAlignment: CrossAxisAlignment.end, | 397 | crossAxisAlignment: CrossAxisAlignment.end, |
| @@ -374,6 +429,7 @@ class _DistributionCard extends StatelessWidget { | @@ -374,6 +429,7 @@ class _DistributionCard extends StatelessWidget { | ||
| 374 | 429 | ||
| 375 | class _DistributionGrid extends StatelessWidget { | 430 | class _DistributionGrid extends StatelessWidget { |
| 376 | const _DistributionGrid({required this.report}); | 431 | const _DistributionGrid({required this.report}); |
| 432 | + | ||
| 377 | final HrvPeriodReport report; | 433 | final HrvPeriodReport report; |
| 378 | 434 | ||
| 379 | @override | 435 | @override |
| @@ -393,6 +449,7 @@ class _DistributionGrid extends StatelessWidget { | @@ -393,6 +449,7 @@ class _DistributionGrid extends StatelessWidget { | ||
| 393 | 449 | ||
| 394 | class _DistributionItem extends StatelessWidget { | 450 | class _DistributionItem extends StatelessWidget { |
| 395 | const _DistributionItem({required this.level, required this.report}); | 451 | const _DistributionItem({required this.level, required this.report}); |
| 452 | + | ||
| 396 | final HrvStressLevel level; | 453 | final HrvStressLevel level; |
| 397 | final HrvPeriodReport report; | 454 | final HrvPeriodReport report; |
| 398 | 455 | ||
| @@ -432,6 +489,7 @@ class _DistributionItem extends StatelessWidget { | @@ -432,6 +489,7 @@ class _DistributionItem extends StatelessWidget { | ||
| 432 | 489 | ||
| 433 | class _DistributionBar extends StatelessWidget { | 490 | class _DistributionBar extends StatelessWidget { |
| 434 | const _DistributionBar({required this.report}); | 491 | const _DistributionBar({required this.report}); |
| 492 | + | ||
| 435 | final HrvPeriodReport report; | 493 | final HrvPeriodReport report; |
| 436 | 494 | ||
| 437 | @override | 495 | @override |
| @@ -467,6 +525,7 @@ class _DistributionBar extends StatelessWidget { | @@ -467,6 +525,7 @@ class _DistributionBar extends StatelessWidget { | ||
| 467 | 525 | ||
| 468 | class _ExtremeHrv extends StatelessWidget { | 526 | class _ExtremeHrv extends StatelessWidget { |
| 469 | const _ExtremeHrv({required this.title, required this.day}); | 527 | const _ExtremeHrv({required this.title, required this.day}); |
| 528 | + | ||
| 470 | final String title; | 529 | final String title; |
| 471 | final HrvDayReport? day; | 530 | final HrvDayReport? day; |
| 472 | 531 | ||
| @@ -493,6 +552,7 @@ class _ExtremeHrv extends StatelessWidget { | @@ -493,6 +552,7 @@ class _ExtremeHrv extends StatelessWidget { | ||
| 493 | 552 | ||
| 494 | class _ValueWithUnit extends StatelessWidget { | 553 | class _ValueWithUnit extends StatelessWidget { |
| 495 | const _ValueWithUnit({required this.value, required this.unit}); | 554 | const _ValueWithUnit({required this.value, required this.unit}); |
| 555 | + | ||
| 496 | final String value; | 556 | final String value; |
| 497 | final String unit; | 557 | final String unit; |
| 498 | 558 | ||
| @@ -513,18 +573,60 @@ class _ValueWithUnit extends StatelessWidget { | @@ -513,18 +573,60 @@ class _ValueWithUnit extends StatelessWidget { | ||
| 513 | } | 573 | } |
| 514 | } | 574 | } |
| 515 | 575 | ||
| 576 | +class _ReportTitle extends StatelessWidget { | ||
| 577 | + const _ReportTitle({required this.title, required this.showExample}); | ||
| 578 | + | ||
| 579 | + final String title; | ||
| 580 | + final bool showExample; | ||
| 581 | + | ||
| 582 | + @override | ||
| 583 | + Widget build(BuildContext context) { | ||
| 584 | + return Row( | ||
| 585 | + children: [ | ||
| 586 | + Text( | ||
| 587 | + showExample ? '$title(示例)' : title, | ||
| 588 | + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), | ||
| 589 | + ), | ||
| 590 | + if (showExample) ...[ | ||
| 591 | + Image.asset( | ||
| 592 | + R.assetsImagesTrendProIcon, | ||
| 593 | + width: 46.w, | ||
| 594 | + height: 13.h, | ||
| 595 | + fit: BoxFit.contain, | ||
| 596 | + ), | ||
| 597 | + ], | ||
| 598 | + ], | ||
| 599 | + ); | ||
| 600 | + } | ||
| 601 | +} | ||
| 602 | + | ||
| 516 | class _Card extends StatelessWidget { | 603 | class _Card extends StatelessWidget { |
| 517 | - const _Card({required this.child}); | 604 | + const _Card({required this.child, this.onTap}); |
| 605 | + | ||
| 518 | final Widget child; | 606 | final Widget child; |
| 607 | + final VoidCallback? onTap; | ||
| 519 | 608 | ||
| 520 | @override | 609 | @override |
| 521 | Widget build(BuildContext context) { | 610 | Widget build(BuildContext context) { |
| 522 | - return Container( | 611 | + final content = Container( |
| 523 | width: double.infinity, | 612 | width: double.infinity, |
| 524 | padding: const EdgeInsets.fromLTRB(16, 17, 16, 18), | 613 | padding: const EdgeInsets.fromLTRB(16, 17, 16, 18), |
| 525 | decoration: BoxDecoration( | 614 | decoration: BoxDecoration( |
| 526 | color: Colors.white, borderRadius: BorderRadius.circular(16)), | 615 | color: Colors.white, borderRadius: BorderRadius.circular(16)), |
| 527 | child: child, | 616 | child: child, |
| 528 | ); | 617 | ); |
| 618 | + if (onTap == null) return content; | ||
| 619 | + | ||
| 620 | + return Stack( | ||
| 621 | + children: [ | ||
| 622 | + content, | ||
| 623 | + Positioned.fill( | ||
| 624 | + child: GestureDetector( | ||
| 625 | + behavior: HitTestBehavior.opaque, | ||
| 626 | + onTap: onTap, | ||
| 627 | + ), | ||
| 628 | + ), | ||
| 629 | + ], | ||
| 630 | + ); | ||
| 529 | } | 631 | } |
| 530 | } | 632 | } |
| @@ -2,6 +2,7 @@ import 'package:fl_chart/fl_chart.dart'; | @@ -2,6 +2,7 @@ import 'package:fl_chart/fl_chart.dart'; | ||
| 2 | import 'package:flutter/material.dart'; | 2 | import 'package:flutter/material.dart'; |
| 3 | 3 | ||
| 4 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; | 4 | import '../../report_common/widgets/chart_selection_line_overlay.dart'; |
| 5 | +import '../../report_common/widgets/health_report_subject_scope.dart'; | ||
| 5 | import '../../report_common/widgets/report_month_calendar_grid.dart'; | 6 | import '../../report_common/widgets/report_month_calendar_grid.dart'; |
| 6 | import '../models/hrv_report_models.dart'; | 7 | import '../models/hrv_report_models.dart'; |
| 7 | 8 | ||
| @@ -12,7 +13,7 @@ class HrvYearReportView extends StatelessWidget { | @@ -12,7 +13,7 @@ class HrvYearReportView extends StatelessWidget { | ||
| 12 | 13 | ||
| 13 | @override | 14 | @override |
| 14 | Widget build(BuildContext context) { | 15 | Widget build(BuildContext context) { |
| 15 | - final data = report ?? YearlyHrvReport.empty(2026); | 16 | + final data = report ?? YearlyHrvReport.empty(DateTime.now().year); |
| 16 | return Column( | 17 | return Column( |
| 17 | children: [ | 18 | children: [ |
| 18 | _YearTrendCard(report: data), | 19 | _YearTrendCard(report: data), |
| @@ -37,7 +38,10 @@ class _YearTrendCard extends StatelessWidget { | @@ -37,7 +38,10 @@ class _YearTrendCard extends StatelessWidget { | ||
| 37 | child: Column( | 38 | child: Column( |
| 38 | crossAxisAlignment: CrossAxisAlignment.start, | 39 | crossAxisAlignment: CrossAxisAlignment.start, |
| 39 | children: [ | 40 | children: [ |
| 40 | - const Text('每日压力趋势', style: _titleStyle), | 41 | + Text( |
| 42 | + HealthReportSubjectScope.titleOf(context, '每日压力趋势'), | ||
| 43 | + style: _titleStyle, | ||
| 44 | + ), | ||
| 41 | const SizedBox(height: 12), | 45 | const SizedBox(height: 12), |
| 42 | SizedBox(height: 174, child: _YearBarChart(report: report)), | 46 | SizedBox(height: 174, child: _YearBarChart(report: report)), |
| 43 | const Divider(height: 25, color: _grid), | 47 | const Divider(height: 25, color: _grid), |
| @@ -252,7 +256,10 @@ class _YearDistributionCard extends StatelessWidget { | @@ -252,7 +256,10 @@ class _YearDistributionCard extends StatelessWidget { | ||
| 252 | child: Column( | 256 | child: Column( |
| 253 | crossAxisAlignment: CrossAxisAlignment.start, | 257 | crossAxisAlignment: CrossAxisAlignment.start, |
| 254 | children: [ | 258 | children: [ |
| 255 | - const Text('压力分布', style: _titleStyle), | 259 | + Text( |
| 260 | + HealthReportSubjectScope.titleOf(context, '压力分布'), | ||
| 261 | + style: _titleStyle, | ||
| 262 | + ), | ||
| 256 | const SizedBox(height: 16), | 263 | const SizedBox(height: 16), |
| 257 | Row( | 264 | Row( |
| 258 | crossAxisAlignment: CrossAxisAlignment.end, | 265 | crossAxisAlignment: CrossAxisAlignment.end, |
| @@ -299,7 +306,10 @@ class _DailyDistributionCard extends StatelessWidget { | @@ -299,7 +306,10 @@ class _DailyDistributionCard extends StatelessWidget { | ||
| 299 | child: Column( | 306 | child: Column( |
| 300 | crossAxisAlignment: CrossAxisAlignment.start, | 307 | crossAxisAlignment: CrossAxisAlignment.start, |
| 301 | children: [ | 308 | children: [ |
| 302 | - const Text('每日压力分布', style: _titleStyle), | 309 | + Text( |
| 310 | + HealthReportSubjectScope.titleOf(context, '每日压力分布'), | ||
| 311 | + style: _titleStyle, | ||
| 312 | + ), | ||
| 303 | const SizedBox(height: 20), | 313 | const SizedBox(height: 20), |
| 304 | GridView.builder( | 314 | GridView.builder( |
| 305 | shrinkWrap: true, | 315 | shrinkWrap: true, |
| @@ -3,9 +3,17 @@ import 'package:intl/intl.dart'; | @@ -3,9 +3,17 @@ import 'package:intl/intl.dart'; | ||
| 3 | 3 | ||
| 4 | import '../models/report_period.dart'; | 4 | import '../models/report_period.dart'; |
| 5 | 5 | ||
| 6 | +DateTime _today() { | ||
| 7 | + final now = DateTime.now(); | ||
| 8 | + return DateTime(now.year, now.month, now.day); | ||
| 9 | +} | ||
| 10 | + | ||
| 6 | abstract class ReportPeriodLogic { | 11 | abstract class ReportPeriodLogic { |
| 12 | + ReportPeriodLogic({this.forceRefresh = true}); | ||
| 13 | + | ||
| 14 | + final bool forceRefresh; | ||
| 7 | final selectedPeriod = ReportPeriod.day.obs; | 15 | final selectedPeriod = ReportPeriod.day.obs; |
| 8 | - final selectedDate = DateTime(2026, 5, 12).obs; | 16 | + final selectedDate = _today().obs; |
| 9 | final isLoading = false.obs; | 17 | final isLoading = false.obs; |
| 10 | 18 | ||
| 11 | DateTime get weekStart { | 19 | DateTime get weekStart { |
| @@ -46,38 +54,80 @@ abstract class ReportPeriodLogic { | @@ -46,38 +54,80 @@ abstract class ReportPeriodLogic { | ||
| 46 | return DateFormat('M月d日').format(selectedDate.value); | 54 | return DateFormat('M月d日').format(selectedDate.value); |
| 47 | } | 55 | } |
| 48 | 56 | ||
| 49 | - Future<void> selectPeriod(ReportPeriod period) { | 57 | + Future<void> selectPeriod( |
| 58 | + ReportPeriod period, { | ||
| 59 | + bool? forceRefresh, | ||
| 60 | + }) { | ||
| 61 | + final shouldRefresh = forceRefresh ?? this.forceRefresh; | ||
| 62 | + if (!shouldRefresh && selectedPeriod.value == period) return Future.value(); | ||
| 50 | selectedPeriod.value = period; | 63 | selectedPeriod.value = period; |
| 51 | return loadReport(); | 64 | return loadReport(); |
| 52 | } | 65 | } |
| 53 | 66 | ||
| 54 | - Future<void> selectDate(DateTime date) { | ||
| 55 | - selectedDate.value = DateTime(date.year, date.month, date.day); | 67 | + Future<void> selectDate( |
| 68 | + DateTime date, { | ||
| 69 | + bool? forceRefresh, | ||
| 70 | + }) { | ||
| 71 | + final shouldRefresh = forceRefresh ?? this.forceRefresh; | ||
| 72 | + final nextDate = DateTime(date.year, date.month, date.day); | ||
| 73 | + if (!shouldRefresh && selectedDate.value == nextDate) return Future.value(); | ||
| 74 | + selectedDate.value = nextDate; | ||
| 75 | + return loadReport(); | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + Future<void> selectWeek( | ||
| 79 | + DateTime date, { | ||
| 80 | + bool? forceRefresh, | ||
| 81 | + }) { | ||
| 82 | + final shouldRefresh = forceRefresh ?? this.forceRefresh; | ||
| 83 | + final nextWeekStart = _weekStartOf(date); | ||
| 84 | + if (!shouldRefresh && weekStart == nextWeekStart) return Future.value(); | ||
| 85 | + selectedDate.value = nextWeekStart; | ||
| 56 | return loadReport(); | 86 | return loadReport(); |
| 57 | } | 87 | } |
| 58 | 88 | ||
| 59 | - Future<void> selectWeek(DateTime weekStart) { | ||
| 60 | - selectedDate.value = | ||
| 61 | - DateTime(weekStart.year, weekStart.month, weekStart.day); | 89 | + Future<void> selectMonth( |
| 90 | + int year, | ||
| 91 | + int month, { | ||
| 92 | + bool? forceRefresh, | ||
| 93 | + }) { | ||
| 94 | + final shouldRefresh = forceRefresh ?? this.forceRefresh; | ||
| 95 | + final nextMonth = DateTime(year, month); | ||
| 96 | + if (!shouldRefresh && monthStart == nextMonth) return Future.value(); | ||
| 97 | + selectedDate.value = nextMonth; | ||
| 62 | return loadReport(); | 98 | return loadReport(); |
| 63 | } | 99 | } |
| 64 | 100 | ||
| 65 | - Future<void> selectMonth(int year, int month) { | ||
| 66 | - selectedDate.value = DateTime(year, month); | 101 | + Future<void> selectYear( |
| 102 | + int year, { | ||
| 103 | + bool? forceRefresh, | ||
| 104 | + }) { | ||
| 105 | + final shouldRefresh = forceRefresh ?? this.forceRefresh; | ||
| 106 | + if (!shouldRefresh && selectedDate.value.year == year) { | ||
| 107 | + return Future.value(); | ||
| 108 | + } | ||
| 109 | + selectedDate.value = DateTime(year); | ||
| 67 | return loadReport(); | 110 | return loadReport(); |
| 68 | } | 111 | } |
| 69 | 112 | ||
| 70 | - Future<void> selectPickedDate(DateTime date) { | 113 | + Future<void> selectPickedDate( |
| 114 | + DateTime date, { | ||
| 115 | + bool? forceRefresh, | ||
| 116 | + }) { | ||
| 71 | if (selectedPeriod.value == ReportPeriod.week) { | 117 | if (selectedPeriod.value == ReportPeriod.week) { |
| 72 | - return selectWeek(date); | 118 | + return selectWeek(date, forceRefresh: forceRefresh); |
| 73 | } | 119 | } |
| 74 | if (selectedPeriod.value == ReportPeriod.month) { | 120 | if (selectedPeriod.value == ReportPeriod.month) { |
| 75 | - return selectMonth(date.year, date.month); | 121 | + return selectMonth( |
| 122 | + date.year, | ||
| 123 | + date.month, | ||
| 124 | + forceRefresh: forceRefresh, | ||
| 125 | + ); | ||
| 76 | } | 126 | } |
| 77 | if (selectedPeriod.value == ReportPeriod.year) { | 127 | if (selectedPeriod.value == ReportPeriod.year) { |
| 78 | - return selectDate(DateTime(date.year)); | 128 | + return selectYear(date.year, forceRefresh: forceRefresh); |
| 79 | } | 129 | } |
| 80 | - return selectDate(date); | 130 | + return selectDate(date, forceRefresh: forceRefresh); |
| 81 | } | 131 | } |
| 82 | 132 | ||
| 83 | Future<void> previousDay() { | 133 | Future<void> previousDay() { |
| @@ -112,6 +162,11 @@ abstract class ReportPeriodLogic { | @@ -112,6 +162,11 @@ abstract class ReportPeriodLogic { | ||
| 112 | 162 | ||
| 113 | void dispose() {} | 163 | void dispose() {} |
| 114 | 164 | ||
| 165 | + DateTime _weekStartOf(DateTime date) { | ||
| 166 | + final normalized = DateTime(date.year, date.month, date.day); | ||
| 167 | + return normalized.subtract(Duration(days: normalized.weekday - 1)); | ||
| 168 | + } | ||
| 169 | + | ||
| 115 | String _formatWeekRangeDate(DateTime date) { | 170 | String _formatWeekRangeDate(DateTime date) { |
| 116 | final prefix = date.year == DateTime.now().year ? '' : '${date.year}年'; | 171 | final prefix = date.year == DateTime.now().year ? '' : '${date.year}年'; |
| 117 | return '$prefix${DateFormat('M月d日').format(date)}'; | 172 | return '$prefix${DateFormat('M月d日').format(date)}'; |
| 1 | +import 'package:flutter/widgets.dart'; | ||
| 2 | + | ||
| 3 | +class HealthReportSubjectScope extends InheritedWidget { | ||
| 4 | + const HealthReportSubjectScope({ | ||
| 5 | + super.key, | ||
| 6 | + required this.isSelf, | ||
| 7 | + required super.child, | ||
| 8 | + }); | ||
| 9 | + | ||
| 10 | + final bool isSelf; | ||
| 11 | + | ||
| 12 | + static String titleOf( | ||
| 13 | + BuildContext context, | ||
| 14 | + String title, { | ||
| 15 | + bool possessive = true, | ||
| 16 | + }) { | ||
| 17 | + final scope = | ||
| 18 | + context.dependOnInheritedWidgetOfExactType<HealthReportSubjectScope>(); | ||
| 19 | + if (scope == null || scope.isSelf) return title; | ||
| 20 | + return possessive ? 'Ta的$title' : 'Ta$title'; | ||
| 21 | + } | ||
| 22 | + | ||
| 23 | + @override | ||
| 24 | + bool updateShouldNotify(HealthReportSubjectScope oldWidget) { | ||
| 25 | + return oldWidget.isSelf != isSelf; | ||
| 26 | + } | ||
| 27 | +} |
| @@ -8,11 +8,11 @@ Future<DateTime?> showReportDatePickerSheet({ | @@ -8,11 +8,11 @@ Future<DateTime?> showReportDatePickerSheet({ | ||
| 8 | required ReportPeriod period, | 8 | required ReportPeriod period, |
| 9 | required DateTime selectedDate, | 9 | required DateTime selectedDate, |
| 10 | required DateTime weekStart, | 10 | required DateTime weekStart, |
| 11 | -}) { | 11 | +}) async { |
| 12 | final backgroundColor = const Color(0xFFF8F4FF); | 12 | final backgroundColor = const Color(0xFFF8F4FF); |
| 13 | final barrierAlpha = period == ReportPeriod.day ? 0.25 : 0.70; | 13 | final barrierAlpha = period == ReportPeriod.day ? 0.25 : 0.70; |
| 14 | 14 | ||
| 15 | - return showModalBottomSheet<DateTime>( | 15 | + final picked = await showModalBottomSheet<DateTime>( |
| 16 | context: context, | 16 | context: context, |
| 17 | backgroundColor: backgroundColor, | 17 | backgroundColor: backgroundColor, |
| 18 | barrierColor: Colors.black.withValues(alpha: barrierAlpha), | 18 | barrierColor: Colors.black.withValues(alpha: barrierAlpha), |
| @@ -32,6 +32,13 @@ Future<DateTime?> showReportDatePickerSheet({ | @@ -32,6 +32,13 @@ Future<DateTime?> showReportDatePickerSheet({ | ||
| 32 | } | 32 | } |
| 33 | }, | 33 | }, |
| 34 | ); | 34 | ); |
| 35 | + | ||
| 36 | + // Navigator.pop completes before the bottom sheet's reverse animation ends. | ||
| 37 | + // Defer the report rebuild so heavy views do not compete with that animation. | ||
| 38 | + if (picked != null) { | ||
| 39 | + await Future<void>.delayed(const Duration(milliseconds: 220)); | ||
| 40 | + } | ||
| 41 | + return picked; | ||
| 35 | } | 42 | } |
| 36 | 43 | ||
| 37 | class _YearDatePickerSheet extends StatefulWidget { | 44 | class _YearDatePickerSheet extends StatefulWidget { |
| @@ -88,8 +95,7 @@ class _YearDatePickerSheetState extends State<_YearDatePickerSheet> { | @@ -88,8 +95,7 @@ class _YearDatePickerSheetState extends State<_YearDatePickerSheet> { | ||
| 88 | count: _yearRange.count, | 95 | count: _yearRange.count, |
| 89 | selectedIndex: _year - _yearRange.first, | 96 | selectedIndex: _year - _yearRange.first, |
| 90 | itemExtent: _itemExtent, | 97 | itemExtent: _itemExtent, |
| 91 | - labelBuilder: (index) => | ||
| 92 | - '${_yearRange.first + index} 年', | 98 | + labelBuilder: (index) => '${_yearRange.first + index} 年', |
| 93 | onSelectedItemChanged: (index) { | 99 | onSelectedItemChanged: (index) { |
| 94 | setState(() => _year = _yearRange.first + index); | 100 | setState(() => _year = _yearRange.first + index); |
| 95 | }, | 101 | }, |
| 1 | +import 'package:flutter/material.dart'; | ||
| 2 | + | ||
| 3 | +class ReportLoadingIndicator extends StatelessWidget { | ||
| 4 | + const ReportLoadingIndicator({super.key}); | ||
| 5 | + | ||
| 6 | + @override | ||
| 7 | + Widget build(BuildContext context) { | ||
| 8 | + return const Center( | ||
| 9 | + child: SizedBox( | ||
| 10 | + width: 24, | ||
| 11 | + height: 24, | ||
| 12 | + child: CircularProgressIndicator( | ||
| 13 | + color: Color(0xFF845EEE), | ||
| 14 | + strokeWidth: 2, | ||
| 15 | + ), | ||
| 16 | + ), | ||
| 17 | + ); | ||
| 18 | + } | ||
| 19 | +} |
| 1 | import 'package:flutter/material.dart'; | 1 | import 'package:flutter/material.dart'; |
| 2 | 2 | ||
| 3 | +import '../../../../r.dart'; | ||
| 3 | import '../models/report_period.dart'; | 4 | import '../models/report_period.dart'; |
| 4 | 5 | ||
| 5 | class ReportPeriodTabBar extends StatelessWidget { | 6 | class ReportPeriodTabBar extends StatelessWidget { |
| @@ -8,11 +9,13 @@ class ReportPeriodTabBar extends StatelessWidget { | @@ -8,11 +9,13 @@ class ReportPeriodTabBar extends StatelessWidget { | ||
| 8 | required this.selectedPeriod, | 9 | required this.selectedPeriod, |
| 9 | required this.onChanged, | 10 | required this.onChanged, |
| 10 | this.lockedPeriods = const {}, | 11 | this.lockedPeriods = const {}, |
| 12 | + this.onLockedPeriodTap, | ||
| 11 | }); | 13 | }); |
| 12 | 14 | ||
| 13 | final ReportPeriod selectedPeriod; | 15 | final ReportPeriod selectedPeriod; |
| 14 | final ValueChanged<ReportPeriod> onChanged; | 16 | final ValueChanged<ReportPeriod> onChanged; |
| 15 | final Set<ReportPeriod> lockedPeriods; | 17 | final Set<ReportPeriod> lockedPeriods; |
| 18 | + final ValueChanged<ReportPeriod>? onLockedPeriodTap; | ||
| 16 | 19 | ||
| 17 | static const _brand = Color(0xFF845EEE); | 20 | static const _brand = Color(0xFF845EEE); |
| 18 | static const _h1 = Color(0xFF0F0F11); | 21 | static const _h1 = Color(0xFF0F0F11); |
| @@ -39,9 +42,13 @@ class ReportPeriodTabBar extends StatelessWidget { | @@ -39,9 +42,13 @@ class ReportPeriodTabBar extends StatelessWidget { | ||
| 39 | Expanded( | 42 | Expanded( |
| 40 | child: GestureDetector( | 43 | child: GestureDetector( |
| 41 | behavior: HitTestBehavior.opaque, | 44 | behavior: HitTestBehavior.opaque, |
| 42 | - onTap: lockedPeriods.contains(period) | ||
| 43 | - ? null | ||
| 44 | - : () => onChanged(period), | 45 | + onTap: () { |
| 46 | + if (lockedPeriods.contains(period)) { | ||
| 47 | + onLockedPeriodTap?.call(period); | ||
| 48 | + } else { | ||
| 49 | + onChanged(period); | ||
| 50 | + } | ||
| 51 | + }, | ||
| 45 | child: Container( | 52 | child: Container( |
| 46 | height: 28, | 53 | height: 28, |
| 47 | decoration: BoxDecoration( | 54 | decoration: BoxDecoration( |
| @@ -54,10 +61,12 @@ class ReportPeriodTabBar extends StatelessWidget { | @@ -54,10 +61,12 @@ class ReportPeriodTabBar extends StatelessWidget { | ||
| 54 | mainAxisAlignment: MainAxisAlignment.center, | 61 | mainAxisAlignment: MainAxisAlignment.center, |
| 55 | children: [ | 62 | children: [ |
| 56 | if (lockedPeriods.contains(period)) ...[ | 63 | if (lockedPeriods.contains(period)) ...[ |
| 57 | - const Icon( | ||
| 58 | - Icons.lock_outline, | ||
| 59 | - size: 14, | 64 | + Image.asset( |
| 65 | + R.assetsImagesLockIcon, | ||
| 66 | + width: 14, | ||
| 67 | + height: 14, | ||
| 60 | color: _brand, | 68 | color: _brand, |
| 69 | + colorBlendMode: BlendMode.srcIn, | ||
| 61 | ), | 70 | ), |
| 62 | const SizedBox(width: 2), | 71 | const SizedBox(width: 2), |
| 63 | ], | 72 | ], |
| @@ -5,7 +5,9 @@ import '../../report_common/models/report_period.dart'; | @@ -5,7 +5,9 @@ import '../../report_common/models/report_period.dart'; | ||
| 5 | import '../../report_common/widgets/report_bottom_slogan.dart'; | 5 | import '../../report_common/widgets/report_bottom_slogan.dart'; |
| 6 | import '../../report_common/widgets/report_date_picker_sheet.dart'; | 6 | import '../../report_common/widgets/report_date_picker_sheet.dart'; |
| 7 | import '../../report_common/widgets/report_date_switcher.dart'; | 7 | import '../../report_common/widgets/report_date_switcher.dart'; |
| 8 | +import '../../report_common/widgets/report_loading_indicator.dart'; | ||
| 8 | import '../../report_common/widgets/report_period_tab_bar.dart'; | 9 | import '../../report_common/widgets/report_period_tab_bar.dart'; |
| 10 | +import '../../report_common/widgets/health_report_subject_scope.dart'; | ||
| 9 | import '../controllers/sleep_report_logic.dart'; | 11 | import '../controllers/sleep_report_logic.dart'; |
| 10 | import '../models/sleep_report_models.dart'; | 12 | import '../models/sleep_report_models.dart'; |
| 11 | import '../widgets/sleep_heart_rate_card.dart'; | 13 | import '../widgets/sleep_heart_rate_card.dart'; |
| @@ -18,9 +20,13 @@ class SleepReportView extends StatelessWidget { | @@ -18,9 +20,13 @@ class SleepReportView extends StatelessWidget { | ||
| 18 | const SleepReportView({ | 20 | const SleepReportView({ |
| 19 | super.key, | 21 | super.key, |
| 20 | required this.logic, | 22 | required this.logic, |
| 23 | + required this.isVip, | ||
| 24 | + required this.onSubscribe, | ||
| 21 | }); | 25 | }); |
| 22 | 26 | ||
| 23 | final SleepReportLogic logic; | 27 | final SleepReportLogic logic; |
| 28 | + final bool isVip; | ||
| 29 | + final VoidCallback onSubscribe; | ||
| 24 | 30 | ||
| 25 | @override | 31 | @override |
| 26 | Widget build(BuildContext context) { | 32 | Widget build(BuildContext context) { |
| @@ -39,6 +45,10 @@ class SleepReportView extends StatelessWidget { | @@ -39,6 +45,10 @@ class SleepReportView extends StatelessWidget { | ||
| 39 | () => ReportPeriodTabBar( | 45 | () => ReportPeriodTabBar( |
| 40 | selectedPeriod: logic.selectedPeriod.value, | 46 | selectedPeriod: logic.selectedPeriod.value, |
| 41 | onChanged: logic.selectPeriod, | 47 | onChanged: logic.selectPeriod, |
| 48 | + lockedPeriods: isVip | ||
| 49 | + ? const {} | ||
| 50 | + : const {ReportPeriod.week, ReportPeriod.month}, | ||
| 51 | + onLockedPeriodTap: (_) => onSubscribe(), | ||
| 42 | ), | 52 | ), |
| 43 | ), | 53 | ), |
| 44 | const SizedBox(height: 8), | 54 | const SizedBox(height: 8), |
| @@ -47,66 +57,38 @@ class SleepReportView extends StatelessWidget { | @@ -47,66 +57,38 @@ class SleepReportView extends StatelessWidget { | ||
| 47 | ), | 57 | ), |
| 48 | ), | 58 | ), |
| 49 | Expanded( | 59 | Expanded( |
| 50 | - child: CustomScrollView( | ||
| 51 | - physics: const ClampingScrollPhysics(), | ||
| 52 | - slivers: [ | ||
| 53 | - SliverPadding( | ||
| 54 | - padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), | ||
| 55 | - sliver: SliverList( | ||
| 56 | - delegate: SliverChildListDelegate([ | ||
| 57 | - Obx( | ||
| 58 | - () { | 60 | + child: Obx( |
| 61 | + () { | ||
| 62 | + if (logic.isLoading.value) { | ||
| 63 | + return const ReportLoadingIndicator(); | ||
| 64 | + } | ||
| 65 | + return CustomScrollView( | ||
| 66 | + physics: const ClampingScrollPhysics(), | ||
| 67 | + slivers: [ | ||
| 68 | + SliverPadding( | ||
| 69 | + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), | ||
| 70 | + sliver: SliverList( | ||
| 71 | + delegate: SliverChildListDelegate([ | ||
| 59 | if (logic.selectedPeriod.value == | 72 | if (logic.selectedPeriod.value == |
| 60 | - ReportPeriod.week) { | ||
| 61 | - return SleepWeekReportView( | 73 | + ReportPeriod.week) |
| 74 | + SleepWeekReportView( | ||
| 62 | report: logic.weeklyReport.value, | 75 | report: logic.weeklyReport.value, |
| 63 | - ); | ||
| 64 | - } | ||
| 65 | - if (logic.selectedPeriod.value == | ||
| 66 | - ReportPeriod.month) { | ||
| 67 | - return SleepMonthReportView( | 76 | + ) |
| 77 | + else if (logic.selectedPeriod.value == | ||
| 78 | + ReportPeriod.month) | ||
| 79 | + SleepMonthReportView( | ||
| 68 | monthStart: logic.monthStart, | 80 | monthStart: logic.monthStart, |
| 69 | report: logic.monthlyReport.value, | 81 | report: logic.monthlyReport.value, |
| 70 | - ); | ||
| 71 | - } | ||
| 72 | - | ||
| 73 | - final report = logic.report.value; | ||
| 74 | - final quality = report?.qualityLevel ?? | ||
| 75 | - SleepQualityLevel.unknown; | ||
| 76 | - return Column( | ||
| 77 | - children: [ | ||
| 78 | - SleepQualityRing(report: report), | ||
| 79 | - const SizedBox(height: 16), | ||
| 80 | - Text( | ||
| 81 | - quality.reportText, | ||
| 82 | - textAlign: TextAlign.center, | ||
| 83 | - style: TextStyle( | ||
| 84 | - color: Color(quality.qualityColorValue), | ||
| 85 | - fontSize: 24, | ||
| 86 | - fontWeight: FontWeight.w500, | ||
| 87 | - height: 1.2, | ||
| 88 | - ), | ||
| 89 | - ), | ||
| 90 | - const SizedBox(height: 25), | ||
| 91 | - SleepSummaryCard( | ||
| 92 | - report: report, | ||
| 93 | - onQualityInfoTap: () => | ||
| 94 | - showSleepQualityDialog(context), | ||
| 95 | - ), | ||
| 96 | - const SizedBox(height: 12), | ||
| 97 | - SleepHeartRateCard( | ||
| 98 | - summary: report?.heartRate ?? | ||
| 99 | - const SleepHeartRateSummary(), | ||
| 100 | - ), | ||
| 101 | - ], | ||
| 102 | - ); | ||
| 103 | - }, | 82 | + ) |
| 83 | + else | ||
| 84 | + _DailyReportContent(report: logic.report.value), | ||
| 85 | + const ReportBottomSlogan(), | ||
| 86 | + ]), | ||
| 104 | ), | 87 | ), |
| 105 | - const ReportBottomSlogan(), | ||
| 106 | - ]), | ||
| 107 | - ), | ||
| 108 | - ), | ||
| 109 | - ], | 88 | + ), |
| 89 | + ], | ||
| 90 | + ); | ||
| 91 | + }, | ||
| 110 | ), | 92 | ), |
| 111 | ), | 93 | ), |
| 112 | ], | 94 | ], |
| @@ -117,6 +99,46 @@ class SleepReportView extends StatelessWidget { | @@ -117,6 +99,46 @@ class SleepReportView extends StatelessWidget { | ||
| 117 | } | 99 | } |
| 118 | } | 100 | } |
| 119 | 101 | ||
| 102 | +class _DailyReportContent extends StatelessWidget { | ||
| 103 | + const _DailyReportContent({required this.report}); | ||
| 104 | + | ||
| 105 | + final SleepReport? report; | ||
| 106 | + | ||
| 107 | + @override | ||
| 108 | + Widget build(BuildContext context) { | ||
| 109 | + final quality = report?.qualityLevel ?? SleepQualityLevel.unknown; | ||
| 110 | + return Column( | ||
| 111 | + children: [ | ||
| 112 | + SleepQualityRing(report: report), | ||
| 113 | + const SizedBox(height: 16), | ||
| 114 | + Text( | ||
| 115 | + HealthReportSubjectScope.titleOf( | ||
| 116 | + context, | ||
| 117 | + quality.reportText, | ||
| 118 | + possessive: false, | ||
| 119 | + ), | ||
| 120 | + textAlign: TextAlign.center, | ||
| 121 | + style: TextStyle( | ||
| 122 | + color: Color(quality.qualityColorValue), | ||
| 123 | + fontSize: 24, | ||
| 124 | + fontWeight: FontWeight.w500, | ||
| 125 | + height: 1.2, | ||
| 126 | + ), | ||
| 127 | + ), | ||
| 128 | + const SizedBox(height: 25), | ||
| 129 | + SleepSummaryCard( | ||
| 130 | + report: report, | ||
| 131 | + onQualityInfoTap: () => showSleepQualityDialog(context), | ||
| 132 | + ), | ||
| 133 | + const SizedBox(height: 12), | ||
| 134 | + SleepHeartRateCard( | ||
| 135 | + summary: report?.heartRate ?? const SleepHeartRateSummary(), | ||
| 136 | + ), | ||
| 137 | + ], | ||
| 138 | + ); | ||
| 139 | + } | ||
| 140 | +} | ||
| 141 | + | ||
| 120 | class _DateSwitcher extends StatelessWidget { | 142 | class _DateSwitcher extends StatelessWidget { |
| 121 | const _DateSwitcher({required this.logic}); | 143 | const _DateSwitcher({required this.logic}); |
| 122 | 144 |
| @@ -2,6 +2,7 @@ import 'package:fl_chart/fl_chart.dart'; | @@ -2,6 +2,7 @@ import 'package:fl_chart/fl_chart.dart'; | ||
| 2 | import 'package:flutter/material.dart'; | 2 | import 'package:flutter/material.dart'; |
| 3 | import 'package:intl/intl.dart'; | 3 | import 'package:intl/intl.dart'; |
| 4 | 4 | ||
| 5 | +import '../../report_common/widgets/health_report_subject_scope.dart'; | ||
| 5 | import '../models/sleep_report_models.dart'; | 6 | import '../models/sleep_report_models.dart'; |
| 6 | 7 | ||
| 7 | class SleepHeartRateCard extends StatelessWidget { | 8 | class SleepHeartRateCard extends StatelessWidget { |
| @@ -29,9 +30,9 @@ class SleepHeartRateCard extends StatelessWidget { | @@ -29,9 +30,9 @@ class SleepHeartRateCard extends StatelessWidget { | ||
| 29 | child: Column( | 30 | child: Column( |
| 30 | crossAxisAlignment: CrossAxisAlignment.start, | 31 | crossAxisAlignment: CrossAxisAlignment.start, |
| 31 | children: [ | 32 | children: [ |
| 32 | - const Text( | ||
| 33 | - '睡眠时心率', | ||
| 34 | - style: TextStyle( | 33 | + Text( |
| 34 | + HealthReportSubjectScope.titleOf(context, '睡眠时心率'), | ||
| 35 | + style: const TextStyle( | ||
| 35 | color: _h1, | 36 | color: _h1, |
| 36 | fontSize: 16, | 37 | fontSize: 16, |
| 37 | fontWeight: FontWeight.w600, | 38 | fontWeight: FontWeight.w600, |
| 1 | -import 'package:fl_chart/fl_chart.dart'; | ||
| 2 | import 'package:doublefeel_flutter/r.dart'; | 1 | import 'package:doublefeel_flutter/r.dart'; |
| 2 | +import 'package:fl_chart/fl_chart.dart'; | ||
| 3 | import 'package:flutter/material.dart'; | 3 | import 'package:flutter/material.dart'; |
| 4 | 4 | ||
| 5 | +import '../../report_common/widgets/health_report_subject_scope.dart'; | ||
| 5 | import '../models/sleep_report_models.dart'; | 6 | import '../models/sleep_report_models.dart'; |
| 6 | 7 | ||
| 7 | class SleepWeekReportView extends StatelessWidget { | 8 | class SleepWeekReportView extends StatelessWidget { |
| @@ -96,7 +97,7 @@ class _SleepPeriodTrendContent extends StatelessWidget { | @@ -96,7 +97,7 @@ class _SleepPeriodTrendContent extends StatelessWidget { | ||
| 96 | ), | 97 | ), |
| 97 | const SizedBox(height: 20), | 98 | const SizedBox(height: 20), |
| 98 | _TrendCard( | 99 | _TrendCard( |
| 99 | - title: '睡眠时长', | 100 | + title: HealthReportSubjectScope.titleOf(context, '睡眠时长'), |
| 100 | chart: _DurationChart(days: days, compact: compact), | 101 | chart: _DurationChart(days: days, compact: compact), |
| 101 | leftMetric: _ExtremeMetric.duration( | 102 | leftMetric: _ExtremeMetric.duration( |
| 102 | title: '睡眠最长', | 103 | title: '睡眠最长', |
| @@ -111,7 +112,7 @@ class _SleepPeriodTrendContent extends StatelessWidget { | @@ -111,7 +112,7 @@ class _SleepPeriodTrendContent extends StatelessWidget { | ||
| 111 | ), | 112 | ), |
| 112 | const SizedBox(height: 12), | 113 | const SizedBox(height: 12), |
| 113 | _TrendCard( | 114 | _TrendCard( |
| 114 | - title: '睡眠质量', | 115 | + title: HealthReportSubjectScope.titleOf(context, '睡眠质量'), |
| 115 | chart: _QualityChart(days: days, compact: compact), | 116 | chart: _QualityChart(days: days, compact: compact), |
| 116 | leftMetric: _ExtremeMetric.score( | 117 | leftMetric: _ExtremeMetric.score( |
| 117 | title: '质量最佳', | 118 | title: '质量最佳', |
| @@ -126,7 +127,7 @@ class _SleepPeriodTrendContent extends StatelessWidget { | @@ -126,7 +127,7 @@ class _SleepPeriodTrendContent extends StatelessWidget { | ||
| 126 | ), | 127 | ), |
| 127 | const SizedBox(height: 12), | 128 | const SizedBox(height: 12), |
| 128 | _TrendCard( | 129 | _TrendCard( |
| 129 | - title: '入睡时间', | 130 | + title: HealthReportSubjectScope.titleOf(context, '入睡时间'), |
| 130 | chart: _BedtimeChart(days: days, compact: compact), | 131 | chart: _BedtimeChart(days: days, compact: compact), |
| 131 | leftMetric: _ExtremeMetric.time( | 132 | leftMetric: _ExtremeMetric.time( |
| 132 | title: '最早入睡', | 133 | title: '最早入睡', |
| @@ -8,11 +8,9 @@ import '../modules/devtools/views/route_list_view.dart'; | @@ -8,11 +8,9 @@ import '../modules/devtools/views/route_list_view.dart'; | ||
| 8 | import '../modules/feedback/feedback_list/bindings/feedback_list_binding.dart'; | 8 | import '../modules/feedback/feedback_list/bindings/feedback_list_binding.dart'; |
| 9 | import '../modules/feedback/feedback_list/views/feedback_list_view.dart'; | 9 | import '../modules/feedback/feedback_list/views/feedback_list_view.dart'; |
| 10 | import '../modules/friends/bindings/add_friend_binding.dart'; | 10 | import '../modules/friends/bindings/add_friend_binding.dart'; |
| 11 | -import '../modules/friends/bindings/select_friend_binding.dart'; | ||
| 12 | import '../modules/friends/bindings/friend_trend_binding.dart'; | 11 | import '../modules/friends/bindings/friend_trend_binding.dart'; |
| 13 | import '../modules/friends/views/add_friend_view.dart'; | 12 | import '../modules/friends/views/add_friend_view.dart'; |
| 14 | import '../modules/friends/views/friend_trend_view.dart'; | 13 | import '../modules/friends/views/friend_trend_view.dart'; |
| 15 | -import '../modules/friends/views/select_friend_view.dart'; | ||
| 16 | import '../modules/help/bindings/help_binding.dart'; | 14 | import '../modules/help/bindings/help_binding.dart'; |
| 17 | import '../modules/help/views/help_view.dart'; | 15 | import '../modules/help/views/help_view.dart'; |
| 18 | import '../modules/home/bindings/home_binding.dart'; | 16 | import '../modules/home/bindings/home_binding.dart'; |
| @@ -108,11 +106,6 @@ abstract final class AppPages { | @@ -108,11 +106,6 @@ abstract final class AppPages { | ||
| 108 | binding: AddFriendBinding(), | 106 | binding: AddFriendBinding(), |
| 109 | ), | 107 | ), |
| 110 | GetPage( | 108 | GetPage( |
| 111 | - name: Routes.SELECT_FRIEND, | ||
| 112 | - page: () => const SelectFriendView(), | ||
| 113 | - binding: SelectFriendBinding(), | ||
| 114 | - ), | ||
| 115 | - GetPage( | ||
| 116 | name: Routes.FRIEND_TREND, | 109 | name: Routes.FRIEND_TREND, |
| 117 | page: () => const FriendTrendView(), | 110 | page: () => const FriendTrendView(), |
| 118 | binding: FriendTrendBinding(), | 111 | binding: FriendTrendBinding(), |
| @@ -8,7 +8,6 @@ abstract class Routes { | @@ -8,7 +8,6 @@ abstract class Routes { | ||
| 8 | static const ROUTE_LIST = _Paths.ROUTE_LIST; | 8 | static const ROUTE_LIST = _Paths.ROUTE_LIST; |
| 9 | static const PURCHASE = _Paths.PURCHASE; | 9 | static const PURCHASE = _Paths.PURCHASE; |
| 10 | static const ADD_FRIEND = _Paths.ADD_FRIEND; | 10 | static const ADD_FRIEND = _Paths.ADD_FRIEND; |
| 11 | - static const SELECT_FRIEND = _Paths.SELECT_FRIEND; | ||
| 12 | static const PREMIUM_ACTIVATED = _Paths.PREMIUM_ACTIVATED; | 11 | static const PREMIUM_ACTIVATED = _Paths.PREMIUM_ACTIVATED; |
| 13 | static const PRIVACY_SETTINGS = _Paths.PRIVACY_SETTINGS; | 12 | static const PRIVACY_SETTINGS = _Paths.PRIVACY_SETTINGS; |
| 14 | static const HELP = _Paths.HELP; | 13 | static const HELP = _Paths.HELP; |
| @@ -29,7 +28,6 @@ abstract class _Paths { | @@ -29,7 +28,6 @@ abstract class _Paths { | ||
| 29 | static const ROUTE_LIST = '/route-list'; | 28 | static const ROUTE_LIST = '/route-list'; |
| 30 | static const PURCHASE = '/purchase'; | 29 | static const PURCHASE = '/purchase'; |
| 31 | static const ADD_FRIEND = '/add-friend'; | 30 | static const ADD_FRIEND = '/add-friend'; |
| 32 | - static const SELECT_FRIEND = '/select-friend'; | ||
| 33 | static const PREMIUM_ACTIVATED = '/premium-activated'; | 31 | static const PREMIUM_ACTIVATED = '/premium-activated'; |
| 34 | static const PRIVACY_SETTINGS = '/privacy-settings'; | 32 | static const PRIVACY_SETTINGS = '/privacy-settings'; |
| 35 | static const HELP = '/help'; | 33 | static const HELP = '/help'; |
| @@ -20,6 +20,9 @@ class R { | @@ -20,6 +20,9 @@ class R { | ||
| 20 | static final String assetsImagesProIcon = 'assets/images/common/ic_pro.webp'; | 20 | static final String assetsImagesProIcon = 'assets/images/common/ic_pro.webp'; |
| 21 | static final String assetsImagesHealthBedIcon = | 21 | static final String assetsImagesHealthBedIcon = |
| 22 | 'assets/images/common/ic_health_bed.webp'; | 22 | 'assets/images/common/ic_health_bed.webp'; |
| 23 | + static final String assetsImagesLockIcon = 'assets/images/common/ic_lock.png'; | ||
| 24 | + static final String assetsImagesTrendProIcon = | ||
| 25 | + 'assets/images/common/ic_trend_pro.webp'; | ||
| 23 | 26 | ||
| 24 | // friends | 27 | // friends |
| 25 | static final String assetsImagesFriendsFriendAddMeIllustration = | 28 | static final String assetsImagesFriendsFriendAddMeIllustration = |
| 1 | +import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/app/modules/friends/controllers/select_friend_logic.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/app/modules/friends/views/select_friend_view.dart'; | ||
| 4 | +import 'package:doublefeel_flutter/app/modules/friends/widgets/friend_health_card.dart'; | ||
| 5 | +import 'package:flutter/material.dart'; | ||
| 6 | +import 'package:flutter_test/flutter_test.dart'; | ||
| 7 | +import 'package:get/get.dart'; | ||
| 8 | + | ||
| 9 | +void main() { | ||
| 10 | + late FriendsController friendsController; | ||
| 11 | + | ||
| 12 | + setUp(() { | ||
| 13 | + friendsController = FriendsController(); | ||
| 14 | + friendsController.friends.assignAll(_friends); | ||
| 15 | + }); | ||
| 16 | + | ||
| 17 | + test('sync moves the watch-face selection to the selected friend', () { | ||
| 18 | + final logic = SelectFriendLogic(friendsController); | ||
| 19 | + | ||
| 20 | + logic.selectFriendAt(1); | ||
| 21 | + final didSync = logic.syncSelectedFriendToWatchFace(); | ||
| 22 | + | ||
| 23 | + expect(didSync, isTrue); | ||
| 24 | + expect(friendsController.friends[0].isOnWatchFace, isFalse); | ||
| 25 | + expect(friendsController.friends[1].isOnWatchFace, isTrue); | ||
| 26 | + }); | ||
| 27 | + | ||
| 28 | + testWidgets('tapping a friend updates the selected card', (tester) async { | ||
| 29 | + await tester.pumpWidget( | ||
| 30 | + GetMaterialApp( | ||
| 31 | + home: Scaffold( | ||
| 32 | + body: SelectFriendView(friendsController: friendsController), | ||
| 33 | + ), | ||
| 34 | + ), | ||
| 35 | + ); | ||
| 36 | + await tester.pump(); | ||
| 37 | + | ||
| 38 | + var cards = tester.widgetList<FriendHealthCard>( | ||
| 39 | + find.byType(FriendHealthCard), | ||
| 40 | + ); | ||
| 41 | + expect(cards.map((card) => card.isSelected), [isTrue, isFalse]); | ||
| 42 | + | ||
| 43 | + await tester.tap(find.text('Bob')); | ||
| 44 | + await tester.pump(); | ||
| 45 | + | ||
| 46 | + cards = tester.widgetList<FriendHealthCard>(find.byType(FriendHealthCard)); | ||
| 47 | + expect(cards.map((card) => card.isSelected), [isFalse, isTrue]); | ||
| 48 | + }); | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +const _friends = [ | ||
| 52 | + FriendHealthData( | ||
| 53 | + name: 'Alice', | ||
| 54 | + updatedAt: '', | ||
| 55 | + sleepQualityScore: null, | ||
| 56 | + steps: null, | ||
| 57 | + statusText: '', | ||
| 58 | + stressValue: null, | ||
| 59 | + isOnWatchFace: true, | ||
| 60 | + ), | ||
| 61 | + FriendHealthData( | ||
| 62 | + name: 'Bob', | ||
| 63 | + updatedAt: '', | ||
| 64 | + sleepQualityScore: null, | ||
| 65 | + steps: null, | ||
| 66 | + statusText: '', | ||
| 67 | + stressValue: null, | ||
| 68 | + ), | ||
| 69 | +]; |
-
Please register or login to post a comment