Commit 5103946971d087d3323dc35f23ff611ea066690b

Authored by 常守达
1 parent 1aace189

feat(friend): 好友主页

... ... @@ -100,7 +100,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
DeviceGuru: f0f2bd81953d82777689f22025bca418e0c51d64
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
image_cropper: c4326ea50132b1e1564499e5d32a84f01fb03537
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
... ...
import 'package:doublefeel_flutter/app/modules/home/controllers/today_controller.dart';
import 'package:doublefeel_flutter/core/network/api/health_api.dart';
import 'package:doublefeel_flutter/core/network/api/user_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:doublefeel_flutter/app/apple_health_upload/apple_health_upload_tool.dart';
import 'package:get/get.dart';
/// 跳转到好友主页时需要传入的参数。
class FriendHomeArguments {
const FriendHomeArguments({
required this.userId,
this.name,
this.avatarUrl,
});
final int userId;
final String? name;
final String? avatarUrl;
}
class FriendHomeBinding extends Bindings {
@override
void dependencies() {
final args = Get.arguments as FriendHomeArguments;
Get.lazyPut<TodayController>(
() => TodayController(
Get.find<UserApi>(),
Get.find<HealthApi>(),
Get.find<UserStateService>(),
Get.find<HealthKitUploadService>(),
Get.find<AppleHealthUploadTool>(),
friendUserId: args.userId, // 好友 userId,驱动 isFriend = true
),
tag: 'friend_${args.userId}', // 动态 tag,支持多个好友同时在路由栈中
);
}
}
... ...
import 'package:doublefeel_flutter/app/modules/home/controllers/today_controller.dart';
import 'package:doublefeel_flutter/app/modules/home/views/tabs/today_tab.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../bindings/friend_home_binding.dart';
class FriendHomePage extends GetView<TodayController> {
const FriendHomePage({super.key});
FriendHomeArguments get _args => Get.arguments as FriendHomeArguments;
/// 与 FriendHomeBinding 中注册的 tag 保持一致
@override
String? get tag => 'friend_${_args.userId}';
@override
Widget build(BuildContext context) {
return Scaffold(
body: TodayTabBody(controller: controller),
);
}
}
... ...
... ... @@ -12,7 +12,6 @@ import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
import 'package:doublefeel_flutter/data/models/health/health_models.dart';
import 'package:doublefeel_flutter/data/models/health/health_upload_models.dart';
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
... ... @@ -46,8 +45,9 @@ class TodayController extends GetxController {
this._healthApi,
this._userStateService,
this._healthKitUploadService,
this._appleHealthUploadTool,
);
this._appleHealthUploadTool, {
this.friendUserId, // null = 自己,非 null = 好友
});
final UserApi _userApi;
final HealthApi _healthApi;
... ... @@ -56,6 +56,12 @@ class TodayController extends GetxController {
final AppleHealthUploadTool _appleHealthUploadTool;
final HealthKitHostApi _hostApi = HealthKitHostApi();
/// 好友的 userId;null 表示查看自己的数据,非 null 表示查看好友的数据。
final int? friendUserId;
/// 是否正在查看好友数据。
bool get isFriend => friendUserId != null;
UserStateService get userStateService => _userStateService;
late final DateTime firstSelectableDay;
... ... @@ -164,7 +170,6 @@ class TodayController extends GetxController {
isLoadingToday.value = true;
try {
await Future.wait([
_refreshUserGreeting(),
_refreshHealthAuthorizationState(),
_refreshHealthDataForDate(date),
]);
... ... @@ -195,16 +200,6 @@ class TodayController extends GetxController {
}
}
Future<void> _refreshUserGreeting() async {
final result = await _userApi.getUserInfo(errorHandlingPolicy: null);
if (result case AppSuccess<UserInfoResponse>(data: final user)) {
final name = user.nickname?.trim();
stressSubtitle.value = name == null || name.isEmpty
? 'Hi, 你今日的综合压力状态'
: 'Hi, $name 今日的综合压力状态';
}
}
Future<void> _refreshHealthAuthorizationState() async {
try {
final result = await _hostApi.checkHealthAppAuthorization();
... ... @@ -224,34 +219,37 @@ class TodayController extends GetxController {
final todayResultFuture = isToday
? _healthApi.getTodayData(
isOther: false,
isOther: isFriend,
errorHandlingPolicy: null,
)
: Future.value(
AppFailure<TodayStatusData>(AppUnknownError('Not today')));
final latestHrvResultFuture = isToday
final latestHrvResultFuture = isToday && !isFriend
? _healthApi.getLatestHrvData(
errorHandlingPolicy: null,
)
: Future.value(AppFailure<LatestHrvData>(AppUnknownError('Not today')));
final hrvStatisticsResultFuture = _healthApi.getHrvStatistics(
isOther: false,
isOther: isFriend,
dateRangeType: dateRangeType,
startDate: startDate,
targetUserId: friendUserId,
errorHandlingPolicy: null,
);
final sleepStatisticsResultFuture = _healthApi.getSleepStateStatistics(
isOther: false,
isOther: isFriend,
dateRangeType: dateRangeType,
startDate: startDate,
targetUserId: friendUserId,
errorHandlingPolicy: null,
);
final activityStatisticsResultFuture = _healthApi.getActivityBurnStatistics(
isOther: false,
isOther: isFriend,
dateRangeType: dateRangeType,
startDate: startDate,
targetUserId: friendUserId,
errorHandlingPolicy: null,
);
... ...
import 'package:cached_network_image/cached_network_image.dart';
import 'package:doublefeel_flutter/app/actions/dialog_action.dart';
import 'package:doublefeel_flutter/app/models/input_dialog_meta_data.dart';
import 'package:doublefeel_flutter/app/modules/friends/bindings/friend_home_binding.dart';
import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.dart';
import 'package:doublefeel_flutter/app/modules/home/widgets/my/account_setting_view.dart';
import 'package:doublefeel_flutter/app/modules/watch_theme/models/watch_theme_models.dart';
... ... @@ -68,12 +69,12 @@ class MyTab extends GetView<MyController> {
onTap: () => Get.toNamed(Routes.HELP),
),
const SizedBox(height: 12),
_SettingsRow(
_SettingsRow(
title: 'Apple Health Upload 测试',
onTap: () {
onTap: () {
controller.testAppleHealthUpload();
},
),
},
),
if (kDebugMode) ...[
const SizedBox(height: 12),
_SettingsRow(
... ...
import 'package:doublefeel_flutter/app/modules/home/widgets/today/hrv_measurement_bottom_sheet.dart';
import 'package:doublefeel_flutter/app/modules/report_common/models/report_period.dart';
import 'package:doublefeel_flutter/app/modules/report_common/widgets/report_date_picker_sheet.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
... ... @@ -9,7 +10,6 @@ import 'package:intl/intl.dart';
import 'package:table_calendar/table_calendar.dart';
import '../../controllers/today_controller.dart';
import '../../widgets/today/hrv_measurement_bottom_sheet.dart';
import '../../widgets/today/today_health_data_auth_card.dart';
import '../../widgets/today/today_hrv_ad_banner.dart';
import '../../widgets/today/today_hrv_chart_card.dart';
... ... @@ -23,6 +23,15 @@ import '../../widgets/today/stress_progress_bar.dart';
class TodayTab extends GetView<TodayController> {
const TodayTab({super.key});
@override
Widget build(BuildContext context) => TodayTabBody(controller: controller);
}
class TodayTabBody extends StatelessWidget {
const TodayTabBody({super.key, required this.controller});
final TodayController controller;
final _bgColor = const Color(0xFFF2F2F7);
final _weekCalendarHeight = 58.0;
final _topContentHeight = /*_topBarHeight + _weekCalendarHeight*/ 106.0;
... ... @@ -183,13 +192,13 @@ class TodayTab extends GetView<TodayController> {
? const SizedBox.shrink()
: const PremiumCard();
}),
const TodayHrvAdBanner(),
const TodayPartnerAdBanner(),
const TodayHrvNumberCard(),
TodayHrvAdBanner(controller: controller),
TodayPartnerAdBanner(controller: controller),
TodayHrvNumberCard(controller: controller),
const SizedBox(height: 12),
const TodayHrvChartCard(),
TodayHrvChartCard(controller: controller),
const SizedBox(height: 12),
const _LatestHrvCard(),
_LatestHrvCard(controller: controller),
const SizedBox(height: 12),
measureHRVButton(context),
Padding(
... ... @@ -203,8 +212,8 @@ class TodayTab extends GetView<TodayController> {
),
),
),
const TodaySleepCard(),
const TodayActivityCard(),
TodaySleepCard(controller: controller),
TodayActivityCard(controller: controller),
Container(
alignment: Alignment.center,
margin: EdgeInsets.only(
... ... @@ -458,8 +467,10 @@ class TodayTab extends GetView<TodayController> {
}
}
class _LatestHrvCard extends GetView<TodayController> {
const _LatestHrvCard();
class _LatestHrvCard extends StatelessWidget {
const _LatestHrvCard({required this.controller});
final TodayController controller;
@override
Widget build(BuildContext context) {
... ...
... ... @@ -6,8 +6,10 @@ import 'package:get/get.dart';
import '../../controllers/today_controller.dart';
class TodayHrvAdBanner extends GetView<TodayController> {
const TodayHrvAdBanner({super.key});
class TodayHrvAdBanner extends StatelessWidget {
const TodayHrvAdBanner({super.key, required this.controller});
final TodayController controller;
@override
Widget build(BuildContext context) {
... ... @@ -37,8 +39,10 @@ class TodayHrvAdBanner extends GetView<TodayController> {
}
/// Figma: 添加亲密联系人 Banner,右上角关闭按钮
class TodayPartnerAdBanner extends GetView<TodayController> {
const TodayPartnerAdBanner({super.key});
class TodayPartnerAdBanner extends StatelessWidget {
const TodayPartnerAdBanner({super.key, required this.controller});
final TodayController controller;
@override
Widget build(BuildContext context) {
... ...
... ... @@ -17,8 +17,10 @@ import 'realtime_stress_explanation_bottom_sheet.dart';
// Y轴:
// 范围0ms ~ 80ms
// 每10ms为一个刻度
class TodayHrvChartCard extends GetView<TodayController> {
const TodayHrvChartCard({super.key});
class TodayHrvChartCard extends StatelessWidget {
const TodayHrvChartCard({super.key, required this.controller});
final TodayController controller;
static const _h5 = Color(0xFFCCCCCC);
... ...
... ... @@ -6,8 +6,10 @@ import 'package:get/get.dart';
import '../../controllers/today_controller.dart';
/// Figma: 两列数字展示 — 该日平均HRV(46ms) + 静息心率(63bpm)
class TodayHrvNumberCard extends GetView<TodayController> {
const TodayHrvNumberCard({super.key});
class TodayHrvNumberCard extends StatelessWidget {
const TodayHrvNumberCard({super.key, required this.controller});
final TodayController controller;
@override
Widget build(BuildContext context) {
... ...
... ... @@ -6,8 +6,10 @@ import 'package:get/get.dart';
import '../../controllers/today_controller.dart';
class TodaySleepCard extends GetView<TodayController> {
const TodaySleepCard({super.key});
class TodaySleepCard extends StatelessWidget {
const TodaySleepCard({super.key, required this.controller});
final TodayController controller;
@override
Widget build(BuildContext context) {
... ... @@ -82,8 +84,10 @@ class TodaySleepCard extends GetView<TodayController> {
}
}
class TodayActivityCard extends GetView<TodayController> {
const TodayActivityCard({super.key});
class TodayActivityCard extends StatelessWidget {
const TodayActivityCard({super.key, required this.controller});
final TodayController controller;
@override
Widget build(BuildContext context) {
... ...
... ... @@ -38,6 +38,8 @@ class LoginController extends GetxController {
final termsChecked = false.obs;
AppleSignInModel? appleSignInModel;
void toggleTermsChecked() {
termsChecked.value = !termsChecked.value;
}
... ... @@ -56,10 +58,13 @@ class LoginController extends GetxController {
return;
}
final result = await PlatformHostApi().requestAppleSignIn();
if (result == null) {
// result 为 null 或 identityToken 为空,说明用户取消或苹果授权失败
if (result == null || result.identityToken?.isNotEmpty != true) {
appleSignInModel = null;
return;
}
appleSignInModel = result;
await _loginWithApple(result);
}
void onDebugPressed() {
... ... @@ -164,80 +169,102 @@ class LoginController extends GetxController {
);
if (loginRes is AppSuccess<LoginResponse>) {
final loginData = loginRes.data;
final isRegister = loginData.isNewUser == true;
String accessToken = loginData.accessToken;
if (isRegister) {
final regRes = await _userApi.register(
telephone: cleanPhone,
verifyCode: codeInput.value,
);
if (regRes is AppSuccess<RegisterResponse>) {
accessToken = regRes.data.accessToken;
} else {
isLoggingIn.value = false;
return;
}
await _handleLoginSuccess(loginRes.data);
} else {
isLoggingIn.value = false;
}
}
Future<void> _loginWithApple(AppleSignInModel appleModel) async {
isLoggingIn.value = true;
final loginRes = await _userApi.loginWithApple(
identityToken: appleModel.identityToken!,
email: appleModel.email,
nickname: appleModel.nickname,
);
if (loginRes is AppSuccess<LoginResponse>) {
await _handleLoginSuccess(loginRes.data, isApple: true);
} else {
isLoggingIn.value = false;
}
}
/// 登录/注册成功后的公共处理逻辑(手机号登录与苹果登录共用)
Future<void> _handleLoginSuccess(
LoginResponse loginData, {
bool isApple = false,
}) async {
final isRegister = loginData.isNewUser == true;
String accessToken = loginData.accessToken;
if (isRegister && !isApple) {
// 苹果登录时后端直接完成注册,无需二次注册请求
final regRes = await _userApi.register(
telephone: cleanPhone,
verifyCode: codeInput.value,
);
if (regRes is AppSuccess<RegisterResponse>) {
accessToken = regRes.data.accessToken;
} else {
isLoggingIn.value = false;
return;
}
}
// Fetch user info and VIP info
final userInfoResFuture = _userApi.getUserInfo(accessToken: accessToken);
final vipInfoResFuture = isRegister
? Future.value(null)
: _vipApi.getVipInfo(accessToken: accessToken);
final results = await Future.wait([userInfoResFuture, vipInfoResFuture]);
final userInfoRes = results[0];
final vipInfoRes = results[1];
if (userInfoRes is AppSuccess<UserInfoResponse>) {
final me = userInfoRes.data;
UserInfoResponse? partner;
if ((me.pairId ?? 0) > 0) {
final partnerRes =
await _userApi.getPartnerUserInfo(accessToken: accessToken);
if (partnerRes is AppSuccess<BoundUserInfoResponse>) {
partner = partnerRes.data.partnerUserInfo;
}
// Fetch user info and VIP info
final userInfoResFuture = _userApi.getUserInfo(accessToken: accessToken);
final vipInfoResFuture = isRegister
? Future.value(null)
: _vipApi.getVipInfo(accessToken: accessToken);
final results = await Future.wait([userInfoResFuture, vipInfoResFuture]);
final userInfoRes = results[0];
final vipInfoRes = results[1];
if (userInfoRes is AppSuccess<UserInfoResponse>) {
final me = userInfoRes.data;
UserInfoResponse? partner;
if ((me.pairId ?? 0) > 0) {
final partnerRes =
await _userApi.getPartnerUserInfo(accessToken: accessToken);
if (partnerRes is AppSuccess<BoundUserInfoResponse>) {
partner = partnerRes.data.partnerUserInfo;
}
}
VipInfo? vip;
if (vipInfoRes is AppSuccess<VipInfo>) {
vip = vipInfoRes.data;
}
VipInfo? vip;
if (vipInfoRes is AppSuccess<VipInfo>) {
vip = vipInfoRes.data;
}
await _userPrefs.updateFromLogin(
accessToken: accessToken,
me: me,
partner: partner,
vip: vip,
);
await _userPrefs.updateFromLogin(
accessToken: accessToken,
me: me,
partner: partner,
vip: vip,
);
await _userStateService.onLogin();
await _userStateService.onLogin();
// 用户登录成功即代表同意了协议,持久化到本地供冷启动时 SDK 初始化判断使用
await Get.find<LocalStorage>().setTermsAgreed(true);
// 用户登录成功即代表同意了协议,持久化到本地供冷启动时 SDK 初始化判断使用
await Get.find<LocalStorage>().setTermsAgreed(true);
isLoggingIn.value = false;
isLoggingIn.value = false;
// 根据引导完成状态决定跳转目标
final userId = me.id ?? 0;
if (_userAccount.hasCompletedOnboarding(userId)) {
// 该账号已完成引导,直接进主页
Get.offAllNamed(AppRoutes.home);
} else {
// 新用户或未完成引导,进入引导页(支持断点续做)
final resumeStage = _userAccount.onboardingResumeStage(userId);
Get.offAllNamed(
AppRoutes.userOnboarding,
arguments:
resumeStage != null ? {'resumeStage': resumeStage} : null,
);
}
// 根据引导完成状态决定跳转目标
final userId = me.id ?? 0;
if (_userAccount.hasCompletedOnboarding(userId)) {
// 该账号已完成引导,直接进主页
Get.offAllNamed(AppRoutes.home);
} else {
isLoggingIn.value = false;
// 新用户或未完成引导,进入引导页(支持断点续做)
final resumeStage = _userAccount.onboardingResumeStage(userId);
Get.offAllNamed(
AppRoutes.userOnboarding,
arguments: resumeStage != null ? {'resumeStage': resumeStage} : null,
);
}
} else {
isLoggingIn.value = false;
... ...
import 'package:doublefeel_flutter/app/modules/friends/bindings/friend_home_binding.dart';
import 'package:doublefeel_flutter/app/modules/friends/views/friend_home_page.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
... ... @@ -103,6 +105,11 @@ abstract final class AppPages {
binding: BindPartnerBinding(),
),
GetPage(
name: Routes.FRIEND_HOME,
page: () => const FriendHomePage(),
binding: FriendHomeBinding(),
),
GetPage(
name: Routes.ADD_FRIEND,
page: () => const AddFriendView(),
binding: AddFriendBinding(),
... ...
... ... @@ -19,6 +19,7 @@ abstract class Routes {
static const WATCH_THEME_CUSTOM_PREVIEW = _Paths.WATCH_THEME_CUSTOM_PREVIEW;
static const ACCOUNT_SETTINGS = _Paths.ACCOUNT_SETTINGS;
static const FRIEND_TREND = _Paths.FRIEND_TREND;
static const FRIEND_HOME = _Paths.FRIEND_HOME;
static const APPLE_HEALTH_UPLOAD_TEST = _Paths.APPLE_HEALTH_UPLOAD_TEST;
}
... ... @@ -40,5 +41,6 @@ abstract class _Paths {
static const WATCH_THEME_CUSTOM_PREVIEW = '/watch-theme/custom-preview';
static const ACCOUNT_SETTINGS = '/account-settings';
static const FRIEND_TREND = '/friend-trend';
static const FRIEND_HOME = '/friend-home';
static const APPLE_HEALTH_UPLOAD_TEST = '/apple-health-upload-test';
}
... ...
... ... @@ -42,6 +42,27 @@ class UserApi {
);
}
Future<AppResult<LoginResponse>> loginWithApple({
required String identityToken,
String? email,
String? nickname,
}) {
return safeCall(
call: () async {
final response = await _dioClient.dio.post(
ApiPaths.userLogin,
data: AppleLoginRequest(
identityToken: identityToken,
email: email,
nickname: nickname,
).toJson(),
options: _dioClient.noTokenOptions(),
);
return LoginResponse.fromJson(response.data as Map<String, dynamic>);
},
);
}
Future<AppResult<RegisterResponse>> register({
required String telephone,
required String verifyCode,
... ...
... ... @@ -18,6 +18,27 @@ class LoginRequest {
};
}
class AppleLoginRequest {
const AppleLoginRequest({
this.loginType = 'apple',
required this.identityToken,
this.email,
this.nickname,
});
final String loginType;
final String identityToken;
final String? email;
final String? nickname;
Map<String, dynamic> toJson() => {
'login_type': loginType,
'identity_token': identityToken,
if (email != null) 'email': email,
if (nickname != null) 'nickname': nickname,
};
}
class RegisterRequest {
const RegisterRequest({
required this.telephone,
... ...