Showing
14 changed files
with
811 additions
and
170 deletions
| @@ -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 | +} |
-
Please register or login to post a comment