Commit b358cfbe92f9296edbd731cb7bc221ac0ca93008

Authored by 权海
1 parent 339fa401

增加互动主页

feat(ui):互动
import 'dart:async';
import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart';
import 'package:doublefeel_flutter/app/modules/friends/data/friends_repository.dart';
import 'package:doublefeel_flutter/app/modules/friends/models/friend_health_data.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/network/api/pay_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/result/app_result.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_datasource_wrapper.dart';
... ... @@ -14,6 +16,8 @@ import 'package:doublefeel_flutter/data/datasource/health/health_remote_datasour
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'
show FriendItem;
import 'package:doublefeel_flutter/data/models/user/user_models.dart'
show UserInfoResponse;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
... ... @@ -25,20 +29,24 @@ import 'today_controller.dart';
/// their date, scroll position, and loaded health data remain isolated.
class AppHomeController extends GetxController {
AppHomeController({
FriendsController? friendsController,
FriendsRepository? friendsRepository,
UserPreferencesStorage? userPreferencesStorage,
}) : _friendsController = friendsController ?? Get.find<FriendsController>(),
UserApi? userApi,
}) : _friendsRepository =
friendsRepository ?? FriendsRepositoryImpl(Get.find<FriendApi>()),
_userPreferencesStorage =
userPreferencesStorage ?? Get.find<UserPreferencesStorage>();
userPreferencesStorage ?? Get.find<UserPreferencesStorage>(),
_userApi = userApi ?? Get.find<UserApi>();
static const selfTodayTag = 'app_home_self';
static const _friendTodayTagPrefix = 'app_home_friend_';
final FriendsController _friendsController;
final FriendsRepository _friendsRepository;
final UserPreferencesStorage _userPreferencesStorage;
final UserApi _userApi;
final selectedTargetId = selfTodayTag.obs;
final watchedFriend = Rxn<FriendHealthData>();
late final Worker _friendsWorker;
final friends = <FriendHealthData>[].obs;
String? _friendTodayTag;
... ... @@ -53,8 +61,16 @@ class AppHomeController extends GetxController {
return Get.find<TodayController>(tag: tag);
}
TodayController? get friendTodayController {
final tag = _friendTodayTag;
if (tag == null || !Get.isRegistered<TodayController>(tag: tag)) {
return null;
}
return Get.find<TodayController>(tag: tag);
}
bool get hasWatchedFriend => watchedFriend.value != null;
bool get hasAnyFriend => _friendsController.friends.isNotEmpty;
bool get hasAnyFriend => friends.isNotEmpty;
bool get isShowingSelf => selectedTargetId.value == selfTodayTag;
String get selfName {
final info = _userPreferencesStorage.preferences.value.meUserInfo;
... ... @@ -70,12 +86,7 @@ class AppHomeController extends GetxController {
void onInit() {
super.onInit();
_registerSelfController();
_friendsWorker = ever<List<FriendHealthData>>(
_friendsController.friends,
_syncWatchedFriend,
);
unawaited(_friendsController.loadFriends());
_syncWatchedFriend(_friendsController.friends);
unawaited(refreshOnShow());
}
void selectSelf() => selectedTargetId.value = selfTodayTag;
... ... @@ -87,6 +98,34 @@ class AppHomeController extends GetxController {
}
}
/// Refreshes the data shown by the app-home header whenever this tab returns
/// to the foreground. The page itself is kept alive by the outer
/// [IndexedStack], so this cannot rely on [onInit].
Future<void> refreshOnShow() async {
await Future.wait<void>([_refreshMyProfile(), _loadFriends()]);
await refreshSelectedToday();
}
Future<void> _refreshMyProfile() async {
final result = await _userApi.getUserInfo();
if (result case AppSuccess<UserInfoResponse>(data: final user)) {
await _userPreferencesStorage.updateMeUserInfo(user);
}
}
/// The app home owns its friend snapshot and reads it directly from the
/// friend-list API. It intentionally does not depend on FriendsController,
/// whose list is scoped to the separate Friends tab.
Future<void> _loadFriends() async {
try {
final data = await _friendsRepository.getFriendList();
friends.assignAll(data.friends);
_syncWatchedFriend(data.friends);
} catch (_) {
// Keep the current home selection visible if refreshing friends fails.
}
}
Future<void> refreshSelectedToday() => selectedTodayController.refreshTab();
void scrollSelectedToLatestHrv() {
... ... @@ -110,6 +149,9 @@ class AppHomeController extends GetxController {
final nextId = next?.friendItem.friendUserId;
if (previousId == nextId) {
watchedFriend.value = next;
// The watched person did not change, but their nickname, remark, or
// avatar may have. Keep the friend Today controller in sync as well.
friendTodayController?.targetFriendInfo.value = next?.friendItem;
return;
}
... ... @@ -149,7 +191,6 @@ class AppHomeController extends GetxController {
@override
void onClose() {
_friendsWorker.dispose();
Get.delete<TodayController>(tag: selfTodayTag, force: true);
final tag = _friendTodayTag;
if (tag != null) Get.delete<TodayController>(tag: tag, force: true);
... ...
... ... @@ -163,7 +163,7 @@ class HomeController extends GetxController {
switch (index) {
case 0:
Get.find<AppHomeController>().refreshSelectedToday();
Get.find<AppHomeController>().refreshOnShow();
break;
case 3:
ta.track('enter_doublefeel_my_page');
... ...
... ... @@ -4,6 +4,8 @@ import 'package:doublefeel_flutter/app/modules/home/controllers/today_controller
import 'package:doublefeel_flutter/app/modules/home/views/tabs/today_tab.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/app/modules/home/widgets/today/app_home_stress_card.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'
show V2StressScore;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
... ... @@ -13,20 +15,16 @@ class AppHomeTab extends GetView<AppHomeController> {
@override
Widget build(BuildContext context) => Obx(() {
final friend = controller.watchedFriend.value;
final selfTodayController = controller.selfTodayController;
final friendTodayController = controller.friendTodayController;
final children = <Widget>[
_todayBody(
controller.selfTodayController,
showPeopleSwitcher: friend != null,
),
if (friend != null)
_todayBody(
Get.find<TodayController>(
tag: 'app_home_friend_${friend.friendItem.friendUserId}',
),
showPeopleSwitcher: true,
),
_todayBody(selfTodayController, showPeopleSwitcher: friend != null),
if (friend != null && friendTodayController != null)
_todayBody(friendTodayController, showPeopleSwitcher: true),
];
final index = controller.isShowingSelf || friend == null ? 0 : 1;
final index = controller.isShowingSelf || friendTodayController == null
? 0
: 1;
return IndexedStack(index: index, children: children);
});
... ... @@ -54,6 +52,12 @@ class _PeopleSwitcher extends StatelessWidget {
@override
Widget build(BuildContext context) => Obx(() {
final friend = controller.watchedFriend.value;
final selfTodayController = controller.selfTodayController;
final friendTodayController = controller.friendTodayController;
// Subscribe to both controllers explicitly. Their date changes update the
// score independently while the outer home tab remains mounted.
final selfStressScore = selfTodayController.v2StressScore.value;
final friendStressScore = friendTodayController?.v2StressScore.value;
if (friend == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
... ... @@ -84,7 +88,7 @@ class _PeopleSwitcher extends StatelessWidget {
isMe: false,
status: _statusText(
isSelected: !controller.isShowingSelf,
todayController: controller.selectedTodayController,
stressScore: friendStressScore,
),
scale: scale,
onTap: controller.selectFriend,
... ... @@ -98,7 +102,7 @@ class _PeopleSwitcher extends StatelessWidget {
isMe: true,
status: _statusText(
isSelected: controller.isShowingSelf,
todayController: controller.selectedTodayController,
stressScore: selfStressScore,
),
scale: scale,
onTap: controller.selectSelf,
... ... @@ -122,12 +126,12 @@ class _PeopleSwitcher extends StatelessWidget {
_StatusText? _statusText({
required bool isSelected,
required TodayController todayController,
required V2StressScore? stressScore,
}) {
if (!isSelected) return null;
final state = todayController.v2StressScore.value?.state;
final text = todayController.v2StressScore.value?.stateString();
if (text == null || text.isEmpty) return null;
if (!isSelected || stressScore == null) return null;
final state = stressScore.state;
final text = stressScore.stateString();
if (text.isEmpty) return null;
return _StatusText(text, _statusColor(state));
}
... ...
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/app/modules/interact/models/home_interact_route_arguments.dart';
import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_entry.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
... ... @@ -31,6 +33,24 @@ class TodayHrvNumberCard extends StatelessWidget {
: context.l10n.averageHrvForTheDay);
var hrvAvg = controller.v2HealthData.value?.hrvAvg ?? 0;
final hrvArguments = controller.isFriend
? HomeInteractRouteArguments.averageHrv(
context: context,
friendUserId: controller.friendUserId,
date: controller.selectedDate.value,
value: hrvAvg,
)
: null;
final restingHeartRate =
controller.v2HealthData.value?.lastRestingHrValue ?? 0;
final restingHeartRateArguments = controller.isFriend
? HomeInteractRouteArguments.restingHeartRate(
context: context,
friendUserId: controller.friendUserId,
date: controller.selectedDate.value,
value: restingHeartRate,
)
: null;
return Row(
children: [
... ... @@ -42,7 +62,8 @@ class TodayHrvNumberCard extends StatelessWidget {
: otherHrv,
value: hrvAvg > 0 ? hrvAvg.toString() : '-',
unit: 'ms',
interactionEnabled: controller.isFriend && hrvAvg > 0,
interactionEnabled: controller.isFriend && hrvArguments != null,
interactionArguments: hrvArguments,
),
),
... ... @@ -53,15 +74,11 @@ class TodayHrvNumberCard extends StatelessWidget {
context.l10n.restingHeartRate,
)
: context.l10n.restingHeartRate,
value:
controller.v2HealthData.value?.lastRestingHrValue
?.toString() ??
'-',
value: restingHeartRate > 0 ? '$restingHeartRate' : '-',
unit: 'bpm',
interactionEnabled:
controller.isFriend &&
(controller.v2HealthData.value?.lastRestingHrValue ?? 0) >
0,
controller.isFriend && restingHeartRateArguments != null,
interactionArguments: restingHeartRateArguments,
),
),
],
... ... @@ -76,12 +93,14 @@ class _NumberItem extends StatelessWidget {
final String value;
final String unit;
final bool? interactionEnabled;
final InteractRouteArguments? interactionArguments;
const _NumberItem({
required this.label,
required this.value,
required this.unit,
this.interactionEnabled,
this.interactionArguments,
});
@override
... ... @@ -135,6 +154,7 @@ class _NumberItem extends StatelessWidget {
const SizedBox(width: 8),
FriendInteractionButton(
enabled: interactionEnabled!,
arguments: interactionArguments,
padding: const EdgeInsets.only(
left: 8,
right: 20,
... ...
import 'package:doublefeel_flutter/app/utils/platform_compact.dart';
import 'package:doublefeel_flutter/app/modules/interact/models/home_interact_route_arguments.dart';
import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_entry.dart';
import 'package:doublefeel_flutter/app/widget/circular_gradient_progress/arc_progress_widget.dart';
import 'package:doublefeel_flutter/app/widget/circular_gradient_progress/combine.dart';
... ... @@ -57,6 +59,17 @@ class TodaySleepCard extends StatelessWidget {
double? sleepScoreRatio = sleepDuration == 0
? null
: ((healthData?.sleepScore ?? 0.0) / 100.0);
final interactionArguments = controller.isFriend
? HomeInteractRouteArguments.sleep(
context: context,
friendUserId: controller.friendUserId,
date: controller.selectedDate.value,
durationSeconds: sleepDuration,
qualityScore: healthData?.sleepScore,
qualityState: healthData?.sleepState,
averageHeartRate: healthData?.hrAvg,
)
: null;
return _TodaySummaryCard(
iconAsset: 'assets/images/common/ic_sleep_stroke.png',
... ... @@ -199,7 +212,9 @@ class TodaySleepCard extends StatelessWidget {
interpolatedColorRatio: 1,
),
),
interactionEnabled: controller.isFriend && sleepDuration > 0,
interactionEnabled:
controller.isFriend && interactionArguments != null,
interactionArguments: interactionArguments,
);
}),
);
... ... @@ -319,6 +334,16 @@ class TodayActivityCard extends StatelessWidget {
double? standRatio = (stand > 0 && (activityTarget?.stand ?? 0) > 0)
? (stand / (activityTarget?.stand ?? 0))
: null;
final interactionArguments = controller.isFriend
? HomeInteractRouteArguments.fitness(
context: context,
friendUserId: controller.friendUserId,
date: controller.selectedDate.value,
activeCalories: activity,
exerciseSeconds: exercise,
standHours: stand,
)
: null;
return _TodaySummaryCard(
iconAsset: 'assets/images/common/ic_exercise.png',
... ... @@ -424,8 +449,8 @@ class TodayActivityCard extends StatelessWidget {
),
),
interactionEnabled:
controller.isFriend &&
(activity > 0 || exercise > 0 || stand > 0),
controller.isFriend && interactionArguments != null,
interactionArguments: interactionArguments,
);
}),
);
... ... @@ -441,6 +466,7 @@ class _TodaySummaryCard extends StatelessWidget {
required this.metrics,
required this.rightWidget,
this.interactionEnabled,
this.interactionArguments,
});
final String iconAsset;
... ... @@ -451,6 +477,7 @@ class _TodaySummaryCard extends StatelessWidget {
final Widget rightWidget;
final bool? interactionEnabled;
final InteractRouteArguments? interactionArguments;
@override
Widget build(BuildContext context) {
... ... @@ -486,7 +513,10 @@ class _TodaySummaryCard extends StatelessWidget {
),
const Spacer(),
if (interactionEnabled != null) ...[
FriendInteractionButton(enabled: interactionEnabled!),
FriendInteractionButton(
enabled: interactionEnabled!,
arguments: interactionArguments,
),
],
],
),
... ...
import 'dart:async';
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
import 'package:doublefeel_flutter/app/modules/friends/data/friends_repository.dart';
import 'package:doublefeel_flutter/app/modules/friends/models/friend_health_data.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/network/api/interaction_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
enum InteractAction { stick, miss, punch }
extension InteractActionUi on InteractAction {
int get apiValue => switch (this) {
InteractAction.stick => 0,
InteractAction.miss => 1,
InteractAction.punch => 2,
};
InteractAction.stick => 0,
InteractAction.miss => 1,
InteractAction.punch => 2,
};
String get title => switch (this) {
InteractAction.stick => '戳一戳',
InteractAction.miss => '想Ta',
InteractAction.punch => '打一拳',
};
InteractAction.stick => '戳一戳',
InteractAction.miss => '想Ta',
InteractAction.punch => '打一拳',
};
bool get needsVip => this != InteractAction.stick;
String get iconAsset => switch (this) {
InteractAction.stick => 'assets/images/interact/icon_stick.png',
InteractAction.miss => 'assets/images/interact/icon_miss.png',
InteractAction.punch => 'assets/images/interact/icon_punch.png',
};
InteractAction.stick => 'assets/images/interact/icon_stick.png',
InteractAction.miss => 'assets/images/interact/icon_miss.png',
InteractAction.punch => 'assets/images/interact/icon_punch.png',
};
String get titleAsset => switch (this) {
InteractAction.stick => 'assets/images/interact/title_stick.png',
InteractAction.miss => 'assets/images/interact/title_miss.png',
InteractAction.punch => 'assets/images/interact/title_punch.png',
};
InteractAction.stick => 'assets/images/interact/title_stick.png',
InteractAction.miss => 'assets/images/interact/title_miss.png',
InteractAction.punch => 'assets/images/interact/title_punch.png',
};
String get lockedTitleAsset => switch (this) {
InteractAction.stick => 'assets/images/interact/title_stick_locked.png',
InteractAction.miss => 'assets/images/interact/title_miss_locked.png',
InteractAction.punch => 'assets/images/interact/title_punch_locked.png',
};
InteractAction.stick => 'assets/images/interact/title_stick_locked.png',
InteractAction.miss => 'assets/images/interact/title_miss_locked.png',
InteractAction.punch => 'assets/images/interact/title_punch_locked.png',
};
}
class InteractController extends GetxController {
InteractController(this._interactionApi, this._userPreferencesStorage);
InteractController(
this._interactionApi,
this._userPreferencesStorage, {
FriendsRepository? friendsRepository,
}) : _friendsRepository =
friendsRepository ?? FriendsRepositoryImpl(Get.find<FriendApi>());
final InteractionApi _interactionApi;
final UserPreferencesStorage _userPreferencesStorage;
final FriendsRepository _friendsRepository;
final records = <InteractionRecord>[].obs;
final isLoading = false.obs;
final sendingAction = Rxn<InteractAction>();
final activeAction = Rxn<InteractAction>();
final selectedFriend = Rxn<FriendHealthData>();
final targetFriendUserId = RxnInt();
final friends = <FriendHealthData>[].obs;
InteractRouteArguments? entryArguments;
UserInfoResponse? get me =>
_userPreferencesStorage.preferences.value.meUserInfo;
UserInfoResponse? get partner =>
_userPreferencesStorage.preferences.value.partnerUserInfo;
bool get isPaired => partner?.id != null;
UserInfoResponse? get partner => selectedFriend.value == null
? _userPreferencesStorage.preferences.value.partnerUserInfo
: null;
bool get isPaired => targetFriendUserId.value != null || partner?.id != null;
bool get isVip =>
_userPreferencesStorage.preferences.value.vipInfo?.isVip ?? false;
List<FriendHealthData> get availableFriends =>
friends.toList(growable: false);
String get friendName =>
selectedFriend.value?.name ?? partner?.nickname ?? '好友';
String? get friendRemark => selectedFriend.value?.remark;
String? get friendAvatarUrl =>
selectedFriend.value?.avatarUrl ?? partner?.avatar;
String get friendLabel {
final remark = friendRemark?.trim();
return remark == null || remark.isEmpty
? friendName
: '$remark$friendName)';
}
String get selfName => me?.nickname ?? '我';
String? get selfAvatarUrl => me?.avatar;
/// V2 interaction types for the currently implemented home-page entries.
int get interactionType {
final values = entryArguments?.values;
if (values == null || values.isEmpty) return 100;
return switch (values.first.type) {
InteractValueType.averageHrv => 101,
InteractValueType.restingHeartRate => 102,
InteractValueType.sleepDuration ||
InteractValueType.sleepQualityScore ||
InteractValueType.sleepQualityState ||
InteractValueType.averageSleepHeartRate => 103,
InteractValueType.activeCalories ||
InteractValueType.exerciseDuration ||
InteractValueType.standDuration => 104,
};
}
@override
void onInit() {
super.onInit();
unawaited(loadRecords());
final arguments = Get.arguments;
if (arguments is InteractRouteArguments) {
entryArguments = arguments;
targetFriendUserId.value = arguments.friendUserId;
}
refreshPage();
}
/// Reload target profile and records together after switching friends.
Future<void> refreshPage() async {
await _loadSelectedFriend();
await loadRecords();
}
Future<void> loadFriendCandidates() async {
await _loadFriends();
await _loadSelectedFriend();
}
Future<void> selectFriend(FriendHealthData friend) async {
final userId = friend.userId;
if (userId == null || userId == targetFriendUserId.value) return;
targetFriendUserId.value = userId;
selectedFriend.value = friend;
await refreshPage();
}
Future<void> _loadSelectedFriend() async {
await _loadFriends();
var targetId = targetFriendUserId.value;
if (targetId == null) {
// Read directly from the friend source rather than the app-home state.
final watchedFriend = availableFriends
.where(
(friend) =>
friend.isOnWatchFace && friend.friendItem.friendUserId != null,
)
.firstOrNull;
targetId =
watchedFriend?.userId ?? watchedFriend?.friendItem.friendUserId;
targetId ??= partner?.id;
targetFriendUserId.value = targetId;
}
if (targetId == null) {
selectedFriend.value = null;
return;
}
final friend = availableFriends
.where((friend) => friend.userId == targetId)
.firstOrNull;
if (friend != null) {
selectedFriend.value = friend;
return;
}
selectedFriend.value = null;
}
Future<void> _loadFriends() async {
try {
final data = await _friendsRepository.getFriendList();
friends.assignAll(data.friends);
} catch (_) {
// Keep the existing snapshot during a transient list-request failure.
}
}
Future<void> loadRecords() async {
isLoading.value = true;
final result = await _interactionApi.getInteractionRecordList();
final friendUserId = targetFriendUserId.value;
if (friendUserId == null) {
records.clear();
isLoading.value = false;
return;
}
final result = await _interactionApi.getInteractionRecordList(friendUserId);
isLoading.value = false;
switch (result) {
case AppSuccess(:final data):
records.assignAll(data.records ?? const <InteractionRecord>[]);
final responseRecords = data.records ?? const <InteractionRecord>[];
records.assignAll(
kDebugMode && responseRecords.isEmpty
? _mockRecords()
: responseRecords,
);
case AppFailure():
// Keep the last successful list visible when a refresh fails.
if (kDebugMode) {
records.assignAll(_mockRecords());
}
// Keep the last successful list visible in release when a refresh fails.
}
}
... ... @@ -96,12 +223,18 @@ class InteractController extends GetxController {
return;
}
if (sendingAction.value != null) return;
final friendUserId = targetFriendUserId.value;
if (friendUserId == null) {
_showToast('未找到互动好友');
return;
}
sendingAction.value = action;
activeAction.value = action;
final result = await _interactionApi.sendInteraction(
InteractionData(
interactionType: 0,
friendUserId: friendUserId,
interactionType: interactionType,
actionType: action.apiValue,
action: actionVariables,
),
... ... @@ -163,16 +296,53 @@ class InteractController extends GetxController {
}
String? actionMaskAsset() => switch (activeAction.value) {
InteractAction.stick =>
'assets/images/interact/animations/interactionStickMaskAnimation.png',
InteractAction.miss =>
'assets/images/interact/animations/interactionMissMaskAnimation.png',
InteractAction.punch =>
'assets/images/interact/animations/interactionPunchMaskAnimation.png',
null => null,
};
InteractAction.stick =>
'assets/images/interact/animations/interactionStickMaskAnimation.png',
InteractAction.miss =>
'assets/images/interact/animations/interactionMissMaskAnimation.png',
InteractAction.punch =>
'assets/images/interact/animations/interactionPunchMaskAnimation.png',
null => null,
};
void _showToast(String message) {
Get.snackbar('互动', message, snackPosition: SnackPosition.BOTTOM);
}
List<InteractionRecord> _mockRecords() {
final now = DateTime.now();
final meId = me?.id ?? 1;
final friendId = targetFriendUserId.value ?? 2;
const texts = <String>[
'我戳了戳Ta一下~',
'Ta打了你一拳~',
'我想Ta了,今天也要好好照顾自己呀~',
'Ta戳了戳你,提醒你起来活动一下。',
'我送给Ta一份今天的好心情~',
'Ta想你了,记得给Ta一个回应。',
'我打了一拳,今天也要元气满满!',
'Ta戳了戳你,别忘了喝水。',
'我想念Ta,晚点一起聊聊天吧。',
'Ta送来一份鼓励:今天辛苦了!',
'我戳了戳Ta,继续加油~',
'Ta打了一拳,是轻轻的一拳。',
'我想Ta了。',
'Ta戳了戳你。',
'我送给Ta一个拥抱。',
];
return List<InteractionRecord>.generate(
texts.length,
(index) => InteractionRecord(
id: index + 1,
userId: index.isEven ? meId : friendId,
actionType: index % 3,
text: texts[index],
createTime:
now
.subtract(Duration(minutes: index * 17 + 3))
.millisecondsSinceEpoch ~/
Duration.millisecondsPerSecond,
),
);
}
}
... ...
import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
/// Builds the typed interaction context emitted by cards on the app home page.
class HomeInteractRouteArguments {
const HomeInteractRouteArguments._();
static InteractRouteArguments? averageHrv({
required BuildContext context,
required int? friendUserId,
required DateTime date,
required int value,
}) => value <= 0
? null
: _create(
context: context,
friendUserId: friendUserId,
date: date,
metricTitle: context.l10n.averageHrvForTheDay,
values: [
InteractDisplayValue(
type: InteractValueType.averageHrv,
value: value,
unit: InteractValueUnit.milliseconds,
),
],
);
static InteractRouteArguments? restingHeartRate({
required BuildContext context,
required int? friendUserId,
required DateTime date,
required int value,
}) => value <= 0
? null
: _create(
context: context,
friendUserId: friendUserId,
date: date,
metricTitle: context.l10n.restingHeartRate,
values: [
InteractDisplayValue(
type: InteractValueType.restingHeartRate,
value: value,
unit: InteractValueUnit.beatsPerMinute,
),
],
);
static InteractRouteArguments? sleep({
required BuildContext context,
required int? friendUserId,
required DateTime date,
required int durationSeconds,
required double? qualityScore,
required int? qualityState,
required int? averageHeartRate,
}) => durationSeconds <= 0
? null
: _create(
context: context,
friendUserId: friendUserId,
date: date,
metricTitle: context.l10n.sleep,
values: [
InteractDisplayValue(
type: InteractValueType.sleepDuration,
value: durationSeconds,
unit: InteractValueUnit.seconds,
),
if (qualityScore != null)
InteractDisplayValue(
type: InteractValueType.sleepQualityScore,
value: qualityScore,
unit: InteractValueUnit.score,
),
if (qualityState != null)
InteractDisplayValue(
type: InteractValueType.sleepQualityState,
value: qualityState,
unit: InteractValueUnit.state,
),
if (averageHeartRate != null && averageHeartRate > 0)
InteractDisplayValue(
type: InteractValueType.averageSleepHeartRate,
value: averageHeartRate,
unit: InteractValueUnit.beatsPerMinute,
),
],
);
static InteractRouteArguments? fitness({
required BuildContext context,
required int? friendUserId,
required DateTime date,
required int activeCalories,
required int exerciseSeconds,
required int standHours,
}) => _create(
context: context,
friendUserId: friendUserId,
date: date,
metricTitle: context.l10n.fitness,
values: [
if (activeCalories > 0)
InteractDisplayValue(
type: InteractValueType.activeCalories,
value: activeCalories,
unit: InteractValueUnit.kilocalories,
),
if (exerciseSeconds > 0)
InteractDisplayValue(
type: InteractValueType.exerciseDuration,
value: exerciseSeconds,
unit: InteractValueUnit.seconds,
),
if (standHours > 0)
InteractDisplayValue(
type: InteractValueType.standDuration,
value: standHours,
unit: InteractValueUnit.hours,
),
],
);
static InteractRouteArguments? _create({
required BuildContext context,
required int? friendUserId,
required DateTime date,
required String metricTitle,
required List<InteractDisplayValue> values,
}) {
if (friendUserId == null || friendUserId <= 0 || values.isEmpty) {
return null;
}
return InteractRouteArguments(
friendUserId: friendUserId,
page: InteractPageSource.home,
title: '${_dateLabel(context, date)} · $metricTitle',
date: DateUtils.dateOnly(date),
values: values,
);
}
static String _dateLabel(BuildContext context, DateTime date) {
final today = DateUtils.dateOnly(DateTime.now());
final target = DateUtils.dateOnly(date);
if (target == today) return context.l10n.today;
if (target == today.subtract(const Duration(days: 1))) {
return context.l10n.yesterday;
}
return MaterialLocalizations.of(context).formatMediumDate(target);
}
}
... ...
/// Typed context passed from a health-data entry to the interaction page.
///
/// Keep values atomic: a sleep or fitness card is represented by several
/// [InteractDisplayValue]s instead of flattening them into one localized
/// string. The interaction page and the request payload can therefore format
/// the same data independently.
class InteractRouteArguments {
const InteractRouteArguments({
required this.friendUserId,
required this.page,
required this.title,
required this.date,
required this.values,
});
final int friendUserId;
final InteractPageSource page;
final String title;
final DateTime date;
final List<InteractDisplayValue> values;
}
enum InteractPageSource { home }
enum InteractValueType {
averageHrv,
restingHeartRate,
sleepDuration,
sleepQualityScore,
sleepQualityState,
averageSleepHeartRate,
activeCalories,
exerciseDuration,
standDuration,
}
enum InteractValueUnit {
milliseconds,
beatsPerMinute,
seconds,
kilocalories,
hours,
score,
state,
}
class InteractDisplayValue {
const InteractDisplayValue({
required this.type,
required this.value,
required this.unit,
});
final InteractValueType type;
final num value;
final InteractValueUnit unit;
}
... ...
import 'package:doublefeel_flutter/app/modules/friends/views/select_friend_view.dart';
import 'package:doublefeel_flutter/app/modules/interact/controllers/interact_controller.dart';
import 'package:doublefeel_flutter/app/modules/friends/models/friend_health_data.dart';
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
... ... @@ -8,169 +9,167 @@ class InteractView extends GetView<InteractController> {
const InteractView({super.key});
static const _purple = Color(0xff896cdc);
static const _muted = Color(0xff908b91);
static const _brandText = Color(0xff845eee);
static const _muted = Color(0xff78787d);
static const _placeholder = Color(0xffded3ff);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
fit: StackFit.expand,
children: [
Image.asset('assets/images/interact/page_bg.png', fit: BoxFit.cover),
SafeArea(
child: Obx(
() => Column(
children: [
_appBar(),
Expanded(
child: RefreshIndicator(
onRefresh: controller.loadRecords,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 28),
children: [
_marquee(),
_characterArea(),
const SizedBox(height: 34),
_actionButtons(context),
const SizedBox(height: 28),
_records(),
],
),
backgroundColor: const Color(0xfff5f2ff),
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xffc5b0ff), Color(0xfff5f2ff)],
begin: Alignment.topCenter,
end: Alignment(0, -.25),
),
),
child: SafeArea(
child: Obx(() {
final hasInteractions = controller.records.isNotEmpty;
return Column(
children: [
_appBar(context),
Expanded(
child: RefreshIndicator(
onRefresh: controller.refreshPage,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 28),
children: [
_interactionHero(hasInteractions),
_actionButtons(context),
const SizedBox(height: 16),
_records(),
],
),
),
],
),
),
),
Obx(() {
final asset = controller.actionMaskAsset();
if (asset == null) return const SizedBox.shrink();
return IgnorePointer(
child: Align(
alignment: const Alignment(0, -.5),
child:
Image.asset(asset, width: Get.width, fit: BoxFit.fitWidth),
),
),
],
);
}),
],
),
),
);
}
Widget _appBar() {
Widget _appBar(BuildContext context) {
return SizedBox(
height: 48,
height: 44,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black),
icon: const Icon(
Icons.arrow_back_ios_new,
color: Color(0xff0f0f11),
size: 20,
),
onPressed: Get.back,
),
const Spacer(),
Expanded(
child: Text(
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xff0f0f11),
fontSize: 16,
fontWeight: FontWeight.w600,
),
'互动',
),
),
IconButton(
tooltip: '刷新互动记录',
icon: const Icon(Icons.refresh, color: Colors.black87),
onPressed: controller.loadRecords,
tooltip: '切换好友',
icon: const Icon(
Icons.swap_horiz_rounded,
color: Color(0xff0f0f11),
size: 23,
),
onPressed: _changeFriend,
),
],
),
);
}
Widget _marquee() {
final records = controller.records.take(3).toList(growable: false);
if (records.isEmpty) return const SizedBox(height: 72);
Widget _interactionHero(bool hasInteractions) {
// The 71px difference matches the Figma's extra interaction bubbles.
final characterTop = hasInteractions ? 127.0 : 56.0;
return SizedBox(
height: 72,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
scrollDirection: Axis.horizontal,
itemCount: records.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (_, index) {
final record = records[index];
final isMe = record.userId == controller.me?.id;
return Container(
constraints: const BoxConstraints(maxWidth: 210),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
image: const DecorationImage(
image: AssetImage('assets/images/interact/page_bubble_bg.png'),
fit: BoxFit.fill,
height: hasInteractions ? 299 : 228,
child: Stack(
clipBehavior: Clip.none,
children: [
if (hasInteractions)
Positioned.fill(
child: _FloatingInteractionBubbles(
records: controller.records.take(3).toList(growable: false),
recordText: controller.recordText,
),
),
Positioned(
top: characterTop,
left: 28,
right: 28,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_avatar(isMe ? controller.me : controller.partner, size: 28),
const SizedBox(width: 7),
Flexible(
child: Text(
controller.recordText(record),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 13),
Expanded(
child: _personPreview(
controller.friendLabel,
'好友',
controller.friendAvatarUrl,
),
),
const SizedBox(width: 28),
Expanded(
child: _personPreview(
controller.selfName,
'我',
controller.selfAvatarUrl,
),
),
],
),
);
},
),
);
}
Widget _characterArea() {
final actionAsset = controller.partnerActionAsset();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
Expanded(child: _person(me: true)),
const SizedBox(width: 12),
Expanded(
child: controller.isPaired
? _person(me: false, actionAsset: actionAsset)
: _unpairedPerson(),
),
],
),
);
}
Widget _person({required bool me, String? actionAsset}) {
final user = me ? controller.me : controller.partner;
Widget _personPreview(String name, String role, String? avatarUrl) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 132,
child: actionAsset == null
? _avatar(user,
size: 108,
borderColor: me ? _purple : const Color(0xfffff45b))
: Image.asset(actionAsset, fit: BoxFit.contain),
_profileImage(avatarUrl, size: 96, radius: 48),
const SizedBox(height: 8),
Container(
width: 78,
height: 2,
color: Colors.white.withValues(alpha: .8),
),
const SizedBox(height: 6),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (me)
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration:
const BoxDecoration(color: _purple, shape: BoxShape.circle),
child: const Text('我',
style: TextStyle(color: Colors.white, fontSize: 11)),
),
if (me) const SizedBox(width: 5),
_profileImage(avatarUrl, size: 36, radius: 18),
const SizedBox(width: 4),
Flexible(
child: Text(
user?.nickname ?? '-',
child: Text.rich(
TextSpan(
text: name,
style: const TextStyle(
color: Color(0xff0f0f11),
fontSize: 14,
),
children: [
TextSpan(
text: '($role)',
style: const TextStyle(color: _muted),
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
),
],
... ... @@ -179,39 +178,35 @@ class InteractView extends GetView<InteractController> {
);
}
Widget _unpairedPerson() {
return Column(
children: [
Image.asset('assets/images/interact/unpaired_avatar.png', width: 58),
const SizedBox(height: 8),
const Text('暂未绑定Feel搭子',
style: TextStyle(color: Color(0xffff2c20), fontSize: 12)),
const SizedBox(height: 10),
TextButton.icon(
onPressed: () => Get.snackbar('互动', '请先前往搭子页面完成绑定'),
icon: const Text('去绑定'),
label: const Icon(Icons.chevron_right, size: 18),
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: _purple,
shape: const StadiumBorder(),
),
),
],
);
}
Widget _actionButtons(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_actionButton(context, InteractAction.miss, width: 88, height: 52),
const SizedBox(width: 12),
_actionButton(context, InteractAction.stick, width: 112, height: 72),
const SizedBox(width: 12),
_actionButton(context, InteractAction.punch, width: 88, height: 52),
],
return SizedBox(
height: 112,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_actionButton(
context,
InteractAction.miss,
width: 90,
elevated: false,
),
const SizedBox(width: 12),
_actionButton(
context,
InteractAction.stick,
width: 116,
elevated: true,
),
const SizedBox(width: 12),
_actionButton(
context,
InteractAction.punch,
width: 90,
elevated: false,
),
],
),
);
}
... ... @@ -219,7 +214,7 @@ class InteractView extends GetView<InteractController> {
BuildContext context,
InteractAction action, {
required double width,
required double height,
required bool elevated,
}) {
final enabled =
controller.isPaired && (!action.needsVip || controller.isVip);
... ... @@ -228,41 +223,59 @@ class InteractView extends GetView<InteractController> {
onLongPress: () => _showActionSheet(context),
child: SizedBox(
width: width,
height: height + 18,
child: ElevatedButton(
onPressed: sending ? null : () => controller.sendAction(action),
style: ElevatedButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor: _purple.withValues(alpha: enabled ? 1 : .4),
disabledBackgroundColor: _purple.withValues(alpha: .4),
elevation: 5,
shadowColor: _purple.withValues(alpha: .5),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
),
child: sending
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
color: Colors.white, strokeWidth: 2))
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
enabled
? action.iconAsset
: 'assets/images/interact/icon_locked.png',
width: action == InteractAction.stick ? 48 : 40,
height: action == InteractAction.stick ? 48 : 40,
),
Image.asset(
enabled ? action.titleAsset : action.lockedTitleAsset,
height: 20,
fit: BoxFit.contain,
height: 112,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.topCenter,
children: [
Positioned(
top: elevated ? 34 : 49,
child: Opacity(
opacity: enabled ? 1 : .4,
child: Material(
color: _purple,
borderRadius: BorderRadius.circular(elevated ? 24 : 16),
child: InkWell(
onTap: sending ? null : () => controller.sendAction(action),
borderRadius: BorderRadius.circular(elevated ? 24 : 16),
child: SizedBox(
width: width,
height: elevated ? 78 : 56,
child: Center(
child: sending
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
action.title,
style: TextStyle(
color: Colors.white,
fontSize: elevated ? 20 : 14,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
),
Positioned(
top: elevated ? 0 : 19,
child: _colorPlaceholder(
elevated ? 68 : 52,
color: enabled
? const Color(0xffc5b0ff)
: const Color(0xffd5cfe3),
radius: elevated ? 22 : 18,
),
),
],
),
),
);
... ... @@ -271,25 +284,25 @@ class InteractView extends GetView<InteractController> {
Widget _records() {
final records = controller.records.take(10).toList(growable: false);
return Container(
margin: const EdgeInsets.only(top: 2),
padding: const EdgeInsets.fromLTRB(24, 20, 24, 42),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xffeae1ff), Color(0x00fcf4ff)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
constraints: const BoxConstraints(minHeight: 208),
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.fromLTRB(16, 17, 16, 18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .76),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Row(
children: [
const Text('互动记录',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: Color(0xff2c2020))),
const Text(
'互动记录',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xff0f0f11),
),
),
const Spacer(),
Text('仅保留10条', style: TextStyle(fontSize: 12, color: _muted)),
],
... ... @@ -297,12 +310,16 @@ class InteractView extends GetView<InteractController> {
const SizedBox(height: 15),
if (controller.isLoading.value && records.isEmpty)
const Padding(
padding: EdgeInsets.all(40), child: CircularProgressIndicator())
padding: EdgeInsets.all(40),
child: CircularProgressIndicator(),
)
else if (records.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 76),
child: Text('暂时还没有记录哦~',
style: TextStyle(color: _muted, fontSize: 14)),
child: Text(
'暂无互动记录',
style: TextStyle(color: _muted, fontSize: 14),
),
)
else
...records.map(_recordItem),
... ... @@ -312,47 +329,99 @@ class InteractView extends GetView<InteractController> {
}
Widget _recordItem(InteractionRecord record) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 7),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .48),
borderRadius: BorderRadius.circular(7)),
return Padding(
padding: const EdgeInsets.only(bottom: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.circle_outlined, size: 10, color: _purple),
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(top: 5),
decoration: const BoxDecoration(
color: _brandText,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(controller.recordText(record),
style: const TextStyle(fontSize: 14))),
Text(controller.recordTime(record),
style: const TextStyle(fontSize: 12, color: _muted)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
controller.recordText(record),
style: const TextStyle(
fontSize: 14,
color: Color(0xff0f0f11),
),
),
const SizedBox(height: 3),
Text(
controller.recordTime(record),
style: const TextStyle(
fontSize: 12,
color: Color(0xffb0b0b6),
),
),
],
),
),
],
),
);
}
Widget _avatar(UserInfoResponse? user,
{required double size, Color? borderColor}) {
final avatar = user?.avatar;
Widget _colorPlaceholder(
double size, {
required Color color,
required double radius,
}) {
return Container(
width: size,
height: size,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: borderColor ?? _purple, width: 2),
color: Colors.white),
child: avatar == null || avatar.isEmpty
? Icon(Icons.person, size: size * .55, color: _muted)
: Image.network(avatar,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(Icons.person, size: size * .55, color: _muted)),
color: color,
borderRadius: BorderRadius.circular(radius),
),
);
}
Widget _profileImage(
String? avatarUrl, {
required double size,
required double radius,
}) {
if (avatarUrl == null || avatarUrl.trim().isEmpty) {
return _colorPlaceholder(size, color: _placeholder, radius: radius);
}
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Image.network(
avatarUrl,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_colorPlaceholder(size, color: _placeholder, radius: radius),
),
);
}
Future<void> _changeFriend() async {
final selectedFriend = await Get.bottomSheet<FriendHealthData>(
SizedBox(height: Get.height * 0.9, child: const SelectFriendView()),
ignoreSafeArea: false,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: const Color(0xB3000000),
);
if (selectedFriend == null) return;
if (selectedFriend.userId == controller.selectedFriend.value?.userId) {
return;
}
await controller.selectFriend(selectedFriend);
}
void _showActionSheet(BuildContext context) {
Get.bottomSheet(
InteractActionBottomSheet(controller: controller),
... ... @@ -362,6 +431,111 @@ class InteractView extends GetView<InteractController> {
}
}
/// A deliberately sparse, looping barrage. Each message begins offscreen on
/// the right and re-enters after it has fully left the left edge.
class _FloatingInteractionBubbles extends StatefulWidget {
const _FloatingInteractionBubbles({
required this.records,
required this.recordText,
});
final List<InteractionRecord> records;
final String Function(InteractionRecord record) recordText;
@override
State<_FloatingInteractionBubbles> createState() =>
_FloatingInteractionBubblesState();
}
class _FloatingInteractionBubblesState
extends State<_FloatingInteractionBubbles>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController = AnimationController(
vsync: this,
duration: const Duration(seconds: 11),
)..repeat();
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// The staggered vertical tracks and phases prevent this from looking like
// a single marquee while still keeping it a calm, readable barrage.
const tracks = <double>[12, 53, 74];
const phases = <double>[.06, .47, .79];
return LayoutBuilder(
builder: (context, constraints) => AnimatedBuilder(
animation: _animationController,
builder: (context, _) => Stack(
clipBehavior: Clip.hardEdge,
children: List.generate(widget.records.length, (index) {
final progress = (_animationController.value + phases[index]) % 1;
// 170 covers the maximum bubble width and its exit gap.
final left =
constraints.maxWidth - progress * (constraints.maxWidth + 170);
return Positioned(
top: tracks[index],
left: left,
child: _FloatingInteractionBubble(
record: widget.records[index],
recordText: widget.recordText,
),
);
}),
),
),
);
}
}
class _FloatingInteractionBubble extends StatelessWidget {
const _FloatingInteractionBubble({
required this.record,
required this.recordText,
});
final InteractionRecord record;
final String Function(InteractionRecord record) recordText;
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 142),
padding: const EdgeInsets.fromLTRB(6, 5, 10, 5),
decoration: BoxDecoration(
color: const Color(0xffa084ef),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: const Color(0xffc5b0ff),
borderRadius: BorderRadius.circular(9),
),
),
const SizedBox(width: 5),
Flexible(
child: Text(
recordText(record),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
],
),
);
}
}
class InteractActionBottomSheet extends StatelessWidget {
const InteractActionBottomSheet({required this.controller, super.key});
... ... @@ -375,41 +549,49 @@ class InteractActionBottomSheet extends StatelessWidget {
height: 331,
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/interact/bottom_sheet_bg.png'),
fit: BoxFit.fill),
image: AssetImage('assets/images/interact/bottom_sheet_bg.png'),
fit: BoxFit.fill,
),
),
child: Stack(
children: [
Align(
alignment: const Alignment(.2, -.98),
child: Image.asset(
'assets/images/interact/bottom_sheet_label.png',
width: 126),
'assets/images/interact/bottom_sheet_label.png',
width: 126,
),
),
Column(
children: [
const SizedBox(height: 18),
_sheetAvatar(),
const SizedBox(height: 13),
const Text('给搭子送去一份互动',
style: TextStyle(color: Color(0xff908b91), fontSize: 14)),
const Text(
'给搭子送去一份互动',
style: TextStyle(color: Color(0xff908b91), fontSize: 14),
),
const SizedBox(height: 3),
const Text('想念要说出来',
style: TextStyle(
color: Color(0xff896cdc),
fontSize: 24,
fontWeight: FontWeight.w700)),
const Text(
'想念要说出来',
style: TextStyle(
color: Color(0xff896cdc),
fontSize: 24,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 24),
Obx(
() => Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: InteractAction.values
.map((action) => Padding(
padding:
const EdgeInsets.symmetric(horizontal: 6),
child: _sheetButton(action),
))
.map(
(action) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: _sheetButton(action),
),
)
.toList(growable: false),
),
),
... ... @@ -428,8 +610,9 @@ class InteractActionBottomSheet extends StatelessWidget {
height: 56,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0xff896cdc), width: 2)),
shape: BoxShape.circle,
border: Border.all(color: const Color(0xff896cdc), width: 2),
),
child: avatar == null || avatar.isEmpty
? const Icon(Icons.person, color: Color(0xff908b91))
: Image.network(avatar, fit: BoxFit.cover),
... ... @@ -455,25 +638,31 @@ class InteractActionBottomSheet extends StatelessWidget {
},
style: ElevatedButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor:
const Color(0xff896cdc).withValues(alpha: enabled ? 1 : .4),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
backgroundColor: const Color(
0xff896cdc,
).withValues(alpha: enabled ? 1 : .4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
),
child: sending
? const CircularProgressIndicator(
color: Colors.white, strokeWidth: 2)
color: Colors.white,
strokeWidth: 2,
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
enabled
? action.iconAsset
: 'assets/images/interact/icon_locked.png',
height: 38),
enabled
? action.iconAsset
: 'assets/images/interact/icon_locked.png',
height: 38,
),
Image.asset(
enabled ? action.titleAsset : action.lockedTitleAsset,
height: 18),
enabled ? action.titleAsset : action.lockedTitleAsset,
height: 18,
),
],
),
),
... ...
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
... ... @@ -6,7 +7,8 @@ class FriendInteractionButton extends StatelessWidget {
const FriendInteractionButton({
super.key,
required this.enabled,
this.size = 24,
this.arguments,
this.size = 24,
this.padding = const EdgeInsets.only(
left: 20,
right: 10,
... ... @@ -16,6 +18,7 @@ class FriendInteractionButton extends StatelessWidget {
});
final bool enabled;
final InteractRouteArguments? arguments;
final double size;
final EdgeInsets padding;
... ... @@ -36,18 +39,23 @@ class FriendInteractionButton extends StatelessWidget {
),
);
void _openInteraction() => Get.toNamed(Routes.INTERACT);
void _openInteraction() => Get.toNamed(Routes.INTERACT, arguments: arguments);
}
class FriendInteractionFloatingEntry extends StatelessWidget {
const FriendInteractionFloatingEntry({super.key, this.size = 72});
const FriendInteractionFloatingEntry({
super.key,
this.size = 72,
this.arguments,
});
final double size;
final InteractRouteArguments? arguments;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Get.toNamed(Routes.INTERACT),
onTap: () => Get.toNamed(Routes.INTERACT, arguments: arguments),
child: Image.asset(
'assets/images/interact/interaction_float.png',
width: size,
... ...
... ... @@ -9,10 +9,15 @@ class InteractionApi {
final DioClient _dioClient;
Future<AppResult<InteractionRecordResponse>> getInteractionRecordList() {
Future<AppResult<InteractionRecordResponse>> getInteractionRecordList(
int friendUserId,
) {
return safeCall(
call: () async {
final response = await _dioClient.dio.get(ApiPaths.interactionRecords);
final response = await _dioClient.dio.get(
ApiPaths.interactionRecords,
queryParameters: {'friend_user_id': friendUserId},
);
return InteractionRecordResponse.fromJson(
response.data as Map<String, dynamic>,
);
... ...
... ... @@ -67,8 +67,9 @@ abstract final class ApiPaths {
'/client/doublefeel/payment/subscriptions/unsubscribe/';
// Interaction
static const interactionRecords = '/client/doublefeel/interaction/records/';
static const interactionAction = '/client/doublefeel/interaction/action/';
static const interactionRecords =
'/client/doublefeel/interaction/v2/records/';
static const interactionAction = '/client/doublefeel/interaction/v2/action/';
// Config
static const dynamicConfigList = '/client/doublefeel/dynamic_config/list/';
... ...
class InteractionData {
const InteractionData({
this.interactionType,
this.actionType,
required this.friendUserId,
required this.interactionType,
required this.actionType,
this.action,
});
final int? interactionType;
final int? actionType;
/// Target of every V2 action and records request.
final int friendUserId;
final int interactionType;
final int actionType;
final InteractionDataAction? action;
factory InteractionData.fromJson(Map<String, dynamic> json) {
return InteractionData(
interactionType: json['interaction_type'] as int?,
actionType: json['action_type'] as int?,
friendUserId: json['friend_user_id'] as int,
interactionType: json['interaction_type'] as int,
actionType: json['action_type'] as int,
action: json['action_variables'] == null
? null
: InteractionDataAction.fromJson(
json['action_variables'] as Map<String, dynamic>),
json['action_variables'] as Map<String, dynamic>,
),
);
}
Map<String, dynamic> toJson() {
final val = <String, dynamic>{};
if (interactionType != null) val['interaction_type'] = interactionType;
if (actionType != null) val['action_type'] = actionType;
final val = <String, dynamic>{
'friend_user_id': friendUserId,
'interaction_type': interactionType,
'action_type': actionType,
};
if (action != null) val['action_variables'] = action!.toJson();
return val;
}
... ... @@ -83,7 +90,8 @@ class InteractionRecord {
action: json['action_variables'] == null
? null
: InteractionDataAction.fromJson(
json['action_variables'] as Map<String, dynamic>),
json['action_variables'] as Map<String, dynamic>,
),
text: json['text'] as String?,
);
}
... ...