Commit bdad0c115786e47f94f4018d4741c02c19f1b5fb

Authored by 刘宏哲
1 parent 44701675

feat(app): add friends module

Showing 25 changed files with 858 additions and 336 deletions
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 +];