Commit 06be138c6f580b12c893f5ac052c6bbab643c6f1

Authored by 刘宏哲
1 parent 34d92c22

feat(app): bug fixed

@@ -7,43 +7,32 @@ import 'package:doublefeel_flutter/app/utils/dialog_utils.dart'; @@ -7,43 +7,32 @@ import 'package:doublefeel_flutter/app/utils/dialog_utils.dart';
7 import 'package:doublefeel_flutter/core/error/app_error.dart'; 7 import 'package:doublefeel_flutter/core/error/app_error.dart';
8 import 'package:doublefeel_flutter/core/logging/app_logger.dart'; 8 import 'package:doublefeel_flutter/core/logging/app_logger.dart';
9 import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; 9 import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
10 -import 'package:doublefeel_flutter/core/network/api/health_api.dart';  
11 import 'package:doublefeel_flutter/core/services/thinking_data_service.dart'; 10 import 'package:doublefeel_flutter/core/services/thinking_data_service.dart';
12 import 'package:doublefeel_flutter/core/util/app_toast.dart'; 11 import 'package:doublefeel_flutter/core/util/app_toast.dart';
13 -import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';  
14 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 12 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
15 import 'package:doublefeel_flutter/pigeon/platform_api.g.dart'; 13 import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
16 import 'package:get/get.dart'; 14 import 'package:get/get.dart';
17 15
18 import '../data/friends_repository.dart'; 16 import '../data/friends_repository.dart';
19 -import '../data/self_health_repository.dart';  
20 import '../models/friend_health_data.dart'; 17 import '../models/friend_health_data.dart';
21 18
22 class FriendsController extends GetxController { 19 class FriendsController extends GetxController {
23 FriendsController({ 20 FriendsController({
24 FriendsRepository? repository, 21 FriendsRepository? repository,
25 - SelfHealthRepository? selfHealthRepository,  
26 Future<void> Function(WatchAppOtherInfo info)? updateWatchOtherUserInfo, 22 Future<void> Function(WatchAppOtherInfo info)? updateWatchOtherUserInfo,
27 }) : _repository = 23 }) : _repository =
28 repository ?? FriendsRepositoryImpl(Get.find<FriendApi>()), 24 repository ?? FriendsRepositoryImpl(Get.find<FriendApi>()),
29 - _selfHealthRepository = selfHealthRepository ??  
30 - SelfHealthRepositoryImpl(Get.find<HealthApi>()),  
31 _updateWatchOtherUserInfo = updateWatchOtherUserInfo ?? 25 _updateWatchOtherUserInfo = updateWatchOtherUserInfo ??
32 ((info) => PlatformHostApi().refreshWatchAppAndWidgets()); 26 ((info) => PlatformHostApi().refreshWatchAppAndWidgets());
33 27
34 static const maxFriends = 10; 28 static const maxFriends = 10;
35 29
36 final FriendsRepository _repository; 30 final FriendsRepository _repository;
37 - final SelfHealthRepository _selfHealthRepository;  
38 final Future<void> Function(WatchAppOtherInfo info) _updateWatchOtherUserInfo; 31 final Future<void> Function(WatchAppOtherInfo info) _updateWatchOtherUserInfo;
39 final friends = <FriendHealthData>[].obs; 32 final friends = <FriendHealthData>[].obs;
40 final isLoading = false.obs; 33 final isLoading = false.obs;
41 - final selfHealthData = Rxn<V2HealthData>();  
42 - final selfStressScore = Rxn<V2StressScore>();  
43 - final selfHealthUpdatedAt = Rxn<DateTime>();  
44 - final isSelfHealthLoading = false.obs; 34 + final selfHealthData = Rxn<SelfFriendHealthData>();
45 Future<void>? _friendsRequest; 35 Future<void>? _friendsRequest;
46 - Future<void>? _selfHealthRequest;  
47 bool _isPageVisible = false; 36 bool _isPageVisible = false;
48 37
49 bool get isFull => friends.length >= maxFriends; 38 bool get isFull => friends.length >= maxFriends;
@@ -64,9 +53,7 @@ class FriendsController extends GetxController { @@ -64,9 +53,7 @@ class FriendsController extends GetxController {
64 _isPageVisible = false; 53 _isPageVisible = false;
65 } 54 }
66 55
67 - Future<void> refreshData() {  
68 - return Future.wait([loadFriends(), loadSelfHealth()]);  
69 - } 56 + Future<void> refreshData() => loadFriends();
70 57
71 Future<void> loadFriends() { 58 Future<void> loadFriends() {
72 return _friendsRequest ??= _loadFriends(); 59 return _friendsRequest ??= _loadFriends();
@@ -75,7 +62,9 @@ class FriendsController extends GetxController { @@ -75,7 +62,9 @@ class FriendsController extends GetxController {
75 Future<void> _loadFriends() async { 62 Future<void> _loadFriends() async {
76 isLoading.value = true; 63 isLoading.value = true;
77 try { 64 try {
78 - friends.assignAll(await _repository.getFriends()); 65 + final data = await _repository.getFriendList();
  66 + friends.assignAll(data.friends);
  67 + selfHealthData.value = data.selfHealthData;
79 } catch (error, stackTrace) { 68 } catch (error, stackTrace) {
80 AppLogger.e('FriendsController.loadFriends failed', error, stackTrace); 69 AppLogger.e('FriendsController.loadFriends failed', error, stackTrace);
81 } finally { 70 } finally {
@@ -84,50 +73,6 @@ class FriendsController extends GetxController { @@ -84,50 +73,6 @@ class FriendsController extends GetxController {
84 } 73 }
85 } 74 }
86 75
87 - Future<void> loadSelfHealth() {  
88 - return _selfHealthRequest ??= _loadSelfHealth();  
89 - }  
90 -  
91 - Future<void> _loadSelfHealth() async {  
92 - isSelfHealthLoading.value = true;  
93 - try {  
94 - final today = DateTime.now();  
95 - await Future.wait([  
96 - _loadSelfHealthData(today),  
97 - _loadSelfStressScore(today),  
98 - ]);  
99 - } finally {  
100 - isSelfHealthLoading.value = false;  
101 - _selfHealthRequest = null;  
102 - }  
103 - }  
104 -  
105 - Future<void> _loadSelfHealthData(DateTime date) async {  
106 - try {  
107 - selfHealthData.value = await _selfHealthRepository.getHealthData(date);  
108 - selfHealthUpdatedAt.value = DateTime.now();  
109 - } catch (error, stackTrace) {  
110 - AppLogger.e(  
111 - 'FriendsController.loadSelfHealthData failed',  
112 - error,  
113 - stackTrace,  
114 - );  
115 - }  
116 - }  
117 -  
118 - Future<void> _loadSelfStressScore(DateTime date) async {  
119 - try {  
120 - selfStressScore.value = await _selfHealthRepository.getStressScore(date);  
121 - selfHealthUpdatedAt.value = DateTime.now();  
122 - } catch (error, stackTrace) {  
123 - AppLogger.e(  
124 - 'FriendsController.loadSelfStressScore failed',  
125 - error,  
126 - stackTrace,  
127 - );  
128 - }  
129 - }  
130 -  
131 Future<void> showEditRemarkDialog(FriendHealthData friend) async { 76 Future<void> showEditRemarkDialog(FriendHealthData friend) async {
132 final result = await DialogUtils.showInputDialog( 77 final result = await DialogUtils.showInputDialog(
133 InputDialogMetaData( 78 InputDialogMetaData(
@@ -11,6 +11,8 @@ import '../models/friend_stress_state.dart'; @@ -11,6 +11,8 @@ import '../models/friend_stress_state.dart';
11 abstract class FriendsRepository { 11 abstract class FriendsRepository {
12 Future<List<FriendHealthData>> getFriends({bool withHealthData = true}); 12 Future<List<FriendHealthData>> getFriends({bool withHealthData = true});
13 13
  14 + Future<FriendListData> getFriendList({bool withHealthData = true});
  15 +
14 Future<void> updateRemark(int userId, String remark); 16 Future<void> updateRemark(int userId, String remark);
15 17
16 Future<void> selectWatchFaceFriend(int userId); 18 Future<void> selectWatchFaceFriend(int userId);
@@ -18,6 +20,16 @@ abstract class FriendsRepository { @@ -18,6 +20,16 @@ abstract class FriendsRepository {
18 Future<void> deleteFriend(int userId); 20 Future<void> deleteFriend(int userId);
19 } 21 }
20 22
  23 +class FriendListData {
  24 + const FriendListData({
  25 + required this.friends,
  26 + required this.selfHealthData,
  27 + });
  28 +
  29 + final List<FriendHealthData> friends;
  30 + final SelfFriendHealthData? selfHealthData;
  31 +}
  32 +
21 class FriendsRepositoryImpl implements FriendsRepository { 33 class FriendsRepositoryImpl implements FriendsRepository {
22 const FriendsRepositoryImpl(this._friendApi); 34 const FriendsRepositoryImpl(this._friendApi);
23 35
@@ -26,8 +38,17 @@ class FriendsRepositoryImpl implements FriendsRepository { @@ -26,8 +38,17 @@ class FriendsRepositoryImpl implements FriendsRepository {
26 @override 38 @override
27 Future<List<FriendHealthData>> getFriends( 39 Future<List<FriendHealthData>> getFriends(
28 {bool withHealthData = true}) async { 40 {bool withHealthData = true}) async {
  41 + final data = await getFriendList(withHealthData: withHealthData);
  42 + return data.friends;
  43 + }
  44 +
  45 + @override
  46 + Future<FriendListData> getFriendList({bool withHealthData = true}) async {
29 return switch (await _friendApi.friendList(withHealthData)) { 47 return switch (await _friendApi.friendList(withHealthData)) {
30 - AppSuccess(:final data) => data.list.map(_mapFriend).toList(), 48 + AppSuccess(:final data) => FriendListData(
  49 + friends: data.list.map(_mapFriend).toList(),
  50 + selfHealthData: _mapSelfHealthData(data.healthData),
  51 + ),
31 AppFailure(:final error) => throw error, 52 AppFailure(:final error) => throw error,
32 }; 53 };
33 } 54 }
@@ -75,7 +96,7 @@ class FriendsRepositoryImpl implements FriendsRepository { @@ -75,7 +96,7 @@ class FriendsRepositoryImpl implements FriendsRepository {
75 ? l10n.friendsUnknownFriend 96 ? l10n.friendsUnknownFriend
76 : nickname, 97 : nickname,
77 remark: remark == null || remark.isEmpty ? null : remark, 98 remark: remark == null || remark.isEmpty ? null : remark,
78 - updatedAt: _updatedAt(friend.updateTime), 99 + updatedAt: _updatedAt(healthData?.lastDataTime ?? friend.updateTime),
79 sleepQualityScore: healthData?.sleepEvaluate, 100 sleepQualityScore: healthData?.sleepEvaluate,
80 steps: healthData?.totalSteps == null 101 steps: healthData?.totalSteps == null
81 ? null 102 ? null
@@ -85,11 +106,25 @@ class FriendsRepositoryImpl implements FriendsRepository { @@ -85,11 +106,25 @@ class FriendsRepositoryImpl implements FriendsRepository {
85 ); 106 );
86 } 107 }
87 108
88 - String _updatedAt(int? timestamp) { 109 + SelfFriendHealthData? _mapSelfHealthData(
  110 + api_models.FriendHealthData? healthData,
  111 + ) {
  112 + if (healthData == null) return null;
  113 + return SelfFriendHealthData(
  114 + updatedAt: _updatedAt(healthData.lastDataTime),
  115 + sleepQualityScore: healthData.sleepEvaluate,
  116 + steps: healthData.totalSteps == null
  117 + ? null
  118 + : l10n.friendsStepCount(healthData.totalSteps!),
  119 + stressState: FriendStressState.fromValue(healthData.hrvState),
  120 + );
  121 + }
  122 +
  123 + String _updatedAt(num? timestamp) {
89 if (timestamp == null) return l10n.friendsWaitingForData; 124 if (timestamp == null) return l10n.friendsWaitingForData;
90 final milliseconds = 125 final milliseconds =
91 timestamp < 1000000000000 ? timestamp * 1000 : timestamp; 126 timestamp < 1000000000000 ? timestamp * 1000 : timestamp;
92 - final time = DateTime.fromMillisecondsSinceEpoch(milliseconds); 127 + final time = DateTime.fromMillisecondsSinceEpoch(milliseconds.toInt());
93 return l10n.friendsUpdatedAt(DateFormat('HH:mm').format(time)); 128 return l10n.friendsUpdatedAt(DateFormat('HH:mm').format(time));
94 } 129 }
95 } 130 }
@@ -61,3 +61,17 @@ class FriendHealthData { @@ -61,3 +61,17 @@ class FriendHealthData {
61 ); 61 );
62 } 62 }
63 } 63 }
  64 +
  65 +class SelfFriendHealthData {
  66 + const SelfFriendHealthData({
  67 + required this.updatedAt,
  68 + required this.sleepQualityScore,
  69 + required this.steps,
  70 + required this.stressState,
  71 + });
  72 +
  73 + final String updatedAt;
  74 + final int? sleepQualityScore;
  75 + final String? steps;
  76 + final FriendStressState stressState;
  77 +}
  1 +import 'package:cached_network_image/cached_network_image.dart';
1 import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'; 2 import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
2 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
3 import 'package:flutter/material.dart'; 4 import 'package:flutter/material.dart';
@@ -97,28 +98,25 @@ class _FriendTrendTitle extends StatelessWidget { @@ -97,28 +98,25 @@ class _FriendTrendTitle extends StatelessWidget {
97 98
98 @override 99 @override
99 Widget build(BuildContext context) { 100 Widget build(BuildContext context) {
  101 + final imageUrl = avatarUrl?.trim();
100 return Row( 102 return Row(
101 children: [ 103 children: [
102 Container( 104 Container(
103 width: 28, 105 width: 28,
104 height: 28, 106 height: 28,
105 - clipBehavior: Clip.antiAlias,  
106 - decoration: BoxDecoration(  
107 - shape: BoxShape.circle,  
108 - border: Border.all(color: Colors.white, width: 0.8),  
109 - gradient: const LinearGradient(  
110 - begin: Alignment.topLeft,  
111 - end: Alignment.bottomRight,  
112 - colors: [Color(0xFFB7A6FF), Color(0xFFFFD7EA)], 107 + decoration: ShapeDecoration(
  108 + image: imageUrl?.isNotEmpty == true
  109 + ? DecorationImage(
  110 + image: CachedNetworkImageProvider(imageUrl!),
  111 + fit: BoxFit.cover,
  112 + )
  113 + : null,
  114 + shape: RoundedRectangleBorder(
  115 + side: const BorderSide(width: 0.78, color: Colors.white),
  116 + borderRadius: BorderRadius.circular(35.78),
113 ), 117 ),
114 ), 118 ),
115 - child: avatarUrl?.trim().isNotEmpty == true  
116 - ? Image.network(  
117 - avatarUrl!,  
118 - fit: BoxFit.cover,  
119 - errorBuilder: (_, __, ___) => const _AvatarFallback(),  
120 - )  
121 - : const _AvatarFallback(), 119 + child: imageUrl?.isNotEmpty == true ? null : const _AvatarFallback(),
122 ), 120 ),
123 const SizedBox(width: 8), 121 const SizedBox(width: 8),
124 Expanded( 122 Expanded(
@@ -7,7 +7,6 @@ import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; @@ -7,7 +7,6 @@ import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
7 import 'package:doublefeel_flutter/r.dart'; 7 import 'package:doublefeel_flutter/r.dart';
8 import 'package:flutter/material.dart'; 8 import 'package:flutter/material.dart';
9 import 'package:get/get.dart'; 9 import 'package:get/get.dart';
10 -import 'package:intl/intl.dart';  
11 10
12 import '../controllers/friends_controller.dart'; 11 import '../controllers/friends_controller.dart';
13 import '../models/friend_health_data.dart'; 12 import '../models/friend_health_data.dart';
@@ -39,37 +38,23 @@ class FriendsTab extends GetView<FriendsController> { @@ -39,37 +38,23 @@ class FriendsTab extends GetView<FriendsController> {
39 final hasFriends = friends.isNotEmpty; 38 final hasFriends = friends.isNotEmpty;
40 final isFull = controller.isFull; 39 final isFull = controller.isFull;
41 final healthData = controller.selfHealthData.value; 40 final healthData = controller.selfHealthData.value;
42 - final stressScore = controller.selfStressScore.value;  
43 - final updatedAt = controller.selfHealthUpdatedAt.value;  
44 final currentUser = 41 final currentUser =
45 userPreferences.preferences.value.meUserInfo; 42 userPreferences.preferences.value.meUserInfo;
46 final isSelfInitialLoading = 43 final isSelfInitialLoading =
47 - controller.isSelfHealthLoading.value &&  
48 - healthData == null &&  
49 - stressScore == null; 44 + controller.isLoading.value && healthData == null;
50 final isFriendsInitialLoading = 45 final isFriendsInitialLoading =
51 controller.isLoading.value && !hasFriends; 46 controller.isLoading.value && !hasFriends;
52 - final selfStressState = FriendStressState.fromValue(  
53 - stressScore?.comprehensiveScore,  
54 - );  
55 final selfHealthCard = _SelfHealthCard( 47 final selfHealthCard = _SelfHealthCard(
56 name: currentUser?.nickname?.trim().isNotEmpty == true 48 name: currentUser?.nickname?.trim().isNotEmpty == true
57 ? currentUser!.nickname!.trim() 49 ? currentUser!.nickname!.trim()
58 : '-', 50 : '-',
59 avatarUrl: currentUser?.avatar, 51 avatarUrl: currentUser?.avatar,
60 - updatedAt: updatedAt == null  
61 - ? context.l10n.friendsWaitingForData  
62 - : context.l10n.friendsUpdatedAt(  
63 - DateFormat('HH:mm').format(updatedAt),  
64 - ),  
65 - sleepQualityScore:  
66 - _normalizedScore(healthData?.sleepScore),  
67 - steps: healthData?.steps == null  
68 - ? null  
69 - : context.l10n.friendsStepCount(  
70 - healthData!.steps!,  
71 - ),  
72 - stressState: selfStressState, 52 + updatedAt: healthData?.updatedAt ??
  53 + context.l10n.friendsWaitingForData,
  54 + sleepQualityScore: healthData?.sleepQualityScore,
  55 + steps: healthData?.steps,
  56 + stressState:
  57 + healthData?.stressState ?? FriendStressState.wait,
73 isLoading: isSelfInitialLoading, 58 isLoading: isSelfInitialLoading,
74 ); 59 );
75 60
@@ -266,11 +251,6 @@ class FriendsTab extends GetView<FriendsController> { @@ -266,11 +251,6 @@ class FriendsTab extends GetView<FriendsController> {
266 controller.applyWatchFaceSelection(friend); 251 controller.applyWatchFaceSelection(friend);
267 } 252 }
268 } 253 }
269 -  
270 - int? _normalizedScore(double? value) {  
271 - if (value == null || !value.isFinite) return null;  
272 - return value.round().clamp(0, 100);  
273 - }  
274 } 254 }
275 255
276 class _SelfHealthCard extends StatelessWidget { 256 class _SelfHealthCard extends StatelessWidget {
@@ -356,8 +356,8 @@ class _StatusFigure extends StatelessWidget { @@ -356,8 +356,8 @@ class _StatusFigure extends StatelessWidget {
356 ), 356 ),
357 ), 357 ),
358 Positioned( 358 Positioned(
359 - left: 22,  
360 - top: 24, 359 + left: 25,
  360 + top: 27,
361 child: Image.asset( 361 child: Image.asset(
362 stressState.iconPath, 362 stressState.iconPath,
363 width: 72, 363 width: 72,
@@ -20,6 +20,7 @@ class HealthTrendContent extends StatefulWidget { @@ -20,6 +20,7 @@ class HealthTrendContent extends StatefulWidget {
20 super.key, 20 super.key,
21 required this.query, 21 required this.query,
22 required this.selectedTypeIndex, 22 required this.selectedTypeIndex,
  23 + this.refreshToken = 0,
23 required this.onTypeChanged, 24 required this.onTypeChanged,
24 required this.onPeriodChanged, 25 required this.onPeriodChanged,
25 required this.onDateChanged, 26 required this.onDateChanged,
@@ -28,6 +29,7 @@ class HealthTrendContent extends StatefulWidget { @@ -28,6 +29,7 @@ class HealthTrendContent extends StatefulWidget {
28 29
29 final HealthReportQuery query; 30 final HealthReportQuery query;
30 final int selectedTypeIndex; 31 final int selectedTypeIndex;
  32 + final int refreshToken;
31 final ValueChanged<int> onTypeChanged; 33 final ValueChanged<int> onTypeChanged;
32 final ValueChanged<ReportPeriod> onPeriodChanged; 34 final ValueChanged<ReportPeriod> onPeriodChanged;
33 final ValueChanged<DateTime> onDateChanged; 35 final ValueChanged<DateTime> onDateChanged;
@@ -117,6 +119,8 @@ class _HealthTrendContentState extends State<HealthTrendContent> @@ -117,6 +119,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
117 _KeepAliveWrapper( 119 _KeepAliveWrapper(
118 child: _HrvTrendSection( 120 child: _HrvTrendSection(
119 query: _queryForType(0), 121 query: _queryForType(0),
  122 + refreshToken: widget.refreshToken,
  123 + isSelected: widget.selectedTypeIndex == 0,
120 isVip: isVip, 124 isVip: isVip,
121 onSubscribe: openPurchase, 125 onSubscribe: openPurchase,
122 onPeriodChanged: widget.onPeriodChanged, 126 onPeriodChanged: widget.onPeriodChanged,
@@ -126,6 +130,8 @@ class _HealthTrendContentState extends State<HealthTrendContent> @@ -126,6 +130,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
126 _KeepAliveWrapper( 130 _KeepAliveWrapper(
127 child: _ActivityBurnTrendSection( 131 child: _ActivityBurnTrendSection(
128 query: _queryForType(1), 132 query: _queryForType(1),
  133 + refreshToken: widget.refreshToken,
  134 + isSelected: widget.selectedTypeIndex == 1,
129 isVip: isVip, 135 isVip: isVip,
130 onSubscribe: openPurchase, 136 onSubscribe: openPurchase,
131 onPeriodChanged: widget.onPeriodChanged, 137 onPeriodChanged: widget.onPeriodChanged,
@@ -135,6 +141,8 @@ class _HealthTrendContentState extends State<HealthTrendContent> @@ -135,6 +141,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
135 _KeepAliveWrapper( 141 _KeepAliveWrapper(
136 child: _SleepTrendSection( 142 child: _SleepTrendSection(
137 query: _queryForType(2), 143 query: _queryForType(2),
  144 + refreshToken: widget.refreshToken,
  145 + isSelected: widget.selectedTypeIndex == 2,
138 isVip: isVip, 146 isVip: isVip,
139 onSubscribe: openPurchase, 147 onSubscribe: openPurchase,
140 onPeriodChanged: widget.onPeriodChanged, 148 onPeriodChanged: widget.onPeriodChanged,
@@ -154,6 +162,8 @@ class _HealthTrendContentState extends State<HealthTrendContent> @@ -154,6 +162,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
154 class _HrvTrendSection extends StatefulWidget { 162 class _HrvTrendSection extends StatefulWidget {
155 const _HrvTrendSection({ 163 const _HrvTrendSection({
156 required this.query, 164 required this.query,
  165 + required this.refreshToken,
  166 + required this.isSelected,
157 required this.isVip, 167 required this.isVip,
158 required this.onSubscribe, 168 required this.onSubscribe,
159 required this.onPeriodChanged, 169 required this.onPeriodChanged,
@@ -161,6 +171,8 @@ class _HrvTrendSection extends StatefulWidget { @@ -161,6 +171,8 @@ class _HrvTrendSection extends StatefulWidget {
161 }); 171 });
162 172
163 final HealthReportQuery query; 173 final HealthReportQuery query;
  174 + final int refreshToken;
  175 + final bool isSelected;
164 final bool isVip; 176 final bool isVip;
165 final ValueChanged<String> onSubscribe; 177 final ValueChanged<String> onSubscribe;
166 final ValueChanged<ReportPeriod> onPeriodChanged; 178 final ValueChanged<ReportPeriod> onPeriodChanged;
@@ -195,6 +207,9 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> { @@ -195,6 +207,9 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
195 super.didUpdateWidget(oldWidget); 207 super.didUpdateWidget(oldWidget);
196 if (oldWidget.query != widget.query) { 208 if (oldWidget.query != widget.query) {
197 _syncExternalQuery(); 209 _syncExternalQuery();
  210 + } else if (oldWidget.refreshToken != widget.refreshToken &&
  211 + widget.isSelected) {
  212 + _logic.loadReport();
198 } 213 }
199 } 214 }
200 215
@@ -227,6 +242,8 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> { @@ -227,6 +242,8 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
227 class _ActivityBurnTrendSection extends StatefulWidget { 242 class _ActivityBurnTrendSection extends StatefulWidget {
228 const _ActivityBurnTrendSection({ 243 const _ActivityBurnTrendSection({
229 required this.query, 244 required this.query,
  245 + required this.refreshToken,
  246 + required this.isSelected,
230 required this.isVip, 247 required this.isVip,
231 required this.onSubscribe, 248 required this.onSubscribe,
232 required this.onPeriodChanged, 249 required this.onPeriodChanged,
@@ -234,6 +251,8 @@ class _ActivityBurnTrendSection extends StatefulWidget { @@ -234,6 +251,8 @@ class _ActivityBurnTrendSection extends StatefulWidget {
234 }); 251 });
235 252
236 final HealthReportQuery query; 253 final HealthReportQuery query;
  254 + final int refreshToken;
  255 + final bool isSelected;
237 final bool isVip; 256 final bool isVip;
238 final ValueChanged<String> onSubscribe; 257 final ValueChanged<String> onSubscribe;
239 final ValueChanged<ReportPeriod> onPeriodChanged; 258 final ValueChanged<ReportPeriod> onPeriodChanged;
@@ -271,6 +290,9 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { @@ -271,6 +290,9 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
271 super.didUpdateWidget(oldWidget); 290 super.didUpdateWidget(oldWidget);
272 if (oldWidget.query != widget.query) { 291 if (oldWidget.query != widget.query) {
273 _syncExternalQuery(); 292 _syncExternalQuery();
  293 + } else if (oldWidget.refreshToken != widget.refreshToken &&
  294 + widget.isSelected) {
  295 + _logic.loadReport();
274 } 296 }
275 } 297 }
276 298
@@ -303,6 +325,8 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { @@ -303,6 +325,8 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
303 class _SleepTrendSection extends StatefulWidget { 325 class _SleepTrendSection extends StatefulWidget {
304 const _SleepTrendSection({ 326 const _SleepTrendSection({
305 required this.query, 327 required this.query,
  328 + required this.refreshToken,
  329 + required this.isSelected,
306 required this.isVip, 330 required this.isVip,
307 required this.onSubscribe, 331 required this.onSubscribe,
308 required this.onPeriodChanged, 332 required this.onPeriodChanged,
@@ -310,6 +334,8 @@ class _SleepTrendSection extends StatefulWidget { @@ -310,6 +334,8 @@ class _SleepTrendSection extends StatefulWidget {
310 }); 334 });
311 335
312 final HealthReportQuery query; 336 final HealthReportQuery query;
  337 + final int refreshToken;
  338 + final bool isSelected;
313 final bool isVip; 339 final bool isVip;
314 final ValueChanged<String> onSubscribe; 340 final ValueChanged<String> onSubscribe;
315 final ValueChanged<ReportPeriod> onPeriodChanged; 341 final ValueChanged<ReportPeriod> onPeriodChanged;
@@ -346,6 +372,9 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { @@ -346,6 +372,9 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
346 super.didUpdateWidget(oldWidget); 372 super.didUpdateWidget(oldWidget);
347 if (oldWidget.query != widget.query) { 373 if (oldWidget.query != widget.query) {
348 _syncExternalQuery(); 374 _syncExternalQuery();
  375 + } else if (oldWidget.refreshToken != widget.refreshToken &&
  376 + widget.isSelected) {
  377 + _logic.loadReport();
349 } 378 }
350 } 379 }
351 380
1 -import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart';  
2 import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart'; 1 import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart';
3 import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; 2 import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
4 import 'package:doublefeel_flutter/core/network/api/health_api.dart'; 3 import 'package:doublefeel_flutter/core/network/api/health_api.dart';
@@ -6,7 +5,6 @@ import 'package:doublefeel_flutter/core/network/api/pay_api.dart'; @@ -6,7 +5,6 @@ import 'package:doublefeel_flutter/core/network/api/pay_api.dart';
6 import 'package:doublefeel_flutter/core/network/api/theme_api.dart'; 5 import 'package:doublefeel_flutter/core/network/api/theme_api.dart';
7 import 'package:doublefeel_flutter/core/network/api/user_api.dart'; 6 import 'package:doublefeel_flutter/core/network/api/user_api.dart';
8 import 'package:doublefeel_flutter/core/network/api/vip_api.dart'; 7 import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
9 -import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';  
10 import 'package:doublefeel_flutter/core/services/user_state_service.dart'; 8 import 'package:doublefeel_flutter/core/services/user_state_service.dart';
11 import 'package:get/get.dart'; 9 import 'package:get/get.dart';
12 10
@@ -34,7 +32,10 @@ class HomeBinding extends Bindings { @@ -34,7 +32,10 @@ class HomeBinding extends Bindings {
34 Get.find<UserApi>(), Get.find<VipApi>(), Get.find<ThemeApi>()), 32 Get.find<UserApi>(), Get.find<VipApi>(), Get.find<ThemeApi>()),
35 fenix: true, 33 fenix: true,
36 ); 34 );
37 - Get.lazyPut<TrendController>(() => TrendController(), fenix: true); 35 + Get.lazyPut<TrendController>(
  36 + () => TrendController(Get.find<FriendApi>()),
  37 + fenix: true,
  38 + );
38 Get.lazyPut<FriendsController>(() => FriendsController(), fenix: true); 39 Get.lazyPut<FriendsController>(() => FriendsController(), fenix: true);
39 } 40 }
40 } 41 }
  1 +import 'package:flutter/material.dart';
1 import 'package:get/get.dart'; 2 import 'package:get/get.dart';
2 3
  4 +import '../../../../../core/network/api/friend_api.dart';
  5 +import '../../../../../core/result/app_result.dart';
  6 +import '../../../../../data/models/friend/friend_models.dart';
3 import '../../../health_trend/controllers/health_trend_analytics.dart'; 7 import '../../../health_trend/controllers/health_trend_analytics.dart';
4 import '../../../health_trend/controllers/health_trend_control.dart'; 8 import '../../../health_trend/controllers/health_trend_control.dart';
5 import '../../../report_common/models/health_report_query.dart'; 9 import '../../../report_common/models/health_report_query.dart';
6 import '../../../report_common/models/report_period.dart'; 10 import '../../../report_common/models/report_period.dart';
  11 +import '../../widgets/trend/trend_friend_select_bottom_sheet.dart';
7 12
8 enum TrendType { 13 enum TrendType {
9 hrv, 14 hrv,
@@ -16,10 +21,21 @@ enum TrendType { @@ -16,10 +21,21 @@ enum TrendType {
16 /// 趋势页顶层 Controller,仅负责: 21 /// 趋势页顶层 Controller,仅负责:
17 /// 顶层 HRV / 活动 / 睡眠 类型切换 (selectedTypeIndex) 22 /// 顶层 HRV / 活动 / 睡眠 类型切换 (selectedTypeIndex)
18 class TrendController extends GetxController with HealthTrendControl { 23 class TrendController extends GetxController with HealthTrendControl {
  24 + TrendController(this._friendApi);
  25 +
  26 + final FriendApi _friendApi;
19 bool _isPageVisible = false; 27 bool _isPageVisible = false;
  28 + final refreshToken = 0.obs;
20 29
21 // 当前查看的用户。null 表示查看自己;非 null 表示查看指定用户。 30 // 当前查看的用户。null 表示查看自己;非 null 表示查看指定用户。
22 final targetUserId = RxnInt(); 31 final targetUserId = RxnInt();
  32 + final targetFriendInfo = Rxn<FriendItem>();
  33 + final friendsList = <FriendItem>[].obs;
  34 + final isFriendListLoaded = false.obs;
  35 + int friendsListLimit = 10;
  36 +
  37 + bool get canSwitchFriend =>
  38 + isFriendListLoaded.value && friendsList.isNotEmpty;
23 39
24 HealthReportQuery get query => HealthReportQuery( 40 HealthReportQuery get query => HealthReportQuery(
25 targetUserId: targetUserId.value, 41 targetUserId: targetUserId.value,
@@ -34,6 +50,12 @@ class TrendController extends GetxController with HealthTrendControl { @@ -34,6 +50,12 @@ class TrendController extends GetxController with HealthTrendControl {
34 ); 50 );
35 51
36 @override 52 @override
  53 + void onInit() {
  54 + super.onInit();
  55 + _refreshFriendList();
  56 + }
  57 +
  58 + @override
37 void changeType(int index) { 59 void changeType(int index) {
38 final previousIndex = selectedTypeIndex.value; 60 final previousIndex = selectedTypeIndex.value;
39 super.changeType(index); 61 super.changeType(index);
@@ -55,11 +77,71 @@ class TrendController extends GetxController with HealthTrendControl { @@ -55,11 +77,71 @@ class TrendController extends GetxController with HealthTrendControl {
55 void changeTargetUser(int? userId) { 77 void changeTargetUser(int? userId) {
56 if (userId == targetUserId.value) return; 78 if (userId == targetUserId.value) return;
57 targetUserId.value = userId; 79 targetUserId.value = userId;
  80 + refreshToken.value++;
  81 + }
  82 +
  83 + void selectFriend(FriendItem friendInfo) {
  84 + if (friendInfo.friendUserId == targetFriendInfo.value?.friendUserId) {
  85 + return;
  86 + }
  87 + targetFriendInfo.value = friendInfo;
  88 + changeTargetUser(friendInfo.friendUserId);
  89 + }
  90 +
  91 + void selectSelf() {
  92 + if (targetFriendInfo.value == null && targetUserId.value == null) {
  93 + return;
  94 + }
  95 + targetFriendInfo.value = null;
  96 + changeTargetUser(null);
  97 + }
  98 +
  99 + void showFriendListBottomSheet() {
  100 + _refreshFriendList();
  101 + if (friendsList.isEmpty) return;
  102 +
  103 + Get.bottomSheet(
  104 + DraggableScrollableSheet(
  105 + maxChildSize: 0.6,
  106 + initialChildSize: 0.6,
  107 + expand: false,
  108 + snap: true,
  109 + builder: (context, scrollController) {
  110 + return TrendFriendSelectBottomSheet(
  111 + scrollController: scrollController,
  112 + friendsList: friendsList,
  113 + selectedFriend: targetFriendInfo,
  114 + onSelectSelf: selectSelf,
  115 + onSelectFriend: selectFriend,
  116 + );
  117 + },
  118 + ),
  119 + barrierColor: Colors.black.withValues(alpha: 0.7),
  120 + enableDrag: true,
  121 + isScrollControlled: true,
  122 + persistent: false,
  123 + );
  124 + }
  125 +
  126 + void _refreshFriendList() {
  127 + _friendApi.friendList(false).then(
  128 + (res) {
  129 + switch (res) {
  130 + case AppSuccess(:final data):
  131 + friendsList.assignAll(data.list);
  132 + friendsListLimit = data.limit ?? 10;
  133 + isFriendListLoaded.value = true;
  134 + case AppFailure():
  135 + isFriendListLoaded.value = true;
  136 + }
  137 + },
  138 + );
58 } 139 }
59 140
60 void markPageVisible() { 141 void markPageVisible() {
61 if (_isPageVisible) return; 142 if (_isPageVisible) return;
62 _isPageVisible = true; 143 _isPageVisible = true;
  144 + refreshToken.value++;
63 _trackEnterPage(); 145 _trackEnterPage();
64 } 146 }
65 147
@@ -70,7 +152,7 @@ class TrendController extends GetxController with HealthTrendControl { @@ -70,7 +152,7 @@ class TrendController extends GetxController with HealthTrendControl {
70 void _trackEnterPage() { 152 void _trackEnterPage() {
71 HealthTrendAnalytics.trackEnterPage( 153 HealthTrendAnalytics.trackEnterPage(
72 selectedTypeIndex.value, 154 selectedTypeIndex.value,
73 - userRole: '我的', 155 + userRole: targetFriendInfo.value == null ? '我的' : '好友的',
74 ); 156 );
75 } 157 }
76 } 158 }
  1 +import 'package:cached_network_image/cached_network_image.dart';
  2 +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
1 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
2 import 'package:get/get.dart'; 4 import 'package:get/get.dart';
3 5
@@ -33,6 +35,7 @@ class _HomeTrendBody extends GetView<TrendController> { @@ -33,6 +35,7 @@ class _HomeTrendBody extends GetView<TrendController> {
33 query: controller.query, 35 query: controller.query,
34 queryForType: controller.queryForType, 36 queryForType: controller.queryForType,
35 selectedTypeIndex: controller.selectedTypeIndex.value, 37 selectedTypeIndex: controller.selectedTypeIndex.value,
  38 + refreshToken: controller.refreshToken.value,
36 onTypeChanged: controller.changeType, 39 onTypeChanged: controller.changeType,
37 onPeriodChanged: controller.changePeriod, 40 onPeriodChanged: controller.changePeriod,
38 onDateChanged: controller.changeDate, 41 onDateChanged: controller.changeDate,
@@ -41,27 +44,103 @@ class _HomeTrendBody extends GetView<TrendController> { @@ -41,27 +44,103 @@ class _HomeTrendBody extends GetView<TrendController> {
41 } 44 }
42 } 45 }
43 46
44 -class _HomeTrendHeader extends StatelessWidget { 47 +class _HomeTrendHeader extends GetView<TrendController> {
45 const _HomeTrendHeader(); 48 const _HomeTrendHeader();
46 49
47 @override 50 @override
48 Widget build(BuildContext context) { 51 Widget build(BuildContext context) {
49 - return const SizedBox( 52 + return SizedBox(
50 height: 48, 53 height: 48,
51 child: Padding( 54 child: Padding(
52 - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),  
53 - child: Align(  
54 - alignment: Alignment.centerLeft,  
55 - child: Text(  
56 - '趋势',  
57 - style: TextStyle(  
58 - color: Color(0xFF0F0F11),  
59 - fontSize: 24,  
60 - fontWeight: FontWeight.w600, 55 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
  56 + child: Stack(
  57 + alignment: Alignment.center,
  58 + children: [
  59 + const Align(
  60 + alignment: Alignment.centerLeft,
  61 + child: Text(
  62 + '趋势',
  63 + style: TextStyle(
  64 + color: Color(0xFF0F0F11),
  65 + fontSize: 24,
  66 + fontWeight: FontWeight.w600,
  67 + ),
  68 + ),
61 ), 69 ),
62 - ), 70 + Obx(() {
  71 + final friendAvatar = controller.targetFriendInfo.value?.avatar;
  72 + final selfAvatar = Get.find<UserPreferencesStorage>()
  73 + .preferences
  74 + .value
  75 + .meUserInfo
  76 + ?.avatar;
  77 + return _TrendHeaderAvatar(
  78 + avatarUrl: friendAvatar?.trim().isNotEmpty == true
  79 + ? friendAvatar
  80 + : selfAvatar,
  81 + );
  82 + }),
  83 + Obx(
  84 + () => !controller.canSwitchFriend
  85 + ? const SizedBox.shrink()
  86 + : Align(
  87 + alignment: Alignment.centerRight,
  88 + child: IconButton(
  89 + highlightColor: Colors.transparent,
  90 + padding: EdgeInsets.zero,
  91 + onPressed: controller.showFriendListBottomSheet,
  92 + icon: Image.asset(
  93 + 'assets/images/common/ic_replace.png',
  94 + width: 20,
  95 + height: 20,
  96 + ),
  97 + ),
  98 + ),
  99 + ),
  100 + ],
  101 + ),
  102 + ),
  103 + );
  104 + }
  105 +}
  106 +
  107 +class _TrendHeaderAvatar extends StatelessWidget {
  108 + const _TrendHeaderAvatar({this.avatarUrl});
  109 +
  110 + final String? avatarUrl;
  111 +
  112 + @override
  113 + Widget build(BuildContext context) {
  114 + final imageUrl = avatarUrl?.trim();
  115 + return Container(
  116 + width: 36,
  117 + height: 36,
  118 + decoration: ShapeDecoration(
  119 + image: imageUrl?.isNotEmpty == true
  120 + ? DecorationImage(
  121 + image: CachedNetworkImageProvider(imageUrl!),
  122 + fit: BoxFit.cover,
  123 + )
  124 + : null,
  125 + shape: RoundedRectangleBorder(
  126 + side: const BorderSide(width: 0.78, color: Colors.white),
  127 + borderRadius: BorderRadius.circular(35.78),
63 ), 128 ),
64 ), 129 ),
  130 + child: imageUrl?.isNotEmpty == true ? null : const _AvatarFallback(),
  131 + );
  132 + }
  133 +}
  134 +
  135 +class _AvatarFallback extends StatelessWidget {
  136 + const _AvatarFallback();
  137 +
  138 + @override
  139 + Widget build(BuildContext context) {
  140 + return const Icon(
  141 + Icons.person_rounded,
  142 + color: Colors.white,
  143 + size: 22,
65 ); 144 );
66 } 145 }
67 } 146 }
  1 +import 'package:cached_network_image/cached_network_image.dart';
  2 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
  3 +import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
  4 +import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
  5 +import 'package:flutter/material.dart';
  6 +import 'package:get/get.dart';
  7 +
  8 +class TrendFriendSelectBottomSheet extends StatelessWidget {
  9 + const TrendFriendSelectBottomSheet({
  10 + super.key,
  11 + required this.scrollController,
  12 + required this.friendsList,
  13 + required this.selectedFriend,
  14 + required this.onSelectSelf,
  15 + required this.onSelectFriend,
  16 + });
  17 +
  18 + final ScrollController scrollController;
  19 + final RxList<FriendItem> friendsList;
  20 + final Rxn<FriendItem> selectedFriend;
  21 + final VoidCallback onSelectSelf;
  22 + final ValueChanged<FriendItem> onSelectFriend;
  23 +
  24 + @override
  25 + Widget build(BuildContext context) {
  26 + return Container(
  27 + decoration: BoxDecoration(
  28 + color: context.colors.backgroundPage,
  29 + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
  30 + ),
  31 + child: SafeArea(
  32 + top: false,
  33 + child: Column(
  34 + children: [
  35 + SizedBox(
  36 + height: 64,
  37 + child: Stack(
  38 + alignment: Alignment.center,
  39 + children: [
  40 + Center(
  41 + child: Text(
  42 + '选择好友',
  43 + style: TextStyle(
  44 + color: context.colors.textPrimary,
  45 + fontSize: 16,
  46 + fontWeight: FontWeight.w600,
  47 + ),
  48 + ),
  49 + ),
  50 + Positioned(
  51 + left: 16,
  52 + child: GestureDetector(
  53 + behavior: HitTestBehavior.opaque,
  54 + onTap: Get.back,
  55 + child: SizedBox(
  56 + width: 44,
  57 + height: 44,
  58 + child: Center(
  59 + child: Image.asset(
  60 + 'assets/images/common/ic_close.png',
  61 + width: 20,
  62 + height: 20,
  63 + color: context.colors.chartPurple,
  64 + ),
  65 + ),
  66 + ),
  67 + ),
  68 + ),
  69 + ],
  70 + ),
  71 + ),
  72 + Expanded(
  73 + child: Obx(() {
  74 + final self = Get.find<UserPreferencesStorage>()
  75 + .preferences
  76 + .value
  77 + .meUserInfo;
  78 + final selfNickname = self?.nickname?.trim();
  79 + final currentSelectedId = selectedFriend.value?.friendUserId;
  80 + final itemCount = friendsList.length + 1;
  81 +
  82 + return ListView.separated(
  83 + controller: scrollController,
  84 + padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
  85 + itemCount: itemCount,
  86 + separatorBuilder: (_, __) => const SizedBox(height: 8),
  87 + itemBuilder: (context, index) {
  88 + if (index == 0) {
  89 + return _BottomSheetUserRow(
  90 + name: selfNickname?.isNotEmpty == true
  91 + ? selfNickname!
  92 + : '我',
  93 + subtitle: selfNickname?.isNotEmpty == true ? '我' : null,
  94 + avatarUrl: self?.avatar,
  95 + isSelected: currentSelectedId == null,
  96 + onTap: () {
  97 + onSelectSelf();
  98 + Get.back();
  99 + },
  100 + );
  101 + }
  102 +
  103 + final friend = friendsList[index - 1];
  104 + final name = _friendName(friend);
  105 + final nickname = friend.friendNickname?.trim();
  106 + return _BottomSheetUserRow(
  107 + name: name,
  108 + subtitle: nickname?.isNotEmpty == true ? nickname : null,
  109 + avatarUrl: friend.avatar,
  110 + isSelected: friend.friendUserId == currentSelectedId,
  111 + onTap: () {
  112 + onSelectFriend(friend);
  113 + Get.back();
  114 + },
  115 + );
  116 + },
  117 + );
  118 + }),
  119 + ),
  120 + ],
  121 + ),
  122 + ),
  123 + );
  124 + }
  125 +
  126 + String _friendName(FriendItem friend) {
  127 + final remark = friend.remarkName?.trim();
  128 + if (remark?.isNotEmpty == true) return remark!;
  129 +
  130 + final nickname = friend.friendNickname?.trim();
  131 + if (nickname?.isNotEmpty == true) return nickname!;
  132 +
  133 + return '未知好友';
  134 + }
  135 +}
  136 +
  137 +class _BottomSheetUserRow extends StatelessWidget {
  138 + const _BottomSheetUserRow({
  139 + required this.name,
  140 + this.subtitle,
  141 + this.avatarUrl,
  142 + required this.isSelected,
  143 + required this.onTap,
  144 + });
  145 +
  146 + final String name;
  147 + final String? subtitle;
  148 + final String? avatarUrl;
  149 + final bool isSelected;
  150 + final VoidCallback onTap;
  151 +
  152 + @override
  153 + Widget build(BuildContext context) {
  154 + return GestureDetector(
  155 + onTap: onTap,
  156 + child: Container(
  157 + height: 56,
  158 + decoration: BoxDecoration(
  159 + color: Colors.white,
  160 + borderRadius: BorderRadius.circular(16),
  161 + border: isSelected
  162 + ? Border.all(color: const Color(0xFF845EEE), width: 1)
  163 + : null,
  164 + ),
  165 + child: Row(
  166 + children: [
  167 + const SizedBox(width: 20),
  168 + _RowAvatar(avatarUrl: avatarUrl),
  169 + const SizedBox(width: 8),
  170 + Expanded(
  171 + child: Text.rich(
  172 + TextSpan(
  173 + children: [
  174 + TextSpan(
  175 + text: name,
  176 + style: TextStyle(
  177 + color: context.colors.textPrimary,
  178 + fontSize: 14,
  179 + fontWeight: FontWeight.w500,
  180 + ),
  181 + ),
  182 + if (subtitle?.isNotEmpty == true)
  183 + TextSpan(
  184 + text: '($subtitle)',
  185 + style: TextStyle(
  186 + color: context.colors.textSecondary,
  187 + fontSize: 14,
  188 + fontWeight: FontWeight.w500,
  189 + ),
  190 + ),
  191 + ],
  192 + ),
  193 + overflow: TextOverflow.ellipsis,
  194 + ),
  195 + ),
  196 + Container(
  197 + width: 20,
  198 + height: 20,
  199 + margin: const EdgeInsets.only(right: 16),
  200 + decoration: BoxDecoration(
  201 + shape: BoxShape.circle,
  202 + color:
  203 + isSelected ? const Color(0xFF845EEE) : Colors.transparent,
  204 + border: isSelected
  205 + ? null
  206 + : Border.all(
  207 + color: const Color(0xFF0F0F11).withValues(alpha: 0.2),
  208 + width: 1.5,
  209 + ),
  210 + ),
  211 + child: isSelected
  212 + ? const Icon(Icons.check, color: Colors.white, size: 12)
  213 + : null,
  214 + ),
  215 + ],
  216 + ),
  217 + ),
  218 + );
  219 + }
  220 +}
  221 +
  222 +class _RowAvatar extends StatelessWidget {
  223 + const _RowAvatar({this.avatarUrl});
  224 +
  225 + final String? avatarUrl;
  226 +
  227 + @override
  228 + Widget build(BuildContext context) {
  229 + final imageUrl = avatarUrl?.trim();
  230 + return Container(
  231 + width: 28,
  232 + height: 28,
  233 + decoration: BoxDecoration(
  234 + shape: BoxShape.circle,
  235 + border: Border.all(
  236 + color: const Color(0xFF845EEE).withValues(alpha: 0.3),
  237 + width: 0.8,
  238 + ),
  239 + ),
  240 + child: ClipOval(
  241 + child: imageUrl?.isNotEmpty == true
  242 + ? CachedNetworkImage(
  243 + imageUrl: imageUrl!,
  244 + width: 28,
  245 + height: 28,
  246 + fit: BoxFit.cover,
  247 + )
  248 + : const Icon(
  249 + Icons.person_rounded,
  250 + color: Color(0xFF845EEE),
  251 + size: 18,
  252 + ),
  253 + ),
  254 + );
  255 + }
  256 +}
1 import 'package:doublefeel_flutter/core/util/size_extensions.dart'; 1 import 'package:doublefeel_flutter/core/util/size_extensions.dart';
  2 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
2 import 'package:fl_chart/fl_chart.dart'; 3 import 'package:fl_chart/fl_chart.dart';
3 import 'package:flutter/material.dart'; 4 import 'package:flutter/material.dart';
4 -import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';  
5 5
6 import '../../../../r.dart'; 6 import '../../../../r.dart';
  7 +import '../../report_common/utils/report_localization.dart';
7 import '../../report_common/widgets/chart_selection_line_overlay.dart'; 8 import '../../report_common/widgets/chart_selection_line_overlay.dart';
8 import '../../report_common/widgets/health_report_subject_scope.dart'; 9 import '../../report_common/widgets/health_report_subject_scope.dart';
9 -import '../../report_common/utils/report_localization.dart';  
10 import '../models/hrv_report_models.dart'; 10 import '../models/hrv_report_models.dart';
11 11
12 class HrvWeekReportView extends StatelessWidget { 12 class HrvWeekReportView extends StatelessWidget {
@@ -167,6 +167,8 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -167,6 +167,8 @@ class _HrvBarChartState extends State<_HrvBarChart> {
167 static const _plotLeft = _axisLabelWidth; 167 static const _plotLeft = _axisLabelWidth;
168 static const _plotRight = 0.0; 168 static const _plotRight = 0.0;
169 static const _bottomTitleHeight = 31.0; 169 static const _bottomTitleHeight = 31.0;
  170 + static const _weekBarWidth = 16.0;
  171 + static const _weekBarGap = 22.0;
170 static const _tooltipBackground = Color(0xFFF3F3F3); 172 static const _tooltipBackground = Color(0xFFF3F3F3);
171 static const _tooltipDateColor = Color(0xFF78787D); 173 static const _tooltipDateColor = Color(0xFF78787D);
172 static const _tooltipMetaColor = Color(0xFFB0B0B6); 174 static const _tooltipMetaColor = Color(0xFFB0B0B6);
@@ -185,7 +187,7 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -185,7 +187,7 @@ class _HrvBarChartState extends State<_HrvBarChart> {
185 barRods: [ 187 barRods: [
186 BarChartRodData( 188 BarChartRodData(
187 toY: day.averageHrv ?? 0, 189 toY: day.averageHrv ?? 0,
188 - width: widget.isMonth ? 4 : 13, 190 + width: widget.isMonth ? 4 : _weekBarWidth,
189 color: day.level == null 191 color: day.level == null
190 ? Colors.transparent 192 ? Colors.transparent
191 : Color(day.level!.colorValue), 193 : Color(day.level!.colorValue),
@@ -207,7 +209,10 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -207,7 +209,10 @@ class _HrvBarChartState extends State<_HrvBarChart> {
207 BarChartData( 209 BarChartData(
208 minY: 0, 210 minY: 0,
209 maxY: 110, 211 maxY: 110,
210 - alignment: BarChartAlignment.spaceAround, 212 + alignment: widget.isMonth
  213 + ? BarChartAlignment.spaceAround
  214 + : BarChartAlignment.center,
  215 + groupsSpace: widget.isMonth ? 16 : _weekBarGap,
211 barGroups: bars, 216 barGroups: bars,
212 borderData: FlBorderData(show: false), 217 borderData: FlBorderData(show: false),
213 gridData: FlGridData( 218 gridData: FlGridData(
@@ -302,6 +307,8 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -302,6 +307,8 @@ class _HrvBarChartState extends State<_HrvBarChart> {
302 child: _TrendXAxisLabels( 307 child: _TrendXAxisLabels(
303 report: widget.report, 308 report: widget.report,
304 isMonth: widget.isMonth, 309 isMonth: widget.isMonth,
  310 + weekBarWidth: _weekBarWidth,
  311 + weekBarGap: _weekBarGap,
305 ), 312 ),
306 ), 313 ),
307 ), 314 ),
@@ -369,6 +376,15 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -369,6 +376,15 @@ class _HrvBarChartState extends State<_HrvBarChart> {
369 final plotStart = _plotLeft; 376 final plotStart = _plotLeft;
370 final plotWidth = width - plotStart - _plotRight; 377 final plotWidth = width - plotStart - _plotRight;
371 if (plotWidth <= 0) return null; 378 if (plotWidth <= 0) return null;
  379 + if (!widget.isMonth) {
  380 + final groupCount = widget.report.days.length;
  381 + final chartWidth =
  382 + groupCount * _weekBarWidth + (groupCount - 1) * _weekBarGap;
  383 + final chartLeft = plotStart + (plotWidth - chartWidth) / 2;
  384 + return chartLeft +
  385 + _weekBarWidth / 2 +
  386 + index * (_weekBarWidth + _weekBarGap);
  387 + }
372 return plotStart + plotWidth * ((index + 0.5) / widget.report.days.length); 388 return plotStart + plotWidth * ((index + 0.5) / widget.report.days.length);
373 } 389 }
374 } 390 }
@@ -397,6 +413,7 @@ class _TrendMetric extends StatelessWidget { @@ -397,6 +413,7 @@ class _TrendMetric extends StatelessWidget {
397 @override 413 @override
398 Widget build(BuildContext context) { 414 Widget build(BuildContext context) {
399 final difference = hasData ? currentValue - (previousValue ?? 0) : null; 415 final difference = hasData ? currentValue - (previousValue ?? 0) : null;
  416 + final isGoodChange = _isGoodChange(difference);
400 final comparison = hasData 417 final comparison = hasData
401 ? isMonth 418 ? isMonth
402 ? _monthComparisonText(context, difference!) 419 ? _monthComparisonText(context, difference!)
@@ -427,12 +444,12 @@ class _TrendMetric extends StatelessWidget { @@ -427,12 +444,12 @@ class _TrendMetric extends StatelessWidget {
427 Row( 444 Row(
428 children: [ 445 children: [
429 if (difference != null && difference != 0) ...[ 446 if (difference != null && difference != 0) ...[
430 - Icon(  
431 - difference > 0  
432 - ? Icons.keyboard_arrow_up_rounded  
433 - : Icons.keyboard_arrow_down_rounded,  
434 - size: 13,  
435 - color: comparisonColor, 447 + Image.asset(
  448 + isGoodChange
  449 + ? R.assetsImagesHealthTrendUp
  450 + : R.assetsImagesHealthTrendDown,
  451 + width: 12,
  452 + height: 12,
436 ), 453 ),
437 const SizedBox(width: 1), 454 const SizedBox(width: 1),
438 ], 455 ],
@@ -481,11 +498,17 @@ class _TrendMetric extends StatelessWidget { @@ -481,11 +498,17 @@ class _TrendMetric extends StatelessWidget {
481 if (difference == null || difference == 0) { 498 if (difference == null || difference == 0) {
482 return const Color(0xFFB0B0B6); 499 return const Color(0xFFB0B0B6);
483 } 500 }
484 - final isGoodChange = switch (metricType) { 501 + return _isGoodChange(difference)
  502 + ? const Color(0xFF3BD49D)
  503 + : const Color(0xFFFF5279);
  504 + }
  505 +
  506 + bool _isGoodChange(int? difference) {
  507 + if (difference == null || difference == 0) return false;
  508 + return switch (metricType) {
485 _TrendMetricType.relaxed => difference > 0, 509 _TrendMetricType.relaxed => difference > 0,
486 _TrendMetricType.stressed => difference < 0, 510 _TrendMetricType.stressed => difference < 0,
487 }; 511 };
488 - return isGoodChange ? const Color(0xFF3BD49D) : const Color(0xFFFF5279);  
489 } 512 }
490 } 513 }
491 514
@@ -530,10 +553,14 @@ class _TrendXAxisLabels extends StatelessWidget { @@ -530,10 +553,14 @@ class _TrendXAxisLabels extends StatelessWidget {
530 const _TrendXAxisLabels({ 553 const _TrendXAxisLabels({
531 required this.report, 554 required this.report,
532 required this.isMonth, 555 required this.isMonth,
  556 + required this.weekBarWidth,
  557 + required this.weekBarGap,
533 }); 558 });
534 559
535 final HrvPeriodReport report; 560 final HrvPeriodReport report;
536 final bool isMonth; 561 final bool isMonth;
  562 + final double weekBarWidth;
  563 + final double weekBarGap;
537 564
538 @override 565 @override
539 Widget build(BuildContext context) { 566 Widget build(BuildContext context) {
@@ -563,9 +590,12 @@ class _TrendXAxisLabels extends StatelessWidget { @@ -563,9 +590,12 @@ class _TrendXAxisLabels extends StatelessWidget {
563 for (final label in labels) 590 for (final label in labels)
564 if (label.index < groupCount) 591 if (label.index < groupCount)
565 Positioned( 592 Positioned(
566 - left: constraints.maxWidth *  
567 - ((label.index + 0.5) / groupCount) -  
568 - labelWidth / 2, 593 + left: _labelLeft(
  594 + constraints.maxWidth,
  595 + groupCount,
  596 + label.index,
  597 + labelWidth,
  598 + ),
569 top: 5, 599 top: 5,
570 width: labelWidth, 600 width: labelWidth,
571 child: Text( 601 child: Text(
@@ -583,6 +613,24 @@ class _TrendXAxisLabels extends StatelessWidget { @@ -583,6 +613,24 @@ class _TrendXAxisLabels extends StatelessWidget {
583 }, 613 },
584 ); 614 );
585 } 615 }
  616 +
  617 + double _labelLeft(
  618 + double width,
  619 + int groupCount,
  620 + int index,
  621 + double labelWidth,
  622 + ) {
  623 + if (isMonth) {
  624 + return width * ((index + 0.5) / groupCount) - labelWidth / 2;
  625 + }
  626 + final chartWidth =
  627 + groupCount * weekBarWidth + (groupCount - 1) * weekBarGap;
  628 + final chartLeft = (width - chartWidth) / 2;
  629 + return chartLeft +
  630 + weekBarWidth / 2 +
  631 + index * (weekBarWidth + weekBarGap) -
  632 + labelWidth / 2;
  633 + }
586 } 634 }
587 635
588 class _DistributionCard extends StatelessWidget { 636 class _DistributionCard extends StatelessWidget {
@@ -737,32 +785,167 @@ class _DistributionBar extends StatelessWidget { @@ -737,32 +785,167 @@ class _DistributionBar extends StatelessWidget {
737 785
738 @override 786 @override
739 Widget build(BuildContext context) { 787 Widget build(BuildContext context) {
740 - return Container(  
741 - width: 33,  
742 - height: 180,  
743 - clipBehavior: Clip.antiAlias,  
744 - decoration: BoxDecoration(  
745 - color: const Color(0xFFF3F3F3),  
746 - borderRadius: BorderRadius.circular(7)),  
747 - child: report.hasData  
748 - ? Column(  
749 - mainAxisAlignment: MainAxisAlignment.end,  
750 - children: [  
751 - for (final level in HrvStressLevel.values)  
752 - if (report.countFor(level) > 0)  
753 - Expanded(  
754 - flex: report.countFor(level),  
755 - child: Container(  
756 - decoration: BoxDecoration(  
757 - color: Color(level.colorValue),  
758 - border: Border.all(color: Colors.white, width: .5),  
759 - ),  
760 - ),  
761 - ),  
762 - ],  
763 - )  
764 - : null, 788 + const radius = 7.0;
  789 + final segments = [
  790 + for (final level in HrvStressLevel.values)
  791 + if (report.countFor(level) > 0)
  792 + _DistributionBarSegment(
  793 + color: Color(level.colorValue),
  794 + flex: report.countFor(level),
  795 + ),
  796 + ];
  797 + final hasSegments = report.hasData && segments.isNotEmpty;
  798 + final height = hasSegments
  799 + ? _DistributionBarPainter.visibleHeightFor(segments)
  800 + : 180.0;
  801 + final paintSize = Size(44.w, height);
  802 +
  803 + return SizedBox.fromSize(
  804 + size: const Size(33, 180),
  805 + child: OverflowBox(
  806 + alignment: Alignment.bottomLeft,
  807 + minWidth: paintSize.width,
  808 + maxWidth: paintSize.width,
  809 + minHeight: paintSize.height,
  810 + maxHeight: paintSize.height,
  811 + child: Transform.translate(
  812 + offset: const Offset(0, -12),
  813 + child: SizedBox.fromSize(
  814 + size: paintSize,
  815 + child: CustomPaint(
  816 + painter: _DistributionBarPainter(
  817 + segments: hasSegments ? segments : const [],
  818 + backgroundColor: const Color(0xFFF3F3F3),
  819 + radius: radius,
  820 + ),
  821 + ),
  822 + ),
  823 + ),
  824 + ),
  825 + );
  826 + }
  827 +}
  828 +
  829 +class _DistributionBarSegment {
  830 + const _DistributionBarSegment({required this.color, required this.flex});
  831 +
  832 + final Color color;
  833 + final int flex;
  834 +}
  835 +
  836 +class _DistributionBarPainter extends CustomPainter {
  837 + const _DistributionBarPainter({
  838 + required this.segments,
  839 + required this.backgroundColor,
  840 + required this.radius,
  841 + });
  842 +
  843 + final List<_DistributionBarSegment> segments;
  844 + final Color backgroundColor;
  845 + final double radius;
  846 + static const maxVisibleHeight = 220.0;
  847 + static const _borderWidth = 1.0;
  848 + static const _overlapHeight = 16.0;
  849 + static const _weekDayCount = 7;
  850 +
  851 + static double visibleHeightFor(List<_DistributionBarSegment> segments) {
  852 + if (segments.isEmpty) return 0;
  853 + final totalFlex =
  854 + segments.fold<int>(0, (sum, segment) => sum + segment.flex);
  855 + return maxVisibleHeight * totalFlex / _weekDayCount;
  856 + }
  857 +
  858 + @override
  859 + void paint(Canvas canvas, Size size) {
  860 + final clip = RRect.fromRectAndRadius(
  861 + Offset.zero & size,
  862 + Radius.circular(radius),
765 ); 863 );
  864 + canvas.save();
  865 + canvas.clipRRect(clip);
  866 +
  867 + final total = segments.fold<int>(0, (sum, segment) => sum + segment.flex);
  868 + if (total == 0) {
  869 + canvas.drawRRect(
  870 + clip,
  871 + Paint()
  872 + ..color = backgroundColor
  873 + ..style = PaintingStyle.fill,
  874 + );
  875 + canvas.restore();
  876 + return;
  877 + }
  878 +
  879 + final visibleHeights = _visibleSegmentHeightsFor(size.height, segments);
  880 + var bottom = 0.0;
  881 + for (var index = 0; index < segments.length; index++) {
  882 + final segment = segments[index];
  883 + final isBottomSegment = index == segments.length - 1;
  884 + final drawTop = index == 0 ? 0.0 : bottom - _overlapHeight;
  885 + final drawBottom = drawTop +
  886 + visibleHeights[index] +
  887 + (isBottomSegment ? 0 : _overlapHeight);
  888 + _drawLayeredSegment(
  889 + canvas,
  890 + Rect.fromLTRB(0, drawTop, size.width, drawBottom),
  891 + color: segment.color,
  892 + radius: radius,
  893 + );
  894 + bottom = drawBottom;
  895 + }
  896 + canvas.restore();
  897 + }
  898 +
  899 + static List<double> _visibleSegmentHeightsFor(
  900 + double visibleHeight,
  901 + List<_DistributionBarSegment> segments,
  902 + ) {
  903 + final totalFlex =
  904 + segments.fold<int>(0, (sum, segment) => sum + segment.flex);
  905 + final dayHeight = visibleHeight / totalFlex;
  906 + return [
  907 + for (final segment in segments) segment.flex * dayHeight,
  908 + ];
  909 + }
  910 +
  911 + void _drawLayeredSegment(
  912 + Canvas canvas,
  913 + Rect rect, {
  914 + required Color color,
  915 + required double radius,
  916 + }) {
  917 + final rrect = RRect.fromRectAndRadius(rect, Radius.circular(radius));
  918 + canvas.drawRRect(
  919 + rrect,
  920 + Paint()
  921 + ..color = color
  922 + ..style = PaintingStyle.fill,
  923 + );
  924 + canvas.drawRRect(
  925 + rrect.deflate(_borderWidth / 2),
  926 + Paint()
  927 + ..color = Colors.white
  928 + ..style = PaintingStyle.stroke
  929 + ..strokeWidth = _borderWidth,
  930 + );
  931 + }
  932 +
  933 + @override
  934 + bool shouldRepaint(covariant _DistributionBarPainter oldDelegate) {
  935 + if (backgroundColor != oldDelegate.backgroundColor ||
  936 + radius != oldDelegate.radius ||
  937 + segments.length != oldDelegate.segments.length) {
  938 + return true;
  939 + }
  940 + for (var i = 0; i < segments.length; i++) {
  941 + final segment = segments[i];
  942 + final oldSegment = oldDelegate.segments[i];
  943 + if (segment.color != oldSegment.color ||
  944 + segment.flex != oldSegment.flex) {
  945 + return true;
  946 + }
  947 + }
  948 + return false;
766 } 949 }
767 } 950 }
768 951
@@ -328,7 +328,7 @@ class _DayDatePickerSheetState extends State<_DayDatePickerSheet> { @@ -328,7 +328,7 @@ class _DayDatePickerSheetState extends State<_DayDatePickerSheet> {
328 child: Stack( 328 child: Stack(
329 alignment: Alignment.center, 329 alignment: Alignment.center,
330 children: [ 330 children: [
331 - const _SelectionFrame(fillWhite: false), 331 + const _SelectionFrame(fillWhite: true),
332 Row( 332 Row(
333 children: isChinese 333 children: isChinese
334 ? [yearPicker, monthPicker, dayPicker] 334 ? [yearPicker, monthPicker, dayPicker]
1 // ─── Responses ─────────────────────────────────────────────────────────────── 1 // ─── Responses ───────────────────────────────────────────────────────────────
2 2
3 class FriendListResponse { 3 class FriendListResponse {
4 - const FriendListResponse({this.list = const [], this.limit}); 4 + const FriendListResponse({
  5 + this.list = const [],
  6 + this.limit,
  7 + this.healthData,
  8 + });
5 9
6 final List<FriendItem> list; 10 final List<FriendItem> list;
7 final int? limit; 11 final int? limit;
  12 + final FriendHealthData? healthData;
8 13
9 factory FriendListResponse.fromJson(Map<String, dynamic> json) { 14 factory FriendListResponse.fromJson(Map<String, dynamic> json) {
10 return FriendListResponse( 15 return FriendListResponse(
@@ -13,12 +18,18 @@ class FriendListResponse { @@ -13,12 +18,18 @@ class FriendListResponse {
13 .toList() ?? 18 .toList() ??
14 const [], 19 const [],
15 limit: json['limit'] as int?, 20 limit: json['limit'] as int?,
  21 + healthData: json['health_data'] == null
  22 + ? null
  23 + : FriendHealthData.fromJson(
  24 + json['health_data'] as Map<String, dynamic>,
  25 + ),
16 ); 26 );
17 } 27 }
18 28
19 Map<String, dynamic> toJson() => { 29 Map<String, dynamic> toJson() => {
20 'list': list.map((e) => e.toJson()).toList(), 30 'list': list.map((e) => e.toJson()).toList(),
21 'limit': limit, 31 'limit': limit,
  32 + if (healthData != null) 'health_data': healthData!.toJson(),
22 }; 33 };
23 } 34 }
24 35
@@ -99,6 +110,7 @@ class FriendHealthData { @@ -99,6 +110,7 @@ class FriendHealthData {
99 this.realtimeStress, 110 this.realtimeStress,
100 this.sleepEvaluate, 111 this.sleepEvaluate,
101 this.totalSteps, 112 this.totalSteps,
  113 + this.lastDataTime,
102 }); 114 });
103 115
104 /// HRV state indicator 116 /// HRV state indicator
@@ -116,13 +128,17 @@ class FriendHealthData { @@ -116,13 +128,17 @@ class FriendHealthData {
116 /// Total step count for the day (nullable) 128 /// Total step count for the day (nullable)
117 final int? totalSteps; 129 final int? totalSteps;
118 130
  131 + /// Last health data timestamp, in seconds or milliseconds.
  132 + final num? lastDataTime;
  133 +
119 factory FriendHealthData.fromJson(Map<String, dynamic> json) { 134 factory FriendHealthData.fromJson(Map<String, dynamic> json) {
120 return FriendHealthData( 135 return FriendHealthData(
121 - hrvState: json['hrv_state'] as int?,  
122 - latestHrv: (json['latest_hrv'] as num?)?.toDouble(), 136 + hrvState: _parseInt(json['hrv_state']),
  137 + latestHrv: _parseDouble(json['latest_hrv']),
123 realtimeStress: json['realtime_stress'] as Map<String, dynamic>?, 138 realtimeStress: json['realtime_stress'] as Map<String, dynamic>?,
124 - sleepEvaluate: json['sleep_evaluate'] as int?,  
125 - totalSteps: json['total_steps'] as int?, 139 + sleepEvaluate: _parseInt(json['sleep_evaluate']),
  140 + totalSteps: _parseInt(json['total_steps']),
  141 + lastDataTime: _parseNum(json['last_data_time']),
126 ); 142 );
127 } 143 }
128 144
@@ -133,6 +149,29 @@ class FriendHealthData { @@ -133,6 +149,29 @@ class FriendHealthData {
133 if (realtimeStress != null) val['realtime_stress'] = realtimeStress; 149 if (realtimeStress != null) val['realtime_stress'] = realtimeStress;
134 if (sleepEvaluate != null) val['sleep_evaluate'] = sleepEvaluate; 150 if (sleepEvaluate != null) val['sleep_evaluate'] = sleepEvaluate;
135 if (totalSteps != null) val['total_steps'] = totalSteps; 151 if (totalSteps != null) val['total_steps'] = totalSteps;
  152 + if (lastDataTime != null) val['last_data_time'] = lastDataTime;
136 return val; 153 return val;
137 } 154 }
138 } 155 }
  156 +
  157 +int? _parseInt(dynamic value) {
  158 + if (value == null) return null;
  159 + if (value is int) return value;
  160 + if (value is double) return value.toInt();
  161 + if (value is String) return int.tryParse(value);
  162 + return null;
  163 +}
  164 +
  165 +double? _parseDouble(dynamic value) {
  166 + if (value == null) return null;
  167 + if (value is num) return value.toDouble();
  168 + if (value is String) return double.tryParse(value);
  169 + return null;
  170 +}
  171 +
  172 +num? _parseNum(dynamic value) {
  173 + if (value == null) return null;
  174 + if (value is num) return value;
  175 + if (value is String) return num.tryParse(value);
  176 + return null;
  177 +}