Commit 06be138c6f580b12c893f5ac052c6bbab643c6f1

Authored by 刘宏哲
1 parent 34d92c22

feat(app): bug fixed

... ... @@ -7,43 +7,32 @@ import 'package:doublefeel_flutter/app/utils/dialog_utils.dart';
import 'package:doublefeel_flutter/core/error/app_error.dart';
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/services/thinking_data_service.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:get/get.dart';
import '../data/friends_repository.dart';
import '../data/self_health_repository.dart';
import '../models/friend_health_data.dart';
class FriendsController extends GetxController {
FriendsController({
FriendsRepository? repository,
SelfHealthRepository? selfHealthRepository,
Future<void> Function(WatchAppOtherInfo info)? updateWatchOtherUserInfo,
}) : _repository =
repository ?? FriendsRepositoryImpl(Get.find<FriendApi>()),
_selfHealthRepository = selfHealthRepository ??
SelfHealthRepositoryImpl(Get.find<HealthApi>()),
_updateWatchOtherUserInfo = updateWatchOtherUserInfo ??
((info) => PlatformHostApi().refreshWatchAppAndWidgets());
static const maxFriends = 10;
final FriendsRepository _repository;
final SelfHealthRepository _selfHealthRepository;
final Future<void> Function(WatchAppOtherInfo info) _updateWatchOtherUserInfo;
final friends = <FriendHealthData>[].obs;
final isLoading = false.obs;
final selfHealthData = Rxn<V2HealthData>();
final selfStressScore = Rxn<V2StressScore>();
final selfHealthUpdatedAt = Rxn<DateTime>();
final isSelfHealthLoading = false.obs;
final selfHealthData = Rxn<SelfFriendHealthData>();
Future<void>? _friendsRequest;
Future<void>? _selfHealthRequest;
bool _isPageVisible = false;
bool get isFull => friends.length >= maxFriends;
... ... @@ -64,9 +53,7 @@ class FriendsController extends GetxController {
_isPageVisible = false;
}
Future<void> refreshData() {
return Future.wait([loadFriends(), loadSelfHealth()]);
}
Future<void> refreshData() => loadFriends();
Future<void> loadFriends() {
return _friendsRequest ??= _loadFriends();
... ... @@ -75,7 +62,9 @@ class FriendsController extends GetxController {
Future<void> _loadFriends() async {
isLoading.value = true;
try {
friends.assignAll(await _repository.getFriends());
final data = await _repository.getFriendList();
friends.assignAll(data.friends);
selfHealthData.value = data.selfHealthData;
} catch (error, stackTrace) {
AppLogger.e('FriendsController.loadFriends failed', error, stackTrace);
} finally {
... ... @@ -84,50 +73,6 @@ class FriendsController extends GetxController {
}
}
Future<void> loadSelfHealth() {
return _selfHealthRequest ??= _loadSelfHealth();
}
Future<void> _loadSelfHealth() async {
isSelfHealthLoading.value = true;
try {
final today = DateTime.now();
await Future.wait([
_loadSelfHealthData(today),
_loadSelfStressScore(today),
]);
} finally {
isSelfHealthLoading.value = false;
_selfHealthRequest = null;
}
}
Future<void> _loadSelfHealthData(DateTime date) async {
try {
selfHealthData.value = await _selfHealthRepository.getHealthData(date);
selfHealthUpdatedAt.value = DateTime.now();
} catch (error, stackTrace) {
AppLogger.e(
'FriendsController.loadSelfHealthData failed',
error,
stackTrace,
);
}
}
Future<void> _loadSelfStressScore(DateTime date) async {
try {
selfStressScore.value = await _selfHealthRepository.getStressScore(date);
selfHealthUpdatedAt.value = DateTime.now();
} catch (error, stackTrace) {
AppLogger.e(
'FriendsController.loadSelfStressScore failed',
error,
stackTrace,
);
}
}
Future<void> showEditRemarkDialog(FriendHealthData friend) async {
final result = await DialogUtils.showInputDialog(
InputDialogMetaData(
... ...
... ... @@ -11,6 +11,8 @@ import '../models/friend_stress_state.dart';
abstract class FriendsRepository {
Future<List<FriendHealthData>> getFriends({bool withHealthData = true});
Future<FriendListData> getFriendList({bool withHealthData = true});
Future<void> updateRemark(int userId, String remark);
Future<void> selectWatchFaceFriend(int userId);
... ... @@ -18,6 +20,16 @@ abstract class FriendsRepository {
Future<void> deleteFriend(int userId);
}
class FriendListData {
const FriendListData({
required this.friends,
required this.selfHealthData,
});
final List<FriendHealthData> friends;
final SelfFriendHealthData? selfHealthData;
}
class FriendsRepositoryImpl implements FriendsRepository {
const FriendsRepositoryImpl(this._friendApi);
... ... @@ -26,8 +38,17 @@ class FriendsRepositoryImpl implements FriendsRepository {
@override
Future<List<FriendHealthData>> getFriends(
{bool withHealthData = true}) async {
final data = await getFriendList(withHealthData: withHealthData);
return data.friends;
}
@override
Future<FriendListData> getFriendList({bool withHealthData = true}) async {
return switch (await _friendApi.friendList(withHealthData)) {
AppSuccess(:final data) => data.list.map(_mapFriend).toList(),
AppSuccess(:final data) => FriendListData(
friends: data.list.map(_mapFriend).toList(),
selfHealthData: _mapSelfHealthData(data.healthData),
),
AppFailure(:final error) => throw error,
};
}
... ... @@ -75,7 +96,7 @@ class FriendsRepositoryImpl implements FriendsRepository {
? l10n.friendsUnknownFriend
: nickname,
remark: remark == null || remark.isEmpty ? null : remark,
updatedAt: _updatedAt(friend.updateTime),
updatedAt: _updatedAt(healthData?.lastDataTime ?? friend.updateTime),
sleepQualityScore: healthData?.sleepEvaluate,
steps: healthData?.totalSteps == null
? null
... ... @@ -85,11 +106,25 @@ class FriendsRepositoryImpl implements FriendsRepository {
);
}
String _updatedAt(int? timestamp) {
SelfFriendHealthData? _mapSelfHealthData(
api_models.FriendHealthData? healthData,
) {
if (healthData == null) return null;
return SelfFriendHealthData(
updatedAt: _updatedAt(healthData.lastDataTime),
sleepQualityScore: healthData.sleepEvaluate,
steps: healthData.totalSteps == null
? null
: l10n.friendsStepCount(healthData.totalSteps!),
stressState: FriendStressState.fromValue(healthData.hrvState),
);
}
String _updatedAt(num? timestamp) {
if (timestamp == null) return l10n.friendsWaitingForData;
final milliseconds =
timestamp < 1000000000000 ? timestamp * 1000 : timestamp;
final time = DateTime.fromMillisecondsSinceEpoch(milliseconds);
final time = DateTime.fromMillisecondsSinceEpoch(milliseconds.toInt());
return l10n.friendsUpdatedAt(DateFormat('HH:mm').format(time));
}
}
... ...
... ... @@ -61,3 +61,17 @@ class FriendHealthData {
);
}
}
class SelfFriendHealthData {
const SelfFriendHealthData({
required this.updatedAt,
required this.sleepQualityScore,
required this.steps,
required this.stressState,
});
final String updatedAt;
final int? sleepQualityScore;
final String? steps;
final FriendStressState stressState;
}
... ...
import 'package:cached_network_image/cached_network_image.dart';
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
... ... @@ -97,28 +98,25 @@ class _FriendTrendTitle extends StatelessWidget {
@override
Widget build(BuildContext context) {
final imageUrl = avatarUrl?.trim();
return Row(
children: [
Container(
width: 28,
height: 28,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 0.8),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFB7A6FF), Color(0xFFFFD7EA)],
decoration: ShapeDecoration(
image: imageUrl?.isNotEmpty == true
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl!),
fit: BoxFit.cover,
)
: null,
shape: RoundedRectangleBorder(
side: const BorderSide(width: 0.78, color: Colors.white),
borderRadius: BorderRadius.circular(35.78),
),
),
child: avatarUrl?.trim().isNotEmpty == true
? Image.network(
avatarUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const _AvatarFallback(),
)
: const _AvatarFallback(),
child: imageUrl?.isNotEmpty == true ? null : const _AvatarFallback(),
),
const SizedBox(width: 8),
Expanded(
... ...
... ... @@ -7,7 +7,6 @@ import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../controllers/friends_controller.dart';
import '../models/friend_health_data.dart';
... ... @@ -39,37 +38,23 @@ class FriendsTab extends GetView<FriendsController> {
final hasFriends = friends.isNotEmpty;
final isFull = controller.isFull;
final healthData = controller.selfHealthData.value;
final stressScore = controller.selfStressScore.value;
final updatedAt = controller.selfHealthUpdatedAt.value;
final currentUser =
userPreferences.preferences.value.meUserInfo;
final isSelfInitialLoading =
controller.isSelfHealthLoading.value &&
healthData == null &&
stressScore == null;
controller.isLoading.value && healthData == null;
final isFriendsInitialLoading =
controller.isLoading.value && !hasFriends;
final selfStressState = FriendStressState.fromValue(
stressScore?.comprehensiveScore,
);
final selfHealthCard = _SelfHealthCard(
name: currentUser?.nickname?.trim().isNotEmpty == true
? currentUser!.nickname!.trim()
: '-',
avatarUrl: currentUser?.avatar,
updatedAt: updatedAt == null
? context.l10n.friendsWaitingForData
: context.l10n.friendsUpdatedAt(
DateFormat('HH:mm').format(updatedAt),
),
sleepQualityScore:
_normalizedScore(healthData?.sleepScore),
steps: healthData?.steps == null
? null
: context.l10n.friendsStepCount(
healthData!.steps!,
),
stressState: selfStressState,
updatedAt: healthData?.updatedAt ??
context.l10n.friendsWaitingForData,
sleepQualityScore: healthData?.sleepQualityScore,
steps: healthData?.steps,
stressState:
healthData?.stressState ?? FriendStressState.wait,
isLoading: isSelfInitialLoading,
);
... ... @@ -266,11 +251,6 @@ class FriendsTab extends GetView<FriendsController> {
controller.applyWatchFaceSelection(friend);
}
}
int? _normalizedScore(double? value) {
if (value == null || !value.isFinite) return null;
return value.round().clamp(0, 100);
}
}
class _SelfHealthCard extends StatelessWidget {
... ...
... ... @@ -356,8 +356,8 @@ class _StatusFigure extends StatelessWidget {
),
),
Positioned(
left: 22,
top: 24,
left: 25,
top: 27,
child: Image.asset(
stressState.iconPath,
width: 72,
... ...
... ... @@ -20,6 +20,7 @@ class HealthTrendContent extends StatefulWidget {
super.key,
required this.query,
required this.selectedTypeIndex,
this.refreshToken = 0,
required this.onTypeChanged,
required this.onPeriodChanged,
required this.onDateChanged,
... ... @@ -28,6 +29,7 @@ class HealthTrendContent extends StatefulWidget {
final HealthReportQuery query;
final int selectedTypeIndex;
final int refreshToken;
final ValueChanged<int> onTypeChanged;
final ValueChanged<ReportPeriod> onPeriodChanged;
final ValueChanged<DateTime> onDateChanged;
... ... @@ -117,6 +119,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
_KeepAliveWrapper(
child: _HrvTrendSection(
query: _queryForType(0),
refreshToken: widget.refreshToken,
isSelected: widget.selectedTypeIndex == 0,
isVip: isVip,
onSubscribe: openPurchase,
onPeriodChanged: widget.onPeriodChanged,
... ... @@ -126,6 +130,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
_KeepAliveWrapper(
child: _ActivityBurnTrendSection(
query: _queryForType(1),
refreshToken: widget.refreshToken,
isSelected: widget.selectedTypeIndex == 1,
isVip: isVip,
onSubscribe: openPurchase,
onPeriodChanged: widget.onPeriodChanged,
... ... @@ -135,6 +141,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
_KeepAliveWrapper(
child: _SleepTrendSection(
query: _queryForType(2),
refreshToken: widget.refreshToken,
isSelected: widget.selectedTypeIndex == 2,
isVip: isVip,
onSubscribe: openPurchase,
onPeriodChanged: widget.onPeriodChanged,
... ... @@ -154,6 +162,8 @@ class _HealthTrendContentState extends State<HealthTrendContent>
class _HrvTrendSection extends StatefulWidget {
const _HrvTrendSection({
required this.query,
required this.refreshToken,
required this.isSelected,
required this.isVip,
required this.onSubscribe,
required this.onPeriodChanged,
... ... @@ -161,6 +171,8 @@ class _HrvTrendSection extends StatefulWidget {
});
final HealthReportQuery query;
final int refreshToken;
final bool isSelected;
final bool isVip;
final ValueChanged<String> onSubscribe;
final ValueChanged<ReportPeriod> onPeriodChanged;
... ... @@ -195,6 +207,9 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
super.didUpdateWidget(oldWidget);
if (oldWidget.query != widget.query) {
_syncExternalQuery();
} else if (oldWidget.refreshToken != widget.refreshToken &&
widget.isSelected) {
_logic.loadReport();
}
}
... ... @@ -227,6 +242,8 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
class _ActivityBurnTrendSection extends StatefulWidget {
const _ActivityBurnTrendSection({
required this.query,
required this.refreshToken,
required this.isSelected,
required this.isVip,
required this.onSubscribe,
required this.onPeriodChanged,
... ... @@ -234,6 +251,8 @@ class _ActivityBurnTrendSection extends StatefulWidget {
});
final HealthReportQuery query;
final int refreshToken;
final bool isSelected;
final bool isVip;
final ValueChanged<String> onSubscribe;
final ValueChanged<ReportPeriod> onPeriodChanged;
... ... @@ -271,6 +290,9 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
super.didUpdateWidget(oldWidget);
if (oldWidget.query != widget.query) {
_syncExternalQuery();
} else if (oldWidget.refreshToken != widget.refreshToken &&
widget.isSelected) {
_logic.loadReport();
}
}
... ... @@ -303,6 +325,8 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
class _SleepTrendSection extends StatefulWidget {
const _SleepTrendSection({
required this.query,
required this.refreshToken,
required this.isSelected,
required this.isVip,
required this.onSubscribe,
required this.onPeriodChanged,
... ... @@ -310,6 +334,8 @@ class _SleepTrendSection extends StatefulWidget {
});
final HealthReportQuery query;
final int refreshToken;
final bool isSelected;
final bool isVip;
final ValueChanged<String> onSubscribe;
final ValueChanged<ReportPeriod> onPeriodChanged;
... ... @@ -346,6 +372,9 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
super.didUpdateWidget(oldWidget);
if (oldWidget.query != widget.query) {
_syncExternalQuery();
} else if (oldWidget.refreshToken != widget.refreshToken &&
widget.isSelected) {
_logic.loadReport();
}
}
... ...
import 'package:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart';
import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
... ... @@ -6,7 +5,6 @@ import 'package:doublefeel_flutter/core/network/api/pay_api.dart';
import 'package:doublefeel_flutter/core/network/api/theme_api.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:doublefeel_flutter/core/services/health_kit_upload_service.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:get/get.dart';
... ... @@ -34,7 +32,10 @@ class HomeBinding extends Bindings {
Get.find<UserApi>(), Get.find<VipApi>(), Get.find<ThemeApi>()),
fenix: true,
);
Get.lazyPut<TrendController>(() => TrendController(), fenix: true);
Get.lazyPut<TrendController>(
() => TrendController(Get.find<FriendApi>()),
fenix: true,
);
Get.lazyPut<FriendsController>(() => FriendsController(), fenix: true);
}
}
... ...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../../../core/network/api/friend_api.dart';
import '../../../../../core/result/app_result.dart';
import '../../../../../data/models/friend/friend_models.dart';
import '../../../health_trend/controllers/health_trend_analytics.dart';
import '../../../health_trend/controllers/health_trend_control.dart';
import '../../../report_common/models/health_report_query.dart';
import '../../../report_common/models/report_period.dart';
import '../../widgets/trend/trend_friend_select_bottom_sheet.dart';
enum TrendType {
hrv,
... ... @@ -16,10 +21,21 @@ enum TrendType {
/// 趋势页顶层 Controller,仅负责:
/// 顶层 HRV / 活动 / 睡眠 类型切换 (selectedTypeIndex)
class TrendController extends GetxController with HealthTrendControl {
TrendController(this._friendApi);
final FriendApi _friendApi;
bool _isPageVisible = false;
final refreshToken = 0.obs;
// 当前查看的用户。null 表示查看自己;非 null 表示查看指定用户。
final targetUserId = RxnInt();
final targetFriendInfo = Rxn<FriendItem>();
final friendsList = <FriendItem>[].obs;
final isFriendListLoaded = false.obs;
int friendsListLimit = 10;
bool get canSwitchFriend =>
isFriendListLoaded.value && friendsList.isNotEmpty;
HealthReportQuery get query => HealthReportQuery(
targetUserId: targetUserId.value,
... ... @@ -34,6 +50,12 @@ class TrendController extends GetxController with HealthTrendControl {
);
@override
void onInit() {
super.onInit();
_refreshFriendList();
}
@override
void changeType(int index) {
final previousIndex = selectedTypeIndex.value;
super.changeType(index);
... ... @@ -55,11 +77,71 @@ class TrendController extends GetxController with HealthTrendControl {
void changeTargetUser(int? userId) {
if (userId == targetUserId.value) return;
targetUserId.value = userId;
refreshToken.value++;
}
void selectFriend(FriendItem friendInfo) {
if (friendInfo.friendUserId == targetFriendInfo.value?.friendUserId) {
return;
}
targetFriendInfo.value = friendInfo;
changeTargetUser(friendInfo.friendUserId);
}
void selectSelf() {
if (targetFriendInfo.value == null && targetUserId.value == null) {
return;
}
targetFriendInfo.value = null;
changeTargetUser(null);
}
void showFriendListBottomSheet() {
_refreshFriendList();
if (friendsList.isEmpty) return;
Get.bottomSheet(
DraggableScrollableSheet(
maxChildSize: 0.6,
initialChildSize: 0.6,
expand: false,
snap: true,
builder: (context, scrollController) {
return TrendFriendSelectBottomSheet(
scrollController: scrollController,
friendsList: friendsList,
selectedFriend: targetFriendInfo,
onSelectSelf: selectSelf,
onSelectFriend: selectFriend,
);
},
),
barrierColor: Colors.black.withValues(alpha: 0.7),
enableDrag: true,
isScrollControlled: true,
persistent: false,
);
}
void _refreshFriendList() {
_friendApi.friendList(false).then(
(res) {
switch (res) {
case AppSuccess(:final data):
friendsList.assignAll(data.list);
friendsListLimit = data.limit ?? 10;
isFriendListLoaded.value = true;
case AppFailure():
isFriendListLoaded.value = true;
}
},
);
}
void markPageVisible() {
if (_isPageVisible) return;
_isPageVisible = true;
refreshToken.value++;
_trackEnterPage();
}
... ... @@ -70,7 +152,7 @@ class TrendController extends GetxController with HealthTrendControl {
void _trackEnterPage() {
HealthTrendAnalytics.trackEnterPage(
selectedTypeIndex.value,
userRole: '我的',
userRole: targetFriendInfo.value == null ? '我的' : '好友的',
);
}
}
... ...
import 'package:cached_network_image/cached_network_image.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
... ... @@ -33,6 +35,7 @@ class _HomeTrendBody extends GetView<TrendController> {
query: controller.query,
queryForType: controller.queryForType,
selectedTypeIndex: controller.selectedTypeIndex.value,
refreshToken: controller.refreshToken.value,
onTypeChanged: controller.changeType,
onPeriodChanged: controller.changePeriod,
onDateChanged: controller.changeDate,
... ... @@ -41,27 +44,103 @@ class _HomeTrendBody extends GetView<TrendController> {
}
}
class _HomeTrendHeader extends StatelessWidget {
class _HomeTrendHeader extends GetView<TrendController> {
const _HomeTrendHeader();
@override
Widget build(BuildContext context) {
return const SizedBox(
return SizedBox(
height: 48,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'趋势',
style: TextStyle(
color: Color(0xFF0F0F11),
fontSize: 24,
fontWeight: FontWeight.w600,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Stack(
alignment: Alignment.center,
children: [
const Align(
alignment: Alignment.centerLeft,
child: Text(
'趋势',
style: TextStyle(
color: Color(0xFF0F0F11),
fontSize: 24,
fontWeight: FontWeight.w600,
),
),
),
),
Obx(() {
final friendAvatar = controller.targetFriendInfo.value?.avatar;
final selfAvatar = Get.find<UserPreferencesStorage>()
.preferences
.value
.meUserInfo
?.avatar;
return _TrendHeaderAvatar(
avatarUrl: friendAvatar?.trim().isNotEmpty == true
? friendAvatar
: selfAvatar,
);
}),
Obx(
() => !controller.canSwitchFriend
? const SizedBox.shrink()
: Align(
alignment: Alignment.centerRight,
child: IconButton(
highlightColor: Colors.transparent,
padding: EdgeInsets.zero,
onPressed: controller.showFriendListBottomSheet,
icon: Image.asset(
'assets/images/common/ic_replace.png',
width: 20,
height: 20,
),
),
),
),
],
),
),
);
}
}
class _TrendHeaderAvatar extends StatelessWidget {
const _TrendHeaderAvatar({this.avatarUrl});
final String? avatarUrl;
@override
Widget build(BuildContext context) {
final imageUrl = avatarUrl?.trim();
return Container(
width: 36,
height: 36,
decoration: ShapeDecoration(
image: imageUrl?.isNotEmpty == true
? DecorationImage(
image: CachedNetworkImageProvider(imageUrl!),
fit: BoxFit.cover,
)
: null,
shape: RoundedRectangleBorder(
side: const BorderSide(width: 0.78, color: Colors.white),
borderRadius: BorderRadius.circular(35.78),
),
),
child: imageUrl?.isNotEmpty == true ? null : const _AvatarFallback(),
);
}
}
class _AvatarFallback extends StatelessWidget {
const _AvatarFallback();
@override
Widget build(BuildContext context) {
return const Icon(
Icons.person_rounded,
color: Colors.white,
size: 22,
);
}
}
... ...
import 'package:cached_network_image/cached_network_image.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class TrendFriendSelectBottomSheet extends StatelessWidget {
const TrendFriendSelectBottomSheet({
super.key,
required this.scrollController,
required this.friendsList,
required this.selectedFriend,
required this.onSelectSelf,
required this.onSelectFriend,
});
final ScrollController scrollController;
final RxList<FriendItem> friendsList;
final Rxn<FriendItem> selectedFriend;
final VoidCallback onSelectSelf;
final ValueChanged<FriendItem> onSelectFriend;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: context.colors.backgroundPage,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: SafeArea(
top: false,
child: Column(
children: [
SizedBox(
height: 64,
child: Stack(
alignment: Alignment.center,
children: [
Center(
child: Text(
'选择好友',
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
Positioned(
left: 16,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: Get.back,
child: SizedBox(
width: 44,
height: 44,
child: Center(
child: Image.asset(
'assets/images/common/ic_close.png',
width: 20,
height: 20,
color: context.colors.chartPurple,
),
),
),
),
),
],
),
),
Expanded(
child: Obx(() {
final self = Get.find<UserPreferencesStorage>()
.preferences
.value
.meUserInfo;
final selfNickname = self?.nickname?.trim();
final currentSelectedId = selectedFriend.value?.friendUserId;
final itemCount = friendsList.length + 1;
return ListView.separated(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
itemCount: itemCount,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
if (index == 0) {
return _BottomSheetUserRow(
name: selfNickname?.isNotEmpty == true
? selfNickname!
: '我',
subtitle: selfNickname?.isNotEmpty == true ? '我' : null,
avatarUrl: self?.avatar,
isSelected: currentSelectedId == null,
onTap: () {
onSelectSelf();
Get.back();
},
);
}
final friend = friendsList[index - 1];
final name = _friendName(friend);
final nickname = friend.friendNickname?.trim();
return _BottomSheetUserRow(
name: name,
subtitle: nickname?.isNotEmpty == true ? nickname : null,
avatarUrl: friend.avatar,
isSelected: friend.friendUserId == currentSelectedId,
onTap: () {
onSelectFriend(friend);
Get.back();
},
);
},
);
}),
),
],
),
),
);
}
String _friendName(FriendItem friend) {
final remark = friend.remarkName?.trim();
if (remark?.isNotEmpty == true) return remark!;
final nickname = friend.friendNickname?.trim();
if (nickname?.isNotEmpty == true) return nickname!;
return '未知好友';
}
}
class _BottomSheetUserRow extends StatelessWidget {
const _BottomSheetUserRow({
required this.name,
this.subtitle,
this.avatarUrl,
required this.isSelected,
required this.onTap,
});
final String name;
final String? subtitle;
final String? avatarUrl;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: isSelected
? Border.all(color: const Color(0xFF845EEE), width: 1)
: null,
),
child: Row(
children: [
const SizedBox(width: 20),
_RowAvatar(avatarUrl: avatarUrl),
const SizedBox(width: 8),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: name,
style: TextStyle(
color: context.colors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
if (subtitle?.isNotEmpty == true)
TextSpan(
text: '($subtitle)',
style: TextStyle(
color: context.colors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
overflow: TextOverflow.ellipsis,
),
),
Container(
width: 20,
height: 20,
margin: const EdgeInsets.only(right: 16),
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
isSelected ? const Color(0xFF845EEE) : Colors.transparent,
border: isSelected
? null
: Border.all(
color: const Color(0xFF0F0F11).withValues(alpha: 0.2),
width: 1.5,
),
),
child: isSelected
? const Icon(Icons.check, color: Colors.white, size: 12)
: null,
),
],
),
),
);
}
}
class _RowAvatar extends StatelessWidget {
const _RowAvatar({this.avatarUrl});
final String? avatarUrl;
@override
Widget build(BuildContext context) {
final imageUrl = avatarUrl?.trim();
return Container(
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF845EEE).withValues(alpha: 0.3),
width: 0.8,
),
),
child: ClipOval(
child: imageUrl?.isNotEmpty == true
? CachedNetworkImage(
imageUrl: imageUrl!,
width: 28,
height: 28,
fit: BoxFit.cover,
)
: const Icon(
Icons.person_rounded,
color: Color(0xFF845EEE),
size: 18,
),
),
);
}
}
... ...
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import '../../../../r.dart';
import '../../report_common/utils/report_localization.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../../report_common/utils/report_localization.dart';
import '../models/hrv_report_models.dart';
class HrvWeekReportView extends StatelessWidget {
... ... @@ -167,6 +167,8 @@ class _HrvBarChartState extends State<_HrvBarChart> {
static const _plotLeft = _axisLabelWidth;
static const _plotRight = 0.0;
static const _bottomTitleHeight = 31.0;
static const _weekBarWidth = 16.0;
static const _weekBarGap = 22.0;
static const _tooltipBackground = Color(0xFFF3F3F3);
static const _tooltipDateColor = Color(0xFF78787D);
static const _tooltipMetaColor = Color(0xFFB0B0B6);
... ... @@ -185,7 +187,7 @@ class _HrvBarChartState extends State<_HrvBarChart> {
barRods: [
BarChartRodData(
toY: day.averageHrv ?? 0,
width: widget.isMonth ? 4 : 13,
width: widget.isMonth ? 4 : _weekBarWidth,
color: day.level == null
? Colors.transparent
: Color(day.level!.colorValue),
... ... @@ -207,7 +209,10 @@ class _HrvBarChartState extends State<_HrvBarChart> {
BarChartData(
minY: 0,
maxY: 110,
alignment: BarChartAlignment.spaceAround,
alignment: widget.isMonth
? BarChartAlignment.spaceAround
: BarChartAlignment.center,
groupsSpace: widget.isMonth ? 16 : _weekBarGap,
barGroups: bars,
borderData: FlBorderData(show: false),
gridData: FlGridData(
... ... @@ -302,6 +307,8 @@ class _HrvBarChartState extends State<_HrvBarChart> {
child: _TrendXAxisLabels(
report: widget.report,
isMonth: widget.isMonth,
weekBarWidth: _weekBarWidth,
weekBarGap: _weekBarGap,
),
),
),
... ... @@ -369,6 +376,15 @@ class _HrvBarChartState extends State<_HrvBarChart> {
final plotStart = _plotLeft;
final plotWidth = width - plotStart - _plotRight;
if (plotWidth <= 0) return null;
if (!widget.isMonth) {
final groupCount = widget.report.days.length;
final chartWidth =
groupCount * _weekBarWidth + (groupCount - 1) * _weekBarGap;
final chartLeft = plotStart + (plotWidth - chartWidth) / 2;
return chartLeft +
_weekBarWidth / 2 +
index * (_weekBarWidth + _weekBarGap);
}
return plotStart + plotWidth * ((index + 0.5) / widget.report.days.length);
}
}
... ... @@ -397,6 +413,7 @@ class _TrendMetric extends StatelessWidget {
@override
Widget build(BuildContext context) {
final difference = hasData ? currentValue - (previousValue ?? 0) : null;
final isGoodChange = _isGoodChange(difference);
final comparison = hasData
? isMonth
? _monthComparisonText(context, difference!)
... ... @@ -427,12 +444,12 @@ class _TrendMetric extends StatelessWidget {
Row(
children: [
if (difference != null && difference != 0) ...[
Icon(
difference > 0
? Icons.keyboard_arrow_up_rounded
: Icons.keyboard_arrow_down_rounded,
size: 13,
color: comparisonColor,
Image.asset(
isGoodChange
? R.assetsImagesHealthTrendUp
: R.assetsImagesHealthTrendDown,
width: 12,
height: 12,
),
const SizedBox(width: 1),
],
... ... @@ -481,11 +498,17 @@ class _TrendMetric extends StatelessWidget {
if (difference == null || difference == 0) {
return const Color(0xFFB0B0B6);
}
final isGoodChange = switch (metricType) {
return _isGoodChange(difference)
? const Color(0xFF3BD49D)
: const Color(0xFFFF5279);
}
bool _isGoodChange(int? difference) {
if (difference == null || difference == 0) return false;
return switch (metricType) {
_TrendMetricType.relaxed => difference > 0,
_TrendMetricType.stressed => difference < 0,
};
return isGoodChange ? const Color(0xFF3BD49D) : const Color(0xFFFF5279);
}
}
... ... @@ -530,10 +553,14 @@ class _TrendXAxisLabels extends StatelessWidget {
const _TrendXAxisLabels({
required this.report,
required this.isMonth,
required this.weekBarWidth,
required this.weekBarGap,
});
final HrvPeriodReport report;
final bool isMonth;
final double weekBarWidth;
final double weekBarGap;
@override
Widget build(BuildContext context) {
... ... @@ -563,9 +590,12 @@ class _TrendXAxisLabels extends StatelessWidget {
for (final label in labels)
if (label.index < groupCount)
Positioned(
left: constraints.maxWidth *
((label.index + 0.5) / groupCount) -
labelWidth / 2,
left: _labelLeft(
constraints.maxWidth,
groupCount,
label.index,
labelWidth,
),
top: 5,
width: labelWidth,
child: Text(
... ... @@ -583,6 +613,24 @@ class _TrendXAxisLabels extends StatelessWidget {
},
);
}
double _labelLeft(
double width,
int groupCount,
int index,
double labelWidth,
) {
if (isMonth) {
return width * ((index + 0.5) / groupCount) - labelWidth / 2;
}
final chartWidth =
groupCount * weekBarWidth + (groupCount - 1) * weekBarGap;
final chartLeft = (width - chartWidth) / 2;
return chartLeft +
weekBarWidth / 2 +
index * (weekBarWidth + weekBarGap) -
labelWidth / 2;
}
}
class _DistributionCard extends StatelessWidget {
... ... @@ -737,32 +785,167 @@ class _DistributionBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: 33,
height: 180,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: const Color(0xFFF3F3F3),
borderRadius: BorderRadius.circular(7)),
child: report.hasData
? Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (final level in HrvStressLevel.values)
if (report.countFor(level) > 0)
Expanded(
flex: report.countFor(level),
child: Container(
decoration: BoxDecoration(
color: Color(level.colorValue),
border: Border.all(color: Colors.white, width: .5),
),
),
),
],
)
: null,
const radius = 7.0;
final segments = [
for (final level in HrvStressLevel.values)
if (report.countFor(level) > 0)
_DistributionBarSegment(
color: Color(level.colorValue),
flex: report.countFor(level),
),
];
final hasSegments = report.hasData && segments.isNotEmpty;
final height = hasSegments
? _DistributionBarPainter.visibleHeightFor(segments)
: 180.0;
final paintSize = Size(44.w, height);
return SizedBox.fromSize(
size: const Size(33, 180),
child: OverflowBox(
alignment: Alignment.bottomLeft,
minWidth: paintSize.width,
maxWidth: paintSize.width,
minHeight: paintSize.height,
maxHeight: paintSize.height,
child: Transform.translate(
offset: const Offset(0, -12),
child: SizedBox.fromSize(
size: paintSize,
child: CustomPaint(
painter: _DistributionBarPainter(
segments: hasSegments ? segments : const [],
backgroundColor: const Color(0xFFF3F3F3),
radius: radius,
),
),
),
),
),
);
}
}
class _DistributionBarSegment {
const _DistributionBarSegment({required this.color, required this.flex});
final Color color;
final int flex;
}
class _DistributionBarPainter extends CustomPainter {
const _DistributionBarPainter({
required this.segments,
required this.backgroundColor,
required this.radius,
});
final List<_DistributionBarSegment> segments;
final Color backgroundColor;
final double radius;
static const maxVisibleHeight = 220.0;
static const _borderWidth = 1.0;
static const _overlapHeight = 16.0;
static const _weekDayCount = 7;
static double visibleHeightFor(List<_DistributionBarSegment> segments) {
if (segments.isEmpty) return 0;
final totalFlex =
segments.fold<int>(0, (sum, segment) => sum + segment.flex);
return maxVisibleHeight * totalFlex / _weekDayCount;
}
@override
void paint(Canvas canvas, Size size) {
final clip = RRect.fromRectAndRadius(
Offset.zero & size,
Radius.circular(radius),
);
canvas.save();
canvas.clipRRect(clip);
final total = segments.fold<int>(0, (sum, segment) => sum + segment.flex);
if (total == 0) {
canvas.drawRRect(
clip,
Paint()
..color = backgroundColor
..style = PaintingStyle.fill,
);
canvas.restore();
return;
}
final visibleHeights = _visibleSegmentHeightsFor(size.height, segments);
var bottom = 0.0;
for (var index = 0; index < segments.length; index++) {
final segment = segments[index];
final isBottomSegment = index == segments.length - 1;
final drawTop = index == 0 ? 0.0 : bottom - _overlapHeight;
final drawBottom = drawTop +
visibleHeights[index] +
(isBottomSegment ? 0 : _overlapHeight);
_drawLayeredSegment(
canvas,
Rect.fromLTRB(0, drawTop, size.width, drawBottom),
color: segment.color,
radius: radius,
);
bottom = drawBottom;
}
canvas.restore();
}
static List<double> _visibleSegmentHeightsFor(
double visibleHeight,
List<_DistributionBarSegment> segments,
) {
final totalFlex =
segments.fold<int>(0, (sum, segment) => sum + segment.flex);
final dayHeight = visibleHeight / totalFlex;
return [
for (final segment in segments) segment.flex * dayHeight,
];
}
void _drawLayeredSegment(
Canvas canvas,
Rect rect, {
required Color color,
required double radius,
}) {
final rrect = RRect.fromRectAndRadius(rect, Radius.circular(radius));
canvas.drawRRect(
rrect,
Paint()
..color = color
..style = PaintingStyle.fill,
);
canvas.drawRRect(
rrect.deflate(_borderWidth / 2),
Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = _borderWidth,
);
}
@override
bool shouldRepaint(covariant _DistributionBarPainter oldDelegate) {
if (backgroundColor != oldDelegate.backgroundColor ||
radius != oldDelegate.radius ||
segments.length != oldDelegate.segments.length) {
return true;
}
for (var i = 0; i < segments.length; i++) {
final segment = segments[i];
final oldSegment = oldDelegate.segments[i];
if (segment.color != oldSegment.color ||
segment.flex != oldSegment.flex) {
return true;
}
}
return false;
}
}
... ...
... ... @@ -328,7 +328,7 @@ class _DayDatePickerSheetState extends State<_DayDatePickerSheet> {
child: Stack(
alignment: Alignment.center,
children: [
const _SelectionFrame(fillWhite: false),
const _SelectionFrame(fillWhite: true),
Row(
children: isChinese
? [yearPicker, monthPicker, dayPicker]
... ...
// ─── Responses ───────────────────────────────────────────────────────────────
class FriendListResponse {
const FriendListResponse({this.list = const [], this.limit});
const FriendListResponse({
this.list = const [],
this.limit,
this.healthData,
});
final List<FriendItem> list;
final int? limit;
final FriendHealthData? healthData;
factory FriendListResponse.fromJson(Map<String, dynamic> json) {
return FriendListResponse(
... ... @@ -13,12 +18,18 @@ class FriendListResponse {
.toList() ??
const [],
limit: json['limit'] as int?,
healthData: json['health_data'] == null
? null
: FriendHealthData.fromJson(
json['health_data'] as Map<String, dynamic>,
),
);
}
Map<String, dynamic> toJson() => {
'list': list.map((e) => e.toJson()).toList(),
'limit': limit,
if (healthData != null) 'health_data': healthData!.toJson(),
};
}
... ... @@ -99,6 +110,7 @@ class FriendHealthData {
this.realtimeStress,
this.sleepEvaluate,
this.totalSteps,
this.lastDataTime,
});
/// HRV state indicator
... ... @@ -116,13 +128,17 @@ class FriendHealthData {
/// Total step count for the day (nullable)
final int? totalSteps;
/// Last health data timestamp, in seconds or milliseconds.
final num? lastDataTime;
factory FriendHealthData.fromJson(Map<String, dynamic> json) {
return FriendHealthData(
hrvState: json['hrv_state'] as int?,
latestHrv: (json['latest_hrv'] as num?)?.toDouble(),
hrvState: _parseInt(json['hrv_state']),
latestHrv: _parseDouble(json['latest_hrv']),
realtimeStress: json['realtime_stress'] as Map<String, dynamic>?,
sleepEvaluate: json['sleep_evaluate'] as int?,
totalSteps: json['total_steps'] as int?,
sleepEvaluate: _parseInt(json['sleep_evaluate']),
totalSteps: _parseInt(json['total_steps']),
lastDataTime: _parseNum(json['last_data_time']),
);
}
... ... @@ -133,6 +149,29 @@ class FriendHealthData {
if (realtimeStress != null) val['realtime_stress'] = realtimeStress;
if (sleepEvaluate != null) val['sleep_evaluate'] = sleepEvaluate;
if (totalSteps != null) val['total_steps'] = totalSteps;
if (lastDataTime != null) val['last_data_time'] = lastDataTime;
return val;
}
}
int? _parseInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is double) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
double? _parseDouble(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
num? _parseNum(dynamic value) {
if (value == null) return null;
if (value is num) return value;
if (value is String) return num.tryParse(value);
return null;
}
... ...