Commit b358cfbe92f9296edbd731cb7bc221ac0ca93008

Authored by 权海
1 parent 339fa401

增加互动主页

feat(ui):互动
1 import 'dart:async'; 1 import 'dart:async';
2 2
3 -import 'package:doublefeel_flutter/app/modules/friends/controllers/friends_controller.dart'; 3 +import 'package:doublefeel_flutter/app/modules/friends/data/friends_repository.dart';
4 import 'package:doublefeel_flutter/app/modules/friends/models/friend_health_data.dart'; 4 import 'package:doublefeel_flutter/app/modules/friends/models/friend_health_data.dart';
5 import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; 5 import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
6 import 'package:doublefeel_flutter/core/network/api/health_api.dart'; 6 import 'package:doublefeel_flutter/core/network/api/health_api.dart';
7 import 'package:doublefeel_flutter/core/network/api/pay_api.dart'; 7 import 'package:doublefeel_flutter/core/network/api/pay_api.dart';
  8 +import 'package:doublefeel_flutter/core/network/api/user_api.dart';
8 import 'package:doublefeel_flutter/core/network/api/vip_api.dart'; 9 import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
  10 +import 'package:doublefeel_flutter/core/result/app_result.dart';
9 import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_data_core_service.dart'; 11 import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_data_core_service.dart';
10 import 'package:doublefeel_flutter/core/services/user_state_service.dart'; 12 import 'package:doublefeel_flutter/core/services/user_state_service.dart';
11 import 'package:doublefeel_flutter/data/datasource/health/health_datasource_wrapper.dart'; 13 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 @@ -14,6 +16,8 @@ import 'package:doublefeel_flutter/data/datasource/health/health_remote_datasour
14 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; 16 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
15 import 'package:doublefeel_flutter/data/models/friend/friend_models.dart' 17 import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'
16 show FriendItem; 18 show FriendItem;
  19 +import 'package:doublefeel_flutter/data/models/user/user_models.dart'
  20 + show UserInfoResponse;
17 import 'package:flutter/material.dart'; 21 import 'package:flutter/material.dart';
18 import 'package:get/get.dart'; 22 import 'package:get/get.dart';
19 23
@@ -25,20 +29,24 @@ import 'today_controller.dart'; @@ -25,20 +29,24 @@ import 'today_controller.dart';
25 /// their date, scroll position, and loaded health data remain isolated. 29 /// their date, scroll position, and loaded health data remain isolated.
26 class AppHomeController extends GetxController { 30 class AppHomeController extends GetxController {
27 AppHomeController({ 31 AppHomeController({
28 - FriendsController? friendsController, 32 + FriendsRepository? friendsRepository,
29 UserPreferencesStorage? userPreferencesStorage, 33 UserPreferencesStorage? userPreferencesStorage,
30 - }) : _friendsController = friendsController ?? Get.find<FriendsController>(), 34 + UserApi? userApi,
  35 + }) : _friendsRepository =
  36 + friendsRepository ?? FriendsRepositoryImpl(Get.find<FriendApi>()),
31 _userPreferencesStorage = 37 _userPreferencesStorage =
32 - userPreferencesStorage ?? Get.find<UserPreferencesStorage>(); 38 + userPreferencesStorage ?? Get.find<UserPreferencesStorage>(),
  39 + _userApi = userApi ?? Get.find<UserApi>();
33 40
34 static const selfTodayTag = 'app_home_self'; 41 static const selfTodayTag = 'app_home_self';
35 static const _friendTodayTagPrefix = 'app_home_friend_'; 42 static const _friendTodayTagPrefix = 'app_home_friend_';
36 43
37 - final FriendsController _friendsController; 44 + final FriendsRepository _friendsRepository;
38 final UserPreferencesStorage _userPreferencesStorage; 45 final UserPreferencesStorage _userPreferencesStorage;
  46 + final UserApi _userApi;
39 final selectedTargetId = selfTodayTag.obs; 47 final selectedTargetId = selfTodayTag.obs;
40 final watchedFriend = Rxn<FriendHealthData>(); 48 final watchedFriend = Rxn<FriendHealthData>();
41 - late final Worker _friendsWorker; 49 + final friends = <FriendHealthData>[].obs;
42 50
43 String? _friendTodayTag; 51 String? _friendTodayTag;
44 52
@@ -53,8 +61,16 @@ class AppHomeController extends GetxController { @@ -53,8 +61,16 @@ class AppHomeController extends GetxController {
53 return Get.find<TodayController>(tag: tag); 61 return Get.find<TodayController>(tag: tag);
54 } 62 }
55 63
  64 + TodayController? get friendTodayController {
  65 + final tag = _friendTodayTag;
  66 + if (tag == null || !Get.isRegistered<TodayController>(tag: tag)) {
  67 + return null;
  68 + }
  69 + return Get.find<TodayController>(tag: tag);
  70 + }
  71 +
56 bool get hasWatchedFriend => watchedFriend.value != null; 72 bool get hasWatchedFriend => watchedFriend.value != null;
57 - bool get hasAnyFriend => _friendsController.friends.isNotEmpty; 73 + bool get hasAnyFriend => friends.isNotEmpty;
58 bool get isShowingSelf => selectedTargetId.value == selfTodayTag; 74 bool get isShowingSelf => selectedTargetId.value == selfTodayTag;
59 String get selfName { 75 String get selfName {
60 final info = _userPreferencesStorage.preferences.value.meUserInfo; 76 final info = _userPreferencesStorage.preferences.value.meUserInfo;
@@ -70,12 +86,7 @@ class AppHomeController extends GetxController { @@ -70,12 +86,7 @@ class AppHomeController extends GetxController {
70 void onInit() { 86 void onInit() {
71 super.onInit(); 87 super.onInit();
72 _registerSelfController(); 88 _registerSelfController();
73 - _friendsWorker = ever<List<FriendHealthData>>(  
74 - _friendsController.friends,  
75 - _syncWatchedFriend,  
76 - );  
77 - unawaited(_friendsController.loadFriends());  
78 - _syncWatchedFriend(_friendsController.friends); 89 + unawaited(refreshOnShow());
79 } 90 }
80 91
81 void selectSelf() => selectedTargetId.value = selfTodayTag; 92 void selectSelf() => selectedTargetId.value = selfTodayTag;
@@ -87,6 +98,34 @@ class AppHomeController extends GetxController { @@ -87,6 +98,34 @@ class AppHomeController extends GetxController {
87 } 98 }
88 } 99 }
89 100
  101 + /// Refreshes the data shown by the app-home header whenever this tab returns
  102 + /// to the foreground. The page itself is kept alive by the outer
  103 + /// [IndexedStack], so this cannot rely on [onInit].
  104 + Future<void> refreshOnShow() async {
  105 + await Future.wait<void>([_refreshMyProfile(), _loadFriends()]);
  106 + await refreshSelectedToday();
  107 + }
  108 +
  109 + Future<void> _refreshMyProfile() async {
  110 + final result = await _userApi.getUserInfo();
  111 + if (result case AppSuccess<UserInfoResponse>(data: final user)) {
  112 + await _userPreferencesStorage.updateMeUserInfo(user);
  113 + }
  114 + }
  115 +
  116 + /// The app home owns its friend snapshot and reads it directly from the
  117 + /// friend-list API. It intentionally does not depend on FriendsController,
  118 + /// whose list is scoped to the separate Friends tab.
  119 + Future<void> _loadFriends() async {
  120 + try {
  121 + final data = await _friendsRepository.getFriendList();
  122 + friends.assignAll(data.friends);
  123 + _syncWatchedFriend(data.friends);
  124 + } catch (_) {
  125 + // Keep the current home selection visible if refreshing friends fails.
  126 + }
  127 + }
  128 +
90 Future<void> refreshSelectedToday() => selectedTodayController.refreshTab(); 129 Future<void> refreshSelectedToday() => selectedTodayController.refreshTab();
91 130
92 void scrollSelectedToLatestHrv() { 131 void scrollSelectedToLatestHrv() {
@@ -110,6 +149,9 @@ class AppHomeController extends GetxController { @@ -110,6 +149,9 @@ class AppHomeController extends GetxController {
110 final nextId = next?.friendItem.friendUserId; 149 final nextId = next?.friendItem.friendUserId;
111 if (previousId == nextId) { 150 if (previousId == nextId) {
112 watchedFriend.value = next; 151 watchedFriend.value = next;
  152 + // The watched person did not change, but their nickname, remark, or
  153 + // avatar may have. Keep the friend Today controller in sync as well.
  154 + friendTodayController?.targetFriendInfo.value = next?.friendItem;
113 return; 155 return;
114 } 156 }
115 157
@@ -149,7 +191,6 @@ class AppHomeController extends GetxController { @@ -149,7 +191,6 @@ class AppHomeController extends GetxController {
149 191
150 @override 192 @override
151 void onClose() { 193 void onClose() {
152 - _friendsWorker.dispose();  
153 Get.delete<TodayController>(tag: selfTodayTag, force: true); 194 Get.delete<TodayController>(tag: selfTodayTag, force: true);
154 final tag = _friendTodayTag; 195 final tag = _friendTodayTag;
155 if (tag != null) Get.delete<TodayController>(tag: tag, force: true); 196 if (tag != null) Get.delete<TodayController>(tag: tag, force: true);
@@ -163,7 +163,7 @@ class HomeController extends GetxController { @@ -163,7 +163,7 @@ class HomeController extends GetxController {
163 163
164 switch (index) { 164 switch (index) {
165 case 0: 165 case 0:
166 - Get.find<AppHomeController>().refreshSelectedToday(); 166 + Get.find<AppHomeController>().refreshOnShow();
167 break; 167 break;
168 case 3: 168 case 3:
169 ta.track('enter_doublefeel_my_page'); 169 ta.track('enter_doublefeel_my_page');
@@ -4,6 +4,8 @@ import 'package:doublefeel_flutter/app/modules/home/controllers/today_controller @@ -4,6 +4,8 @@ import 'package:doublefeel_flutter/app/modules/home/controllers/today_controller
4 import 'package:doublefeel_flutter/app/modules/home/views/tabs/today_tab.dart'; 4 import 'package:doublefeel_flutter/app/modules/home/views/tabs/today_tab.dart';
5 import 'package:doublefeel_flutter/core/theme/app_theme.dart'; 5 import 'package:doublefeel_flutter/core/theme/app_theme.dart';
6 import 'package:doublefeel_flutter/app/modules/home/widgets/today/app_home_stress_card.dart'; 6 import 'package:doublefeel_flutter/app/modules/home/widgets/today/app_home_stress_card.dart';
  7 +import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'
  8 + show V2StressScore;
7 import 'package:flutter/material.dart'; 9 import 'package:flutter/material.dart';
8 import 'package:get/get.dart'; 10 import 'package:get/get.dart';
9 11
@@ -13,20 +15,16 @@ class AppHomeTab extends GetView<AppHomeController> { @@ -13,20 +15,16 @@ class AppHomeTab extends GetView<AppHomeController> {
13 @override 15 @override
14 Widget build(BuildContext context) => Obx(() { 16 Widget build(BuildContext context) => Obx(() {
15 final friend = controller.watchedFriend.value; 17 final friend = controller.watchedFriend.value;
  18 + final selfTodayController = controller.selfTodayController;
  19 + final friendTodayController = controller.friendTodayController;
16 final children = <Widget>[ 20 final children = <Widget>[
17 - _todayBody(  
18 - controller.selfTodayController,  
19 - showPeopleSwitcher: friend != null,  
20 - ),  
21 - if (friend != null)  
22 - _todayBody(  
23 - Get.find<TodayController>(  
24 - tag: 'app_home_friend_${friend.friendItem.friendUserId}',  
25 - ),  
26 - showPeopleSwitcher: true,  
27 - ), 21 + _todayBody(selfTodayController, showPeopleSwitcher: friend != null),
  22 + if (friend != null && friendTodayController != null)
  23 + _todayBody(friendTodayController, showPeopleSwitcher: true),
28 ]; 24 ];
29 - final index = controller.isShowingSelf || friend == null ? 0 : 1; 25 + final index = controller.isShowingSelf || friendTodayController == null
  26 + ? 0
  27 + : 1;
30 return IndexedStack(index: index, children: children); 28 return IndexedStack(index: index, children: children);
31 }); 29 });
32 30
@@ -54,6 +52,12 @@ class _PeopleSwitcher extends StatelessWidget { @@ -54,6 +52,12 @@ class _PeopleSwitcher extends StatelessWidget {
54 @override 52 @override
55 Widget build(BuildContext context) => Obx(() { 53 Widget build(BuildContext context) => Obx(() {
56 final friend = controller.watchedFriend.value; 54 final friend = controller.watchedFriend.value;
  55 + final selfTodayController = controller.selfTodayController;
  56 + final friendTodayController = controller.friendTodayController;
  57 + // Subscribe to both controllers explicitly. Their date changes update the
  58 + // score independently while the outer home tab remains mounted.
  59 + final selfStressScore = selfTodayController.v2StressScore.value;
  60 + final friendStressScore = friendTodayController?.v2StressScore.value;
57 if (friend == null) return const SizedBox.shrink(); 61 if (friend == null) return const SizedBox.shrink();
58 return Padding( 62 return Padding(
59 padding: const EdgeInsets.symmetric(horizontal: 16), 63 padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -84,7 +88,7 @@ class _PeopleSwitcher extends StatelessWidget { @@ -84,7 +88,7 @@ class _PeopleSwitcher extends StatelessWidget {
84 isMe: false, 88 isMe: false,
85 status: _statusText( 89 status: _statusText(
86 isSelected: !controller.isShowingSelf, 90 isSelected: !controller.isShowingSelf,
87 - todayController: controller.selectedTodayController, 91 + stressScore: friendStressScore,
88 ), 92 ),
89 scale: scale, 93 scale: scale,
90 onTap: controller.selectFriend, 94 onTap: controller.selectFriend,
@@ -98,7 +102,7 @@ class _PeopleSwitcher extends StatelessWidget { @@ -98,7 +102,7 @@ class _PeopleSwitcher extends StatelessWidget {
98 isMe: true, 102 isMe: true,
99 status: _statusText( 103 status: _statusText(
100 isSelected: controller.isShowingSelf, 104 isSelected: controller.isShowingSelf,
101 - todayController: controller.selectedTodayController, 105 + stressScore: selfStressScore,
102 ), 106 ),
103 scale: scale, 107 scale: scale,
104 onTap: controller.selectSelf, 108 onTap: controller.selectSelf,
@@ -122,12 +126,12 @@ class _PeopleSwitcher extends StatelessWidget { @@ -122,12 +126,12 @@ class _PeopleSwitcher extends StatelessWidget {
122 126
123 _StatusText? _statusText({ 127 _StatusText? _statusText({
124 required bool isSelected, 128 required bool isSelected,
125 - required TodayController todayController, 129 + required V2StressScore? stressScore,
126 }) { 130 }) {
127 - if (!isSelected) return null;  
128 - final state = todayController.v2StressScore.value?.state;  
129 - final text = todayController.v2StressScore.value?.stateString();  
130 - if (text == null || text.isEmpty) return null; 131 + if (!isSelected || stressScore == null) return null;
  132 + final state = stressScore.state;
  133 + final text = stressScore.stateString();
  134 + if (text.isEmpty) return null;
131 return _StatusText(text, _statusColor(state)); 135 return _StatusText(text, _statusColor(state));
132 } 136 }
133 137
1 import 'package:doublefeel_flutter/core/theme/app_theme.dart'; 1 import 'package:doublefeel_flutter/core/theme/app_theme.dart';
  2 +import 'package:doublefeel_flutter/app/modules/interact/models/home_interact_route_arguments.dart';
  3 +import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
2 import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_entry.dart'; 4 import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_entry.dart';
3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 5 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
4 import 'package:flutter/material.dart'; 6 import 'package:flutter/material.dart';
@@ -31,6 +33,24 @@ class TodayHrvNumberCard extends StatelessWidget { @@ -31,6 +33,24 @@ class TodayHrvNumberCard extends StatelessWidget {
31 : context.l10n.averageHrvForTheDay); 33 : context.l10n.averageHrvForTheDay);
32 34
33 var hrvAvg = controller.v2HealthData.value?.hrvAvg ?? 0; 35 var hrvAvg = controller.v2HealthData.value?.hrvAvg ?? 0;
  36 + final hrvArguments = controller.isFriend
  37 + ? HomeInteractRouteArguments.averageHrv(
  38 + context: context,
  39 + friendUserId: controller.friendUserId,
  40 + date: controller.selectedDate.value,
  41 + value: hrvAvg,
  42 + )
  43 + : null;
  44 + final restingHeartRate =
  45 + controller.v2HealthData.value?.lastRestingHrValue ?? 0;
  46 + final restingHeartRateArguments = controller.isFriend
  47 + ? HomeInteractRouteArguments.restingHeartRate(
  48 + context: context,
  49 + friendUserId: controller.friendUserId,
  50 + date: controller.selectedDate.value,
  51 + value: restingHeartRate,
  52 + )
  53 + : null;
34 54
35 return Row( 55 return Row(
36 children: [ 56 children: [
@@ -42,7 +62,8 @@ class TodayHrvNumberCard extends StatelessWidget { @@ -42,7 +62,8 @@ class TodayHrvNumberCard extends StatelessWidget {
42 : otherHrv, 62 : otherHrv,
43 value: hrvAvg > 0 ? hrvAvg.toString() : '-', 63 value: hrvAvg > 0 ? hrvAvg.toString() : '-',
44 unit: 'ms', 64 unit: 'ms',
45 - interactionEnabled: controller.isFriend && hrvAvg > 0, 65 + interactionEnabled: controller.isFriend && hrvArguments != null,
  66 + interactionArguments: hrvArguments,
46 ), 67 ),
47 ), 68 ),
48 69
@@ -53,15 +74,11 @@ class TodayHrvNumberCard extends StatelessWidget { @@ -53,15 +74,11 @@ class TodayHrvNumberCard extends StatelessWidget {
53 context.l10n.restingHeartRate, 74 context.l10n.restingHeartRate,
54 ) 75 )
55 : context.l10n.restingHeartRate, 76 : context.l10n.restingHeartRate,
56 - value:  
57 - controller.v2HealthData.value?.lastRestingHrValue  
58 - ?.toString() ??  
59 - '-', 77 + value: restingHeartRate > 0 ? '$restingHeartRate' : '-',
60 unit: 'bpm', 78 unit: 'bpm',
61 interactionEnabled: 79 interactionEnabled:
62 - controller.isFriend &&  
63 - (controller.v2HealthData.value?.lastRestingHrValue ?? 0) >  
64 - 0, 80 + controller.isFriend && restingHeartRateArguments != null,
  81 + interactionArguments: restingHeartRateArguments,
65 ), 82 ),
66 ), 83 ),
67 ], 84 ],
@@ -76,12 +93,14 @@ class _NumberItem extends StatelessWidget { @@ -76,12 +93,14 @@ class _NumberItem extends StatelessWidget {
76 final String value; 93 final String value;
77 final String unit; 94 final String unit;
78 final bool? interactionEnabled; 95 final bool? interactionEnabled;
  96 + final InteractRouteArguments? interactionArguments;
79 97
80 const _NumberItem({ 98 const _NumberItem({
81 required this.label, 99 required this.label,
82 required this.value, 100 required this.value,
83 required this.unit, 101 required this.unit,
84 this.interactionEnabled, 102 this.interactionEnabled,
  103 + this.interactionArguments,
85 }); 104 });
86 105
87 @override 106 @override
@@ -135,6 +154,7 @@ class _NumberItem extends StatelessWidget { @@ -135,6 +154,7 @@ class _NumberItem extends StatelessWidget {
135 const SizedBox(width: 8), 154 const SizedBox(width: 8),
136 FriendInteractionButton( 155 FriendInteractionButton(
137 enabled: interactionEnabled!, 156 enabled: interactionEnabled!,
  157 + arguments: interactionArguments,
138 padding: const EdgeInsets.only( 158 padding: const EdgeInsets.only(
139 left: 8, 159 left: 8,
140 right: 20, 160 right: 20,
1 import 'package:doublefeel_flutter/app/utils/platform_compact.dart'; 1 import 'package:doublefeel_flutter/app/utils/platform_compact.dart';
  2 +import 'package:doublefeel_flutter/app/modules/interact/models/home_interact_route_arguments.dart';
  3 +import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
2 import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_entry.dart'; 4 import 'package:doublefeel_flutter/app/modules/interact/widgets/friend_interaction_entry.dart';
3 import 'package:doublefeel_flutter/app/widget/circular_gradient_progress/arc_progress_widget.dart'; 5 import 'package:doublefeel_flutter/app/widget/circular_gradient_progress/arc_progress_widget.dart';
4 import 'package:doublefeel_flutter/app/widget/circular_gradient_progress/combine.dart'; 6 import 'package:doublefeel_flutter/app/widget/circular_gradient_progress/combine.dart';
@@ -57,6 +59,17 @@ class TodaySleepCard extends StatelessWidget { @@ -57,6 +59,17 @@ class TodaySleepCard extends StatelessWidget {
57 double? sleepScoreRatio = sleepDuration == 0 59 double? sleepScoreRatio = sleepDuration == 0
58 ? null 60 ? null
59 : ((healthData?.sleepScore ?? 0.0) / 100.0); 61 : ((healthData?.sleepScore ?? 0.0) / 100.0);
  62 + final interactionArguments = controller.isFriend
  63 + ? HomeInteractRouteArguments.sleep(
  64 + context: context,
  65 + friendUserId: controller.friendUserId,
  66 + date: controller.selectedDate.value,
  67 + durationSeconds: sleepDuration,
  68 + qualityScore: healthData?.sleepScore,
  69 + qualityState: healthData?.sleepState,
  70 + averageHeartRate: healthData?.hrAvg,
  71 + )
  72 + : null;
60 73
61 return _TodaySummaryCard( 74 return _TodaySummaryCard(
62 iconAsset: 'assets/images/common/ic_sleep_stroke.png', 75 iconAsset: 'assets/images/common/ic_sleep_stroke.png',
@@ -199,7 +212,9 @@ class TodaySleepCard extends StatelessWidget { @@ -199,7 +212,9 @@ class TodaySleepCard extends StatelessWidget {
199 interpolatedColorRatio: 1, 212 interpolatedColorRatio: 1,
200 ), 213 ),
201 ), 214 ),
202 - interactionEnabled: controller.isFriend && sleepDuration > 0, 215 + interactionEnabled:
  216 + controller.isFriend && interactionArguments != null,
  217 + interactionArguments: interactionArguments,
203 ); 218 );
204 }), 219 }),
205 ); 220 );
@@ -319,6 +334,16 @@ class TodayActivityCard extends StatelessWidget { @@ -319,6 +334,16 @@ class TodayActivityCard extends StatelessWidget {
319 double? standRatio = (stand > 0 && (activityTarget?.stand ?? 0) > 0) 334 double? standRatio = (stand > 0 && (activityTarget?.stand ?? 0) > 0)
320 ? (stand / (activityTarget?.stand ?? 0)) 335 ? (stand / (activityTarget?.stand ?? 0))
321 : null; 336 : null;
  337 + final interactionArguments = controller.isFriend
  338 + ? HomeInteractRouteArguments.fitness(
  339 + context: context,
  340 + friendUserId: controller.friendUserId,
  341 + date: controller.selectedDate.value,
  342 + activeCalories: activity,
  343 + exerciseSeconds: exercise,
  344 + standHours: stand,
  345 + )
  346 + : null;
322 347
323 return _TodaySummaryCard( 348 return _TodaySummaryCard(
324 iconAsset: 'assets/images/common/ic_exercise.png', 349 iconAsset: 'assets/images/common/ic_exercise.png',
@@ -424,8 +449,8 @@ class TodayActivityCard extends StatelessWidget { @@ -424,8 +449,8 @@ class TodayActivityCard extends StatelessWidget {
424 ), 449 ),
425 ), 450 ),
426 interactionEnabled: 451 interactionEnabled:
427 - controller.isFriend &&  
428 - (activity > 0 || exercise > 0 || stand > 0), 452 + controller.isFriend && interactionArguments != null,
  453 + interactionArguments: interactionArguments,
429 ); 454 );
430 }), 455 }),
431 ); 456 );
@@ -441,6 +466,7 @@ class _TodaySummaryCard extends StatelessWidget { @@ -441,6 +466,7 @@ class _TodaySummaryCard extends StatelessWidget {
441 required this.metrics, 466 required this.metrics,
442 required this.rightWidget, 467 required this.rightWidget,
443 this.interactionEnabled, 468 this.interactionEnabled,
  469 + this.interactionArguments,
444 }); 470 });
445 471
446 final String iconAsset; 472 final String iconAsset;
@@ -451,6 +477,7 @@ class _TodaySummaryCard extends StatelessWidget { @@ -451,6 +477,7 @@ class _TodaySummaryCard extends StatelessWidget {
451 477
452 final Widget rightWidget; 478 final Widget rightWidget;
453 final bool? interactionEnabled; 479 final bool? interactionEnabled;
  480 + final InteractRouteArguments? interactionArguments;
454 481
455 @override 482 @override
456 Widget build(BuildContext context) { 483 Widget build(BuildContext context) {
@@ -486,7 +513,10 @@ class _TodaySummaryCard extends StatelessWidget { @@ -486,7 +513,10 @@ class _TodaySummaryCard extends StatelessWidget {
486 ), 513 ),
487 const Spacer(), 514 const Spacer(),
488 if (interactionEnabled != null) ...[ 515 if (interactionEnabled != null) ...[
489 - FriendInteractionButton(enabled: interactionEnabled!), 516 + FriendInteractionButton(
  517 + enabled: interactionEnabled!,
  518 + arguments: interactionArguments,
  519 + ),
490 ], 520 ],
491 ], 521 ],
492 ), 522 ),
1 import 'dart:async'; 1 import 'dart:async';
2 2
3 import 'package:doublefeel_flutter/app/routes/app_pages.dart'; 3 import 'package:doublefeel_flutter/app/routes/app_pages.dart';
  4 +import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
  5 +import 'package:doublefeel_flutter/app/modules/friends/data/friends_repository.dart';
  6 +import 'package:doublefeel_flutter/app/modules/friends/models/friend_health_data.dart';
  7 +import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
4 import 'package:doublefeel_flutter/core/network/api/interaction_api.dart'; 8 import 'package:doublefeel_flutter/core/network/api/interaction_api.dart';
5 import 'package:doublefeel_flutter/core/result/app_result.dart'; 9 import 'package:doublefeel_flutter/core/result/app_result.dart';
6 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; 10 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
7 import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart'; 11 import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
8 import 'package:doublefeel_flutter/data/models/user/user_models.dart'; 12 import 'package:doublefeel_flutter/data/models/user/user_models.dart';
  13 +import 'package:flutter/foundation.dart';
9 import 'package:get/get.dart'; 14 import 'package:get/get.dart';
10 15
11 enum InteractAction { stick, miss, punch } 16 enum InteractAction { stick, miss, punch }
12 17
13 extension InteractActionUi on InteractAction { 18 extension InteractActionUi on InteractAction {
14 int get apiValue => switch (this) { 19 int get apiValue => switch (this) {
15 - InteractAction.stick => 0,  
16 - InteractAction.miss => 1,  
17 - InteractAction.punch => 2,  
18 - }; 20 + InteractAction.stick => 0,
  21 + InteractAction.miss => 1,
  22 + InteractAction.punch => 2,
  23 + };
19 24
20 String get title => switch (this) { 25 String get title => switch (this) {
21 - InteractAction.stick => '戳一戳',  
22 - InteractAction.miss => '想Ta',  
23 - InteractAction.punch => '打一拳',  
24 - }; 26 + InteractAction.stick => '戳一戳',
  27 + InteractAction.miss => '想Ta',
  28 + InteractAction.punch => '打一拳',
  29 + };
25 30
26 bool get needsVip => this != InteractAction.stick; 31 bool get needsVip => this != InteractAction.stick;
27 32
28 String get iconAsset => switch (this) { 33 String get iconAsset => switch (this) {
29 - InteractAction.stick => 'assets/images/interact/icon_stick.png',  
30 - InteractAction.miss => 'assets/images/interact/icon_miss.png',  
31 - InteractAction.punch => 'assets/images/interact/icon_punch.png',  
32 - }; 34 + InteractAction.stick => 'assets/images/interact/icon_stick.png',
  35 + InteractAction.miss => 'assets/images/interact/icon_miss.png',
  36 + InteractAction.punch => 'assets/images/interact/icon_punch.png',
  37 + };
33 38
34 String get titleAsset => switch (this) { 39 String get titleAsset => switch (this) {
35 - InteractAction.stick => 'assets/images/interact/title_stick.png',  
36 - InteractAction.miss => 'assets/images/interact/title_miss.png',  
37 - InteractAction.punch => 'assets/images/interact/title_punch.png',  
38 - }; 40 + InteractAction.stick => 'assets/images/interact/title_stick.png',
  41 + InteractAction.miss => 'assets/images/interact/title_miss.png',
  42 + InteractAction.punch => 'assets/images/interact/title_punch.png',
  43 + };
39 44
40 String get lockedTitleAsset => switch (this) { 45 String get lockedTitleAsset => switch (this) {
41 - InteractAction.stick => 'assets/images/interact/title_stick_locked.png',  
42 - InteractAction.miss => 'assets/images/interact/title_miss_locked.png',  
43 - InteractAction.punch => 'assets/images/interact/title_punch_locked.png',  
44 - }; 46 + InteractAction.stick => 'assets/images/interact/title_stick_locked.png',
  47 + InteractAction.miss => 'assets/images/interact/title_miss_locked.png',
  48 + InteractAction.punch => 'assets/images/interact/title_punch_locked.png',
  49 + };
45 } 50 }
46 51
47 class InteractController extends GetxController { 52 class InteractController extends GetxController {
48 - InteractController(this._interactionApi, this._userPreferencesStorage); 53 + InteractController(
  54 + this._interactionApi,
  55 + this._userPreferencesStorage, {
  56 + FriendsRepository? friendsRepository,
  57 + }) : _friendsRepository =
  58 + friendsRepository ?? FriendsRepositoryImpl(Get.find<FriendApi>());
49 59
50 final InteractionApi _interactionApi; 60 final InteractionApi _interactionApi;
51 final UserPreferencesStorage _userPreferencesStorage; 61 final UserPreferencesStorage _userPreferencesStorage;
  62 + final FriendsRepository _friendsRepository;
52 63
53 final records = <InteractionRecord>[].obs; 64 final records = <InteractionRecord>[].obs;
54 final isLoading = false.obs; 65 final isLoading = false.obs;
55 final sendingAction = Rxn<InteractAction>(); 66 final sendingAction = Rxn<InteractAction>();
56 final activeAction = Rxn<InteractAction>(); 67 final activeAction = Rxn<InteractAction>();
  68 + final selectedFriend = Rxn<FriendHealthData>();
  69 + final targetFriendUserId = RxnInt();
  70 + final friends = <FriendHealthData>[].obs;
  71 + InteractRouteArguments? entryArguments;
57 72
58 UserInfoResponse? get me => 73 UserInfoResponse? get me =>
59 _userPreferencesStorage.preferences.value.meUserInfo; 74 _userPreferencesStorage.preferences.value.meUserInfo;
60 - UserInfoResponse? get partner =>  
61 - _userPreferencesStorage.preferences.value.partnerUserInfo;  
62 - bool get isPaired => partner?.id != null; 75 + UserInfoResponse? get partner => selectedFriend.value == null
  76 + ? _userPreferencesStorage.preferences.value.partnerUserInfo
  77 + : null;
  78 + bool get isPaired => targetFriendUserId.value != null || partner?.id != null;
63 bool get isVip => 79 bool get isVip =>
64 _userPreferencesStorage.preferences.value.vipInfo?.isVip ?? false; 80 _userPreferencesStorage.preferences.value.vipInfo?.isVip ?? false;
  81 + List<FriendHealthData> get availableFriends =>
  82 + friends.toList(growable: false);
  83 +
  84 + String get friendName =>
  85 + selectedFriend.value?.name ?? partner?.nickname ?? '好友';
  86 + String? get friendRemark => selectedFriend.value?.remark;
  87 + String? get friendAvatarUrl =>
  88 + selectedFriend.value?.avatarUrl ?? partner?.avatar;
  89 + String get friendLabel {
  90 + final remark = friendRemark?.trim();
  91 + return remark == null || remark.isEmpty
  92 + ? friendName
  93 + : '$remark$friendName)';
  94 + }
  95 +
  96 + String get selfName => me?.nickname ?? '我';
  97 + String? get selfAvatarUrl => me?.avatar;
  98 +
  99 + /// V2 interaction types for the currently implemented home-page entries.
  100 + int get interactionType {
  101 + final values = entryArguments?.values;
  102 + if (values == null || values.isEmpty) return 100;
  103 + return switch (values.first.type) {
  104 + InteractValueType.averageHrv => 101,
  105 + InteractValueType.restingHeartRate => 102,
  106 + InteractValueType.sleepDuration ||
  107 + InteractValueType.sleepQualityScore ||
  108 + InteractValueType.sleepQualityState ||
  109 + InteractValueType.averageSleepHeartRate => 103,
  110 + InteractValueType.activeCalories ||
  111 + InteractValueType.exerciseDuration ||
  112 + InteractValueType.standDuration => 104,
  113 + };
  114 + }
65 115
66 @override 116 @override
67 void onInit() { 117 void onInit() {
68 super.onInit(); 118 super.onInit();
69 - unawaited(loadRecords()); 119 + final arguments = Get.arguments;
  120 + if (arguments is InteractRouteArguments) {
  121 + entryArguments = arguments;
  122 + targetFriendUserId.value = arguments.friendUserId;
  123 + }
  124 + refreshPage();
  125 + }
  126 +
  127 + /// Reload target profile and records together after switching friends.
  128 + Future<void> refreshPage() async {
  129 + await _loadSelectedFriend();
  130 + await loadRecords();
  131 + }
  132 +
  133 + Future<void> loadFriendCandidates() async {
  134 + await _loadFriends();
  135 + await _loadSelectedFriend();
  136 + }
  137 +
  138 + Future<void> selectFriend(FriendHealthData friend) async {
  139 + final userId = friend.userId;
  140 + if (userId == null || userId == targetFriendUserId.value) return;
  141 + targetFriendUserId.value = userId;
  142 + selectedFriend.value = friend;
  143 + await refreshPage();
  144 + }
  145 +
  146 + Future<void> _loadSelectedFriend() async {
  147 + await _loadFriends();
  148 + var targetId = targetFriendUserId.value;
  149 + if (targetId == null) {
  150 + // Read directly from the friend source rather than the app-home state.
  151 + final watchedFriend = availableFriends
  152 + .where(
  153 + (friend) =>
  154 + friend.isOnWatchFace && friend.friendItem.friendUserId != null,
  155 + )
  156 + .firstOrNull;
  157 + targetId =
  158 + watchedFriend?.userId ?? watchedFriend?.friendItem.friendUserId;
  159 + targetId ??= partner?.id;
  160 + targetFriendUserId.value = targetId;
  161 + }
  162 + if (targetId == null) {
  163 + selectedFriend.value = null;
  164 + return;
  165 + }
  166 + final friend = availableFriends
  167 + .where((friend) => friend.userId == targetId)
  168 + .firstOrNull;
  169 + if (friend != null) {
  170 + selectedFriend.value = friend;
  171 + return;
  172 + }
  173 + selectedFriend.value = null;
  174 + }
  175 +
  176 + Future<void> _loadFriends() async {
  177 + try {
  178 + final data = await _friendsRepository.getFriendList();
  179 + friends.assignAll(data.friends);
  180 + } catch (_) {
  181 + // Keep the existing snapshot during a transient list-request failure.
  182 + }
70 } 183 }
71 184
72 Future<void> loadRecords() async { 185 Future<void> loadRecords() async {
73 isLoading.value = true; 186 isLoading.value = true;
74 - final result = await _interactionApi.getInteractionRecordList(); 187 + final friendUserId = targetFriendUserId.value;
  188 + if (friendUserId == null) {
  189 + records.clear();
  190 + isLoading.value = false;
  191 + return;
  192 + }
  193 + final result = await _interactionApi.getInteractionRecordList(friendUserId);
75 isLoading.value = false; 194 isLoading.value = false;
76 switch (result) { 195 switch (result) {
77 case AppSuccess(:final data): 196 case AppSuccess(:final data):
78 - records.assignAll(data.records ?? const <InteractionRecord>[]); 197 + final responseRecords = data.records ?? const <InteractionRecord>[];
  198 + records.assignAll(
  199 + kDebugMode && responseRecords.isEmpty
  200 + ? _mockRecords()
  201 + : responseRecords,
  202 + );
79 case AppFailure(): 203 case AppFailure():
80 - // Keep the last successful list visible when a refresh fails. 204 + if (kDebugMode) {
  205 + records.assignAll(_mockRecords());
  206 + }
  207 + // Keep the last successful list visible in release when a refresh fails.
81 } 208 }
82 } 209 }
83 210
@@ -96,12 +223,18 @@ class InteractController extends GetxController { @@ -96,12 +223,18 @@ class InteractController extends GetxController {
96 return; 223 return;
97 } 224 }
98 if (sendingAction.value != null) return; 225 if (sendingAction.value != null) return;
  226 + final friendUserId = targetFriendUserId.value;
  227 + if (friendUserId == null) {
  228 + _showToast('未找到互动好友');
  229 + return;
  230 + }
99 231
100 sendingAction.value = action; 232 sendingAction.value = action;
101 activeAction.value = action; 233 activeAction.value = action;
102 final result = await _interactionApi.sendInteraction( 234 final result = await _interactionApi.sendInteraction(
103 InteractionData( 235 InteractionData(
104 - interactionType: 0, 236 + friendUserId: friendUserId,
  237 + interactionType: interactionType,
105 actionType: action.apiValue, 238 actionType: action.apiValue,
106 action: actionVariables, 239 action: actionVariables,
107 ), 240 ),
@@ -163,16 +296,53 @@ class InteractController extends GetxController { @@ -163,16 +296,53 @@ class InteractController extends GetxController {
163 } 296 }
164 297
165 String? actionMaskAsset() => switch (activeAction.value) { 298 String? actionMaskAsset() => switch (activeAction.value) {
166 - InteractAction.stick =>  
167 - 'assets/images/interact/animations/interactionStickMaskAnimation.png',  
168 - InteractAction.miss =>  
169 - 'assets/images/interact/animations/interactionMissMaskAnimation.png',  
170 - InteractAction.punch =>  
171 - 'assets/images/interact/animations/interactionPunchMaskAnimation.png',  
172 - null => null,  
173 - }; 299 + InteractAction.stick =>
  300 + 'assets/images/interact/animations/interactionStickMaskAnimation.png',
  301 + InteractAction.miss =>
  302 + 'assets/images/interact/animations/interactionMissMaskAnimation.png',
  303 + InteractAction.punch =>
  304 + 'assets/images/interact/animations/interactionPunchMaskAnimation.png',
  305 + null => null,
  306 + };
174 307
175 void _showToast(String message) { 308 void _showToast(String message) {
176 Get.snackbar('互动', message, snackPosition: SnackPosition.BOTTOM); 309 Get.snackbar('互动', message, snackPosition: SnackPosition.BOTTOM);
177 } 310 }
  311 +
  312 + List<InteractionRecord> _mockRecords() {
  313 + final now = DateTime.now();
  314 + final meId = me?.id ?? 1;
  315 + final friendId = targetFriendUserId.value ?? 2;
  316 + const texts = <String>[
  317 + '我戳了戳Ta一下~',
  318 + 'Ta打了你一拳~',
  319 + '我想Ta了,今天也要好好照顾自己呀~',
  320 + 'Ta戳了戳你,提醒你起来活动一下。',
  321 + '我送给Ta一份今天的好心情~',
  322 + 'Ta想你了,记得给Ta一个回应。',
  323 + '我打了一拳,今天也要元气满满!',
  324 + 'Ta戳了戳你,别忘了喝水。',
  325 + '我想念Ta,晚点一起聊聊天吧。',
  326 + 'Ta送来一份鼓励:今天辛苦了!',
  327 + '我戳了戳Ta,继续加油~',
  328 + 'Ta打了一拳,是轻轻的一拳。',
  329 + '我想Ta了。',
  330 + 'Ta戳了戳你。',
  331 + '我送给Ta一个拥抱。',
  332 + ];
  333 + return List<InteractionRecord>.generate(
  334 + texts.length,
  335 + (index) => InteractionRecord(
  336 + id: index + 1,
  337 + userId: index.isEven ? meId : friendId,
  338 + actionType: index % 3,
  339 + text: texts[index],
  340 + createTime:
  341 + now
  342 + .subtract(Duration(minutes: index * 17 + 3))
  343 + .millisecondsSinceEpoch ~/
  344 + Duration.millisecondsPerSecond,
  345 + ),
  346 + );
  347 + }
178 } 348 }
  1 +import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
  2 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  3 +import 'package:flutter/material.dart';
  4 +
  5 +/// Builds the typed interaction context emitted by cards on the app home page.
  6 +class HomeInteractRouteArguments {
  7 + const HomeInteractRouteArguments._();
  8 +
  9 + static InteractRouteArguments? averageHrv({
  10 + required BuildContext context,
  11 + required int? friendUserId,
  12 + required DateTime date,
  13 + required int value,
  14 + }) => value <= 0
  15 + ? null
  16 + : _create(
  17 + context: context,
  18 + friendUserId: friendUserId,
  19 + date: date,
  20 + metricTitle: context.l10n.averageHrvForTheDay,
  21 + values: [
  22 + InteractDisplayValue(
  23 + type: InteractValueType.averageHrv,
  24 + value: value,
  25 + unit: InteractValueUnit.milliseconds,
  26 + ),
  27 + ],
  28 + );
  29 +
  30 + static InteractRouteArguments? restingHeartRate({
  31 + required BuildContext context,
  32 + required int? friendUserId,
  33 + required DateTime date,
  34 + required int value,
  35 + }) => value <= 0
  36 + ? null
  37 + : _create(
  38 + context: context,
  39 + friendUserId: friendUserId,
  40 + date: date,
  41 + metricTitle: context.l10n.restingHeartRate,
  42 + values: [
  43 + InteractDisplayValue(
  44 + type: InteractValueType.restingHeartRate,
  45 + value: value,
  46 + unit: InteractValueUnit.beatsPerMinute,
  47 + ),
  48 + ],
  49 + );
  50 +
  51 + static InteractRouteArguments? sleep({
  52 + required BuildContext context,
  53 + required int? friendUserId,
  54 + required DateTime date,
  55 + required int durationSeconds,
  56 + required double? qualityScore,
  57 + required int? qualityState,
  58 + required int? averageHeartRate,
  59 + }) => durationSeconds <= 0
  60 + ? null
  61 + : _create(
  62 + context: context,
  63 + friendUserId: friendUserId,
  64 + date: date,
  65 + metricTitle: context.l10n.sleep,
  66 + values: [
  67 + InteractDisplayValue(
  68 + type: InteractValueType.sleepDuration,
  69 + value: durationSeconds,
  70 + unit: InteractValueUnit.seconds,
  71 + ),
  72 + if (qualityScore != null)
  73 + InteractDisplayValue(
  74 + type: InteractValueType.sleepQualityScore,
  75 + value: qualityScore,
  76 + unit: InteractValueUnit.score,
  77 + ),
  78 + if (qualityState != null)
  79 + InteractDisplayValue(
  80 + type: InteractValueType.sleepQualityState,
  81 + value: qualityState,
  82 + unit: InteractValueUnit.state,
  83 + ),
  84 + if (averageHeartRate != null && averageHeartRate > 0)
  85 + InteractDisplayValue(
  86 + type: InteractValueType.averageSleepHeartRate,
  87 + value: averageHeartRate,
  88 + unit: InteractValueUnit.beatsPerMinute,
  89 + ),
  90 + ],
  91 + );
  92 +
  93 + static InteractRouteArguments? fitness({
  94 + required BuildContext context,
  95 + required int? friendUserId,
  96 + required DateTime date,
  97 + required int activeCalories,
  98 + required int exerciseSeconds,
  99 + required int standHours,
  100 + }) => _create(
  101 + context: context,
  102 + friendUserId: friendUserId,
  103 + date: date,
  104 + metricTitle: context.l10n.fitness,
  105 + values: [
  106 + if (activeCalories > 0)
  107 + InteractDisplayValue(
  108 + type: InteractValueType.activeCalories,
  109 + value: activeCalories,
  110 + unit: InteractValueUnit.kilocalories,
  111 + ),
  112 + if (exerciseSeconds > 0)
  113 + InteractDisplayValue(
  114 + type: InteractValueType.exerciseDuration,
  115 + value: exerciseSeconds,
  116 + unit: InteractValueUnit.seconds,
  117 + ),
  118 + if (standHours > 0)
  119 + InteractDisplayValue(
  120 + type: InteractValueType.standDuration,
  121 + value: standHours,
  122 + unit: InteractValueUnit.hours,
  123 + ),
  124 + ],
  125 + );
  126 +
  127 + static InteractRouteArguments? _create({
  128 + required BuildContext context,
  129 + required int? friendUserId,
  130 + required DateTime date,
  131 + required String metricTitle,
  132 + required List<InteractDisplayValue> values,
  133 + }) {
  134 + if (friendUserId == null || friendUserId <= 0 || values.isEmpty) {
  135 + return null;
  136 + }
  137 + return InteractRouteArguments(
  138 + friendUserId: friendUserId,
  139 + page: InteractPageSource.home,
  140 + title: '${_dateLabel(context, date)} · $metricTitle',
  141 + date: DateUtils.dateOnly(date),
  142 + values: values,
  143 + );
  144 + }
  145 +
  146 + static String _dateLabel(BuildContext context, DateTime date) {
  147 + final today = DateUtils.dateOnly(DateTime.now());
  148 + final target = DateUtils.dateOnly(date);
  149 + if (target == today) return context.l10n.today;
  150 + if (target == today.subtract(const Duration(days: 1))) {
  151 + return context.l10n.yesterday;
  152 + }
  153 + return MaterialLocalizations.of(context).formatMediumDate(target);
  154 + }
  155 +}
  1 +/// Typed context passed from a health-data entry to the interaction page.
  2 +///
  3 +/// Keep values atomic: a sleep or fitness card is represented by several
  4 +/// [InteractDisplayValue]s instead of flattening them into one localized
  5 +/// string. The interaction page and the request payload can therefore format
  6 +/// the same data independently.
  7 +class InteractRouteArguments {
  8 + const InteractRouteArguments({
  9 + required this.friendUserId,
  10 + required this.page,
  11 + required this.title,
  12 + required this.date,
  13 + required this.values,
  14 + });
  15 +
  16 + final int friendUserId;
  17 + final InteractPageSource page;
  18 + final String title;
  19 + final DateTime date;
  20 + final List<InteractDisplayValue> values;
  21 +}
  22 +
  23 +enum InteractPageSource { home }
  24 +
  25 +enum InteractValueType {
  26 + averageHrv,
  27 + restingHeartRate,
  28 + sleepDuration,
  29 + sleepQualityScore,
  30 + sleepQualityState,
  31 + averageSleepHeartRate,
  32 + activeCalories,
  33 + exerciseDuration,
  34 + standDuration,
  35 +}
  36 +
  37 +enum InteractValueUnit {
  38 + milliseconds,
  39 + beatsPerMinute,
  40 + seconds,
  41 + kilocalories,
  42 + hours,
  43 + score,
  44 + state,
  45 +}
  46 +
  47 +class InteractDisplayValue {
  48 + const InteractDisplayValue({
  49 + required this.type,
  50 + required this.value,
  51 + required this.unit,
  52 + });
  53 +
  54 + final InteractValueType type;
  55 + final num value;
  56 + final InteractValueUnit unit;
  57 +}
  1 +import 'package:doublefeel_flutter/app/modules/friends/views/select_friend_view.dart';
1 import 'package:doublefeel_flutter/app/modules/interact/controllers/interact_controller.dart'; 2 import 'package:doublefeel_flutter/app/modules/interact/controllers/interact_controller.dart';
  3 +import 'package:doublefeel_flutter/app/modules/friends/models/friend_health_data.dart';
2 import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart'; 4 import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
3 -import 'package:doublefeel_flutter/data/models/user/user_models.dart';  
4 import 'package:flutter/material.dart'; 5 import 'package:flutter/material.dart';
5 import 'package:get/get.dart'; 6 import 'package:get/get.dart';
6 7
@@ -8,169 +9,167 @@ class InteractView extends GetView<InteractController> { @@ -8,169 +9,167 @@ class InteractView extends GetView<InteractController> {
8 const InteractView({super.key}); 9 const InteractView({super.key});
9 10
10 static const _purple = Color(0xff896cdc); 11 static const _purple = Color(0xff896cdc);
11 - static const _muted = Color(0xff908b91); 12 + static const _brandText = Color(0xff845eee);
  13 + static const _muted = Color(0xff78787d);
  14 + static const _placeholder = Color(0xffded3ff);
12 15
13 @override 16 @override
14 Widget build(BuildContext context) { 17 Widget build(BuildContext context) {
15 return Scaffold( 18 return Scaffold(
16 - body: Stack(  
17 - fit: StackFit.expand,  
18 - children: [  
19 - Image.asset('assets/images/interact/page_bg.png', fit: BoxFit.cover),  
20 - SafeArea(  
21 - child: Obx(  
22 - () => Column(  
23 - children: [  
24 - _appBar(),  
25 - Expanded(  
26 - child: RefreshIndicator(  
27 - onRefresh: controller.loadRecords,  
28 - child: ListView(  
29 - physics: const AlwaysScrollableScrollPhysics(),  
30 - padding: const EdgeInsets.only(bottom: 28),  
31 - children: [  
32 - _marquee(),  
33 - _characterArea(),  
34 - const SizedBox(height: 34),  
35 - _actionButtons(context),  
36 - const SizedBox(height: 28),  
37 - _records(),  
38 - ],  
39 - ), 19 + backgroundColor: const Color(0xfff5f2ff),
  20 + body: DecoratedBox(
  21 + decoration: const BoxDecoration(
  22 + gradient: LinearGradient(
  23 + colors: [Color(0xffc5b0ff), Color(0xfff5f2ff)],
  24 + begin: Alignment.topCenter,
  25 + end: Alignment(0, -.25),
  26 + ),
  27 + ),
  28 + child: SafeArea(
  29 + child: Obx(() {
  30 + final hasInteractions = controller.records.isNotEmpty;
  31 + return Column(
  32 + children: [
  33 + _appBar(context),
  34 + Expanded(
  35 + child: RefreshIndicator(
  36 + onRefresh: controller.refreshPage,
  37 + child: ListView(
  38 + physics: const AlwaysScrollableScrollPhysics(),
  39 + padding: const EdgeInsets.only(bottom: 28),
  40 + children: [
  41 + _interactionHero(hasInteractions),
  42 + _actionButtons(context),
  43 + const SizedBox(height: 16),
  44 + _records(),
  45 + ],
40 ), 46 ),
41 ), 47 ),
42 - ],  
43 - ),  
44 - ),  
45 - ),  
46 - Obx(() {  
47 - final asset = controller.actionMaskAsset();  
48 - if (asset == null) return const SizedBox.shrink();  
49 - return IgnorePointer(  
50 - child: Align(  
51 - alignment: const Alignment(0, -.5),  
52 - child:  
53 - Image.asset(asset, width: Get.width, fit: BoxFit.fitWidth),  
54 - ), 48 + ),
  49 + ],
55 ); 50 );
56 }), 51 }),
57 - ], 52 + ),
58 ), 53 ),
59 ); 54 );
60 } 55 }
61 56
62 - Widget _appBar() { 57 + Widget _appBar(BuildContext context) {
63 return SizedBox( 58 return SizedBox(
64 - height: 48, 59 + height: 44,
65 child: Row( 60 child: Row(
66 children: [ 61 children: [
67 IconButton( 62 IconButton(
68 - icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black), 63 + icon: const Icon(
  64 + Icons.arrow_back_ios_new,
  65 + color: Color(0xff0f0f11),
  66 + size: 20,
  67 + ),
69 onPressed: Get.back, 68 onPressed: Get.back,
70 ), 69 ),
71 - const Spacer(), 70 + Expanded(
  71 + child: Text(
  72 + textAlign: TextAlign.center,
  73 + style: const TextStyle(
  74 + color: Color(0xff0f0f11),
  75 + fontSize: 16,
  76 + fontWeight: FontWeight.w600,
  77 + ),
  78 + '互动',
  79 + ),
  80 + ),
72 IconButton( 81 IconButton(
73 - tooltip: '刷新互动记录',  
74 - icon: const Icon(Icons.refresh, color: Colors.black87),  
75 - onPressed: controller.loadRecords, 82 + tooltip: '切换好友',
  83 + icon: const Icon(
  84 + Icons.swap_horiz_rounded,
  85 + color: Color(0xff0f0f11),
  86 + size: 23,
  87 + ),
  88 + onPressed: _changeFriend,
76 ), 89 ),
77 ], 90 ],
78 ), 91 ),
79 ); 92 );
80 } 93 }
81 94
82 - Widget _marquee() {  
83 - final records = controller.records.take(3).toList(growable: false);  
84 - if (records.isEmpty) return const SizedBox(height: 72); 95 + Widget _interactionHero(bool hasInteractions) {
  96 + // The 71px difference matches the Figma's extra interaction bubbles.
  97 + final characterTop = hasInteractions ? 127.0 : 56.0;
85 return SizedBox( 98 return SizedBox(
86 - height: 72,  
87 - child: ListView.separated(  
88 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),  
89 - scrollDirection: Axis.horizontal,  
90 - itemCount: records.length,  
91 - separatorBuilder: (_, __) => const SizedBox(width: 10),  
92 - itemBuilder: (_, index) {  
93 - final record = records[index];  
94 - final isMe = record.userId == controller.me?.id;  
95 - return Container(  
96 - constraints: const BoxConstraints(maxWidth: 210),  
97 - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),  
98 - decoration: BoxDecoration(  
99 - image: const DecorationImage(  
100 - image: AssetImage('assets/images/interact/page_bubble_bg.png'),  
101 - fit: BoxFit.fill, 99 + height: hasInteractions ? 299 : 228,
  100 + child: Stack(
  101 + clipBehavior: Clip.none,
  102 + children: [
  103 + if (hasInteractions)
  104 + Positioned.fill(
  105 + child: _FloatingInteractionBubbles(
  106 + records: controller.records.take(3).toList(growable: false),
  107 + recordText: controller.recordText,
102 ), 108 ),
103 ), 109 ),
  110 + Positioned(
  111 + top: characterTop,
  112 + left: 28,
  113 + right: 28,
104 child: Row( 114 child: Row(
105 - mainAxisSize: MainAxisSize.min,  
106 children: [ 115 children: [
107 - _avatar(isMe ? controller.me : controller.partner, size: 28),  
108 - const SizedBox(width: 7),  
109 - Flexible(  
110 - child: Text(  
111 - controller.recordText(record),  
112 - maxLines: 1,  
113 - overflow: TextOverflow.ellipsis,  
114 - style: const TextStyle(color: Colors.white, fontSize: 13), 116 + Expanded(
  117 + child: _personPreview(
  118 + controller.friendLabel,
  119 + '好友',
  120 + controller.friendAvatarUrl,
  121 + ),
  122 + ),
  123 + const SizedBox(width: 28),
  124 + Expanded(
  125 + child: _personPreview(
  126 + controller.selfName,
  127 + '我',
  128 + controller.selfAvatarUrl,
115 ), 129 ),
116 ), 130 ),
117 ], 131 ],
118 ), 132 ),
119 - );  
120 - },  
121 - ),  
122 - );  
123 - }  
124 -  
125 - Widget _characterArea() {  
126 - final actionAsset = controller.partnerActionAsset();  
127 - return Padding(  
128 - padding: const EdgeInsets.symmetric(horizontal: 24),  
129 - child: Row(  
130 - children: [  
131 - Expanded(child: _person(me: true)),  
132 - const SizedBox(width: 12),  
133 - Expanded(  
134 - child: controller.isPaired  
135 - ? _person(me: false, actionAsset: actionAsset)  
136 - : _unpairedPerson(),  
137 ), 133 ),
138 ], 134 ],
139 ), 135 ),
140 ); 136 );
141 } 137 }
142 138
143 - Widget _person({required bool me, String? actionAsset}) {  
144 - final user = me ? controller.me : controller.partner; 139 + Widget _personPreview(String name, String role, String? avatarUrl) {
145 return Column( 140 return Column(
  141 + mainAxisSize: MainAxisSize.min,
146 children: [ 142 children: [
147 - SizedBox(  
148 - height: 132,  
149 - child: actionAsset == null  
150 - ? _avatar(user,  
151 - size: 108,  
152 - borderColor: me ? _purple : const Color(0xfffff45b))  
153 - : Image.asset(actionAsset, fit: BoxFit.contain), 143 + _profileImage(avatarUrl, size: 96, radius: 48),
  144 + const SizedBox(height: 8),
  145 + Container(
  146 + width: 78,
  147 + height: 2,
  148 + color: Colors.white.withValues(alpha: .8),
154 ), 149 ),
155 - const SizedBox(height: 6), 150 + const SizedBox(height: 14),
156 Row( 151 Row(
157 mainAxisAlignment: MainAxisAlignment.center, 152 mainAxisAlignment: MainAxisAlignment.center,
158 children: [ 153 children: [
159 - if (me)  
160 - Container(  
161 - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),  
162 - decoration:  
163 - const BoxDecoration(color: _purple, shape: BoxShape.circle),  
164 - child: const Text('我',  
165 - style: TextStyle(color: Colors.white, fontSize: 11)),  
166 - ),  
167 - if (me) const SizedBox(width: 5), 154 + _profileImage(avatarUrl, size: 36, radius: 18),
  155 + const SizedBox(width: 4),
168 Flexible( 156 Flexible(
169 - child: Text(  
170 - user?.nickname ?? '-', 157 + child: Text.rich(
  158 + TextSpan(
  159 + text: name,
  160 + style: const TextStyle(
  161 + color: Color(0xff0f0f11),
  162 + fontSize: 14,
  163 + ),
  164 + children: [
  165 + TextSpan(
  166 + text: '($role)',
  167 + style: const TextStyle(color: _muted),
  168 + ),
  169 + ],
  170 + ),
  171 + maxLines: 1,
171 overflow: TextOverflow.ellipsis, 172 overflow: TextOverflow.ellipsis,
172 - style:  
173 - const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),  
174 ), 173 ),
175 ), 174 ),
176 ], 175 ],
@@ -179,39 +178,35 @@ class InteractView extends GetView<InteractController> { @@ -179,39 +178,35 @@ class InteractView extends GetView<InteractController> {
179 ); 178 );
180 } 179 }
181 180
182 - Widget _unpairedPerson() {  
183 - return Column(  
184 - children: [  
185 - Image.asset('assets/images/interact/unpaired_avatar.png', width: 58),  
186 - const SizedBox(height: 8),  
187 - const Text('暂未绑定Feel搭子',  
188 - style: TextStyle(color: Color(0xffff2c20), fontSize: 12)),  
189 - const SizedBox(height: 10),  
190 - TextButton.icon(  
191 - onPressed: () => Get.snackbar('互动', '请先前往搭子页面完成绑定'),  
192 - icon: const Text('去绑定'),  
193 - label: const Icon(Icons.chevron_right, size: 18),  
194 - style: TextButton.styleFrom(  
195 - foregroundColor: Colors.white,  
196 - backgroundColor: _purple,  
197 - shape: const StadiumBorder(),  
198 - ),  
199 - ),  
200 - ],  
201 - );  
202 - }  
203 -  
204 Widget _actionButtons(BuildContext context) { 181 Widget _actionButtons(BuildContext context) {
205 - return Row(  
206 - mainAxisAlignment: MainAxisAlignment.center,  
207 - crossAxisAlignment: CrossAxisAlignment.end,  
208 - children: [  
209 - _actionButton(context, InteractAction.miss, width: 88, height: 52),  
210 - const SizedBox(width: 12),  
211 - _actionButton(context, InteractAction.stick, width: 112, height: 72),  
212 - const SizedBox(width: 12),  
213 - _actionButton(context, InteractAction.punch, width: 88, height: 52),  
214 - ], 182 + return SizedBox(
  183 + height: 112,
  184 + child: Row(
  185 + mainAxisAlignment: MainAxisAlignment.center,
  186 + crossAxisAlignment: CrossAxisAlignment.start,
  187 + children: [
  188 + _actionButton(
  189 + context,
  190 + InteractAction.miss,
  191 + width: 90,
  192 + elevated: false,
  193 + ),
  194 + const SizedBox(width: 12),
  195 + _actionButton(
  196 + context,
  197 + InteractAction.stick,
  198 + width: 116,
  199 + elevated: true,
  200 + ),
  201 + const SizedBox(width: 12),
  202 + _actionButton(
  203 + context,
  204 + InteractAction.punch,
  205 + width: 90,
  206 + elevated: false,
  207 + ),
  208 + ],
  209 + ),
215 ); 210 );
216 } 211 }
217 212
@@ -219,7 +214,7 @@ class InteractView extends GetView<InteractController> { @@ -219,7 +214,7 @@ class InteractView extends GetView<InteractController> {
219 BuildContext context, 214 BuildContext context,
220 InteractAction action, { 215 InteractAction action, {
221 required double width, 216 required double width,
222 - required double height, 217 + required bool elevated,
223 }) { 218 }) {
224 final enabled = 219 final enabled =
225 controller.isPaired && (!action.needsVip || controller.isVip); 220 controller.isPaired && (!action.needsVip || controller.isVip);
@@ -228,41 +223,59 @@ class InteractView extends GetView<InteractController> { @@ -228,41 +223,59 @@ class InteractView extends GetView<InteractController> {
228 onLongPress: () => _showActionSheet(context), 223 onLongPress: () => _showActionSheet(context),
229 child: SizedBox( 224 child: SizedBox(
230 width: width, 225 width: width,
231 - height: height + 18,  
232 - child: ElevatedButton(  
233 - onPressed: sending ? null : () => controller.sendAction(action),  
234 - style: ElevatedButton.styleFrom(  
235 - padding: EdgeInsets.zero,  
236 - backgroundColor: _purple.withValues(alpha: enabled ? 1 : .4),  
237 - disabledBackgroundColor: _purple.withValues(alpha: .4),  
238 - elevation: 5,  
239 - shadowColor: _purple.withValues(alpha: .5),  
240 - shape:  
241 - RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),  
242 - ),  
243 - child: sending  
244 - ? const SizedBox(  
245 - width: 22,  
246 - height: 22,  
247 - child: CircularProgressIndicator(  
248 - color: Colors.white, strokeWidth: 2))  
249 - : Column(  
250 - mainAxisAlignment: MainAxisAlignment.center,  
251 - children: [  
252 - Image.asset(  
253 - enabled  
254 - ? action.iconAsset  
255 - : 'assets/images/interact/icon_locked.png',  
256 - width: action == InteractAction.stick ? 48 : 40,  
257 - height: action == InteractAction.stick ? 48 : 40,  
258 - ),  
259 - Image.asset(  
260 - enabled ? action.titleAsset : action.lockedTitleAsset,  
261 - height: 20,  
262 - fit: BoxFit.contain, 226 + height: 112,
  227 + child: Stack(
  228 + clipBehavior: Clip.none,
  229 + alignment: Alignment.topCenter,
  230 + children: [
  231 + Positioned(
  232 + top: elevated ? 34 : 49,
  233 + child: Opacity(
  234 + opacity: enabled ? 1 : .4,
  235 + child: Material(
  236 + color: _purple,
  237 + borderRadius: BorderRadius.circular(elevated ? 24 : 16),
  238 + child: InkWell(
  239 + onTap: sending ? null : () => controller.sendAction(action),
  240 + borderRadius: BorderRadius.circular(elevated ? 24 : 16),
  241 + child: SizedBox(
  242 + width: width,
  243 + height: elevated ? 78 : 56,
  244 + child: Center(
  245 + child: sending
  246 + ? const SizedBox(
  247 + width: 22,
  248 + height: 22,
  249 + child: CircularProgressIndicator(
  250 + color: Colors.white,
  251 + strokeWidth: 2,
  252 + ),
  253 + )
  254 + : Text(
  255 + action.title,
  256 + style: TextStyle(
  257 + color: Colors.white,
  258 + fontSize: elevated ? 20 : 14,
  259 + fontWeight: FontWeight.w600,
  260 + ),
  261 + ),
  262 + ),
263 ), 263 ),
264 - ], 264 + ),
265 ), 265 ),
  266 + ),
  267 + ),
  268 + Positioned(
  269 + top: elevated ? 0 : 19,
  270 + child: _colorPlaceholder(
  271 + elevated ? 68 : 52,
  272 + color: enabled
  273 + ? const Color(0xffc5b0ff)
  274 + : const Color(0xffd5cfe3),
  275 + radius: elevated ? 22 : 18,
  276 + ),
  277 + ),
  278 + ],
266 ), 279 ),
267 ), 280 ),
268 ); 281 );
@@ -271,25 +284,25 @@ class InteractView extends GetView<InteractController> { @@ -271,25 +284,25 @@ class InteractView extends GetView<InteractController> {
271 Widget _records() { 284 Widget _records() {
272 final records = controller.records.take(10).toList(growable: false); 285 final records = controller.records.take(10).toList(growable: false);
273 return Container( 286 return Container(
274 - margin: const EdgeInsets.only(top: 2),  
275 - padding: const EdgeInsets.fromLTRB(24, 20, 24, 42),  
276 - decoration: const BoxDecoration(  
277 - gradient: LinearGradient(  
278 - colors: [Color(0xffeae1ff), Color(0x00fcf4ff)],  
279 - begin: Alignment.topCenter,  
280 - end: Alignment.bottomCenter,  
281 - ),  
282 - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), 287 + constraints: const BoxConstraints(minHeight: 208),
  288 + margin: const EdgeInsets.symmetric(horizontal: 16),
  289 + padding: const EdgeInsets.fromLTRB(16, 17, 16, 18),
  290 + decoration: BoxDecoration(
  291 + color: Colors.white.withValues(alpha: .76),
  292 + borderRadius: BorderRadius.circular(16),
283 ), 293 ),
284 child: Column( 294 child: Column(
285 children: [ 295 children: [
286 Row( 296 Row(
287 children: [ 297 children: [
288 - const Text('互动记录',  
289 - style: TextStyle(  
290 - fontSize: 17,  
291 - fontWeight: FontWeight.w700,  
292 - color: Color(0xff2c2020))), 298 + const Text(
  299 + '互动记录',
  300 + style: TextStyle(
  301 + fontSize: 16,
  302 + fontWeight: FontWeight.w600,
  303 + color: Color(0xff0f0f11),
  304 + ),
  305 + ),
293 const Spacer(), 306 const Spacer(),
294 Text('仅保留10条', style: TextStyle(fontSize: 12, color: _muted)), 307 Text('仅保留10条', style: TextStyle(fontSize: 12, color: _muted)),
295 ], 308 ],
@@ -297,12 +310,16 @@ class InteractView extends GetView<InteractController> { @@ -297,12 +310,16 @@ class InteractView extends GetView<InteractController> {
297 const SizedBox(height: 15), 310 const SizedBox(height: 15),
298 if (controller.isLoading.value && records.isEmpty) 311 if (controller.isLoading.value && records.isEmpty)
299 const Padding( 312 const Padding(
300 - padding: EdgeInsets.all(40), child: CircularProgressIndicator()) 313 + padding: EdgeInsets.all(40),
  314 + child: CircularProgressIndicator(),
  315 + )
301 else if (records.isEmpty) 316 else if (records.isEmpty)
302 const Padding( 317 const Padding(
303 padding: EdgeInsets.symmetric(vertical: 76), 318 padding: EdgeInsets.symmetric(vertical: 76),
304 - child: Text('暂时还没有记录哦~',  
305 - style: TextStyle(color: _muted, fontSize: 14)), 319 + child: Text(
  320 + '暂无互动记录',
  321 + style: TextStyle(color: _muted, fontSize: 14),
  322 + ),
306 ) 323 )
307 else 324 else
308 ...records.map(_recordItem), 325 ...records.map(_recordItem),
@@ -312,47 +329,99 @@ class InteractView extends GetView<InteractController> { @@ -312,47 +329,99 @@ class InteractView extends GetView<InteractController> {
312 } 329 }
313 330
314 Widget _recordItem(InteractionRecord record) { 331 Widget _recordItem(InteractionRecord record) {
315 - return Container(  
316 - width: double.infinity,  
317 - margin: const EdgeInsets.only(bottom: 7),  
318 - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),  
319 - decoration: BoxDecoration(  
320 - color: Colors.white.withValues(alpha: .48),  
321 - borderRadius: BorderRadius.circular(7)), 332 + return Padding(
  333 + padding: const EdgeInsets.only(bottom: 13),
322 child: Row( 334 child: Row(
  335 + crossAxisAlignment: CrossAxisAlignment.start,
323 children: [ 336 children: [
324 - const Icon(Icons.circle_outlined, size: 10, color: _purple), 337 + Container(
  338 + width: 8,
  339 + height: 8,
  340 + margin: const EdgeInsets.only(top: 5),
  341 + decoration: const BoxDecoration(
  342 + color: _brandText,
  343 + shape: BoxShape.circle,
  344 + ),
  345 + ),
325 const SizedBox(width: 8), 346 const SizedBox(width: 8),
326 Expanded( 347 Expanded(
327 - child: Text(controller.recordText(record),  
328 - style: const TextStyle(fontSize: 14))),  
329 - Text(controller.recordTime(record),  
330 - style: const TextStyle(fontSize: 12, color: _muted)), 348 + child: Column(
  349 + crossAxisAlignment: CrossAxisAlignment.start,
  350 + children: [
  351 + Text(
  352 + controller.recordText(record),
  353 + style: const TextStyle(
  354 + fontSize: 14,
  355 + color: Color(0xff0f0f11),
  356 + ),
  357 + ),
  358 + const SizedBox(height: 3),
  359 + Text(
  360 + controller.recordTime(record),
  361 + style: const TextStyle(
  362 + fontSize: 12,
  363 + color: Color(0xffb0b0b6),
  364 + ),
  365 + ),
  366 + ],
  367 + ),
  368 + ),
331 ], 369 ],
332 ), 370 ),
333 ); 371 );
334 } 372 }
335 373
336 - Widget _avatar(UserInfoResponse? user,  
337 - {required double size, Color? borderColor}) {  
338 - final avatar = user?.avatar; 374 + Widget _colorPlaceholder(
  375 + double size, {
  376 + required Color color,
  377 + required double radius,
  378 + }) {
339 return Container( 379 return Container(
340 width: size, 380 width: size,
341 height: size, 381 height: size,
342 - clipBehavior: Clip.antiAlias,  
343 decoration: BoxDecoration( 382 decoration: BoxDecoration(
344 - shape: BoxShape.circle,  
345 - border: Border.all(color: borderColor ?? _purple, width: 2),  
346 - color: Colors.white),  
347 - child: avatar == null || avatar.isEmpty  
348 - ? Icon(Icons.person, size: size * .55, color: _muted)  
349 - : Image.network(avatar,  
350 - fit: BoxFit.cover,  
351 - errorBuilder: (_, __, ___) =>  
352 - Icon(Icons.person, size: size * .55, color: _muted)), 383 + color: color,
  384 + borderRadius: BorderRadius.circular(radius),
  385 + ),
353 ); 386 );
354 } 387 }
355 388
  389 + Widget _profileImage(
  390 + String? avatarUrl, {
  391 + required double size,
  392 + required double radius,
  393 + }) {
  394 + if (avatarUrl == null || avatarUrl.trim().isEmpty) {
  395 + return _colorPlaceholder(size, color: _placeholder, radius: radius);
  396 + }
  397 + return ClipRRect(
  398 + borderRadius: BorderRadius.circular(radius),
  399 + child: Image.network(
  400 + avatarUrl,
  401 + width: size,
  402 + height: size,
  403 + fit: BoxFit.cover,
  404 + errorBuilder: (_, __, ___) =>
  405 + _colorPlaceholder(size, color: _placeholder, radius: radius),
  406 + ),
  407 + );
  408 + }
  409 +
  410 + Future<void> _changeFriend() async {
  411 + final selectedFriend = await Get.bottomSheet<FriendHealthData>(
  412 + SizedBox(height: Get.height * 0.9, child: const SelectFriendView()),
  413 + ignoreSafeArea: false,
  414 + isScrollControlled: true,
  415 + backgroundColor: Colors.transparent,
  416 + barrierColor: const Color(0xB3000000),
  417 + );
  418 + if (selectedFriend == null) return;
  419 + if (selectedFriend.userId == controller.selectedFriend.value?.userId) {
  420 + return;
  421 + }
  422 + await controller.selectFriend(selectedFriend);
  423 + }
  424 +
356 void _showActionSheet(BuildContext context) { 425 void _showActionSheet(BuildContext context) {
357 Get.bottomSheet( 426 Get.bottomSheet(
358 InteractActionBottomSheet(controller: controller), 427 InteractActionBottomSheet(controller: controller),
@@ -362,6 +431,111 @@ class InteractView extends GetView<InteractController> { @@ -362,6 +431,111 @@ class InteractView extends GetView<InteractController> {
362 } 431 }
363 } 432 }
364 433
  434 +/// A deliberately sparse, looping barrage. Each message begins offscreen on
  435 +/// the right and re-enters after it has fully left the left edge.
  436 +class _FloatingInteractionBubbles extends StatefulWidget {
  437 + const _FloatingInteractionBubbles({
  438 + required this.records,
  439 + required this.recordText,
  440 + });
  441 +
  442 + final List<InteractionRecord> records;
  443 + final String Function(InteractionRecord record) recordText;
  444 +
  445 + @override
  446 + State<_FloatingInteractionBubbles> createState() =>
  447 + _FloatingInteractionBubblesState();
  448 +}
  449 +
  450 +class _FloatingInteractionBubblesState
  451 + extends State<_FloatingInteractionBubbles>
  452 + with SingleTickerProviderStateMixin {
  453 + late final AnimationController _animationController = AnimationController(
  454 + vsync: this,
  455 + duration: const Duration(seconds: 11),
  456 + )..repeat();
  457 +
  458 + @override
  459 + void dispose() {
  460 + _animationController.dispose();
  461 + super.dispose();
  462 + }
  463 +
  464 + @override
  465 + Widget build(BuildContext context) {
  466 + // The staggered vertical tracks and phases prevent this from looking like
  467 + // a single marquee while still keeping it a calm, readable barrage.
  468 + const tracks = <double>[12, 53, 74];
  469 + const phases = <double>[.06, .47, .79];
  470 + return LayoutBuilder(
  471 + builder: (context, constraints) => AnimatedBuilder(
  472 + animation: _animationController,
  473 + builder: (context, _) => Stack(
  474 + clipBehavior: Clip.hardEdge,
  475 + children: List.generate(widget.records.length, (index) {
  476 + final progress = (_animationController.value + phases[index]) % 1;
  477 + // 170 covers the maximum bubble width and its exit gap.
  478 + final left =
  479 + constraints.maxWidth - progress * (constraints.maxWidth + 170);
  480 + return Positioned(
  481 + top: tracks[index],
  482 + left: left,
  483 + child: _FloatingInteractionBubble(
  484 + record: widget.records[index],
  485 + recordText: widget.recordText,
  486 + ),
  487 + );
  488 + }),
  489 + ),
  490 + ),
  491 + );
  492 + }
  493 +}
  494 +
  495 +class _FloatingInteractionBubble extends StatelessWidget {
  496 + const _FloatingInteractionBubble({
  497 + required this.record,
  498 + required this.recordText,
  499 + });
  500 +
  501 + final InteractionRecord record;
  502 + final String Function(InteractionRecord record) recordText;
  503 +
  504 + @override
  505 + Widget build(BuildContext context) {
  506 + return Container(
  507 + constraints: const BoxConstraints(maxWidth: 142),
  508 + padding: const EdgeInsets.fromLTRB(6, 5, 10, 5),
  509 + decoration: BoxDecoration(
  510 + color: const Color(0xffa084ef),
  511 + borderRadius: BorderRadius.circular(16),
  512 + ),
  513 + child: Row(
  514 + mainAxisSize: MainAxisSize.min,
  515 + children: [
  516 + Container(
  517 + width: 28,
  518 + height: 28,
  519 + decoration: BoxDecoration(
  520 + color: const Color(0xffc5b0ff),
  521 + borderRadius: BorderRadius.circular(9),
  522 + ),
  523 + ),
  524 + const SizedBox(width: 5),
  525 + Flexible(
  526 + child: Text(
  527 + recordText(record),
  528 + maxLines: 1,
  529 + overflow: TextOverflow.ellipsis,
  530 + style: const TextStyle(color: Colors.white, fontSize: 12),
  531 + ),
  532 + ),
  533 + ],
  534 + ),
  535 + );
  536 + }
  537 +}
  538 +
365 class InteractActionBottomSheet extends StatelessWidget { 539 class InteractActionBottomSheet extends StatelessWidget {
366 const InteractActionBottomSheet({required this.controller, super.key}); 540 const InteractActionBottomSheet({required this.controller, super.key});
367 541
@@ -375,41 +549,49 @@ class InteractActionBottomSheet extends StatelessWidget { @@ -375,41 +549,49 @@ class InteractActionBottomSheet extends StatelessWidget {
375 height: 331, 549 height: 331,
376 decoration: const BoxDecoration( 550 decoration: const BoxDecoration(
377 image: DecorationImage( 551 image: DecorationImage(
378 - image: AssetImage('assets/images/interact/bottom_sheet_bg.png'),  
379 - fit: BoxFit.fill), 552 + image: AssetImage('assets/images/interact/bottom_sheet_bg.png'),
  553 + fit: BoxFit.fill,
  554 + ),
380 ), 555 ),
381 child: Stack( 556 child: Stack(
382 children: [ 557 children: [
383 Align( 558 Align(
384 alignment: const Alignment(.2, -.98), 559 alignment: const Alignment(.2, -.98),
385 child: Image.asset( 560 child: Image.asset(
386 - 'assets/images/interact/bottom_sheet_label.png',  
387 - width: 126), 561 + 'assets/images/interact/bottom_sheet_label.png',
  562 + width: 126,
  563 + ),
388 ), 564 ),
389 Column( 565 Column(
390 children: [ 566 children: [
391 const SizedBox(height: 18), 567 const SizedBox(height: 18),
392 _sheetAvatar(), 568 _sheetAvatar(),
393 const SizedBox(height: 13), 569 const SizedBox(height: 13),
394 - const Text('给搭子送去一份互动',  
395 - style: TextStyle(color: Color(0xff908b91), fontSize: 14)), 570 + const Text(
  571 + '给搭子送去一份互动',
  572 + style: TextStyle(color: Color(0xff908b91), fontSize: 14),
  573 + ),
396 const SizedBox(height: 3), 574 const SizedBox(height: 3),
397 - const Text('想念要说出来',  
398 - style: TextStyle(  
399 - color: Color(0xff896cdc),  
400 - fontSize: 24,  
401 - fontWeight: FontWeight.w700)), 575 + const Text(
  576 + '想念要说出来',
  577 + style: TextStyle(
  578 + color: Color(0xff896cdc),
  579 + fontSize: 24,
  580 + fontWeight: FontWeight.w700,
  581 + ),
  582 + ),
402 const SizedBox(height: 24), 583 const SizedBox(height: 24),
403 Obx( 584 Obx(
404 () => Row( 585 () => Row(
405 mainAxisAlignment: MainAxisAlignment.center, 586 mainAxisAlignment: MainAxisAlignment.center,
406 crossAxisAlignment: CrossAxisAlignment.end, 587 crossAxisAlignment: CrossAxisAlignment.end,
407 children: InteractAction.values 588 children: InteractAction.values
408 - .map((action) => Padding(  
409 - padding:  
410 - const EdgeInsets.symmetric(horizontal: 6),  
411 - child: _sheetButton(action),  
412 - )) 589 + .map(
  590 + (action) => Padding(
  591 + padding: const EdgeInsets.symmetric(horizontal: 6),
  592 + child: _sheetButton(action),
  593 + ),
  594 + )
413 .toList(growable: false), 595 .toList(growable: false),
414 ), 596 ),
415 ), 597 ),
@@ -428,8 +610,9 @@ class InteractActionBottomSheet extends StatelessWidget { @@ -428,8 +610,9 @@ class InteractActionBottomSheet extends StatelessWidget {
428 height: 56, 610 height: 56,
429 clipBehavior: Clip.antiAlias, 611 clipBehavior: Clip.antiAlias,
430 decoration: BoxDecoration( 612 decoration: BoxDecoration(
431 - shape: BoxShape.circle,  
432 - border: Border.all(color: const Color(0xff896cdc), width: 2)), 613 + shape: BoxShape.circle,
  614 + border: Border.all(color: const Color(0xff896cdc), width: 2),
  615 + ),
433 child: avatar == null || avatar.isEmpty 616 child: avatar == null || avatar.isEmpty
434 ? const Icon(Icons.person, color: Color(0xff908b91)) 617 ? const Icon(Icons.person, color: Color(0xff908b91))
435 : Image.network(avatar, fit: BoxFit.cover), 618 : Image.network(avatar, fit: BoxFit.cover),
@@ -455,25 +638,31 @@ class InteractActionBottomSheet extends StatelessWidget { @@ -455,25 +638,31 @@ class InteractActionBottomSheet extends StatelessWidget {
455 }, 638 },
456 style: ElevatedButton.styleFrom( 639 style: ElevatedButton.styleFrom(
457 padding: EdgeInsets.zero, 640 padding: EdgeInsets.zero,
458 - backgroundColor:  
459 - const Color(0xff896cdc).withValues(alpha: enabled ? 1 : .4),  
460 - shape:  
461 - RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), 641 + backgroundColor: const Color(
  642 + 0xff896cdc,
  643 + ).withValues(alpha: enabled ? 1 : .4),
  644 + shape: RoundedRectangleBorder(
  645 + borderRadius: BorderRadius.circular(28),
  646 + ),
462 ), 647 ),
463 child: sending 648 child: sending
464 ? const CircularProgressIndicator( 649 ? const CircularProgressIndicator(
465 - color: Colors.white, strokeWidth: 2) 650 + color: Colors.white,
  651 + strokeWidth: 2,
  652 + )
466 : Column( 653 : Column(
467 mainAxisAlignment: MainAxisAlignment.center, 654 mainAxisAlignment: MainAxisAlignment.center,
468 children: [ 655 children: [
469 Image.asset( 656 Image.asset(
470 - enabled  
471 - ? action.iconAsset  
472 - : 'assets/images/interact/icon_locked.png',  
473 - height: 38), 657 + enabled
  658 + ? action.iconAsset
  659 + : 'assets/images/interact/icon_locked.png',
  660 + height: 38,
  661 + ),
474 Image.asset( 662 Image.asset(
475 - enabled ? action.titleAsset : action.lockedTitleAsset,  
476 - height: 18), 663 + enabled ? action.titleAsset : action.lockedTitleAsset,
  664 + height: 18,
  665 + ),
477 ], 666 ],
478 ), 667 ),
479 ), 668 ),
1 import 'package:doublefeel_flutter/app/routes/app_pages.dart'; 1 import 'package:doublefeel_flutter/app/routes/app_pages.dart';
  2 +import 'package:doublefeel_flutter/app/modules/interact/models/interact_route_arguments.dart';
2 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
3 import 'package:get/get.dart'; 4 import 'package:get/get.dart';
4 5
@@ -6,7 +7,8 @@ class FriendInteractionButton extends StatelessWidget { @@ -6,7 +7,8 @@ class FriendInteractionButton extends StatelessWidget {
6 const FriendInteractionButton({ 7 const FriendInteractionButton({
7 super.key, 8 super.key,
8 required this.enabled, 9 required this.enabled,
9 - this.size = 24, 10 + this.arguments,
  11 + this.size = 24,
10 this.padding = const EdgeInsets.only( 12 this.padding = const EdgeInsets.only(
11 left: 20, 13 left: 20,
12 right: 10, 14 right: 10,
@@ -16,6 +18,7 @@ class FriendInteractionButton extends StatelessWidget { @@ -16,6 +18,7 @@ class FriendInteractionButton extends StatelessWidget {
16 }); 18 });
17 19
18 final bool enabled; 20 final bool enabled;
  21 + final InteractRouteArguments? arguments;
19 final double size; 22 final double size;
20 final EdgeInsets padding; 23 final EdgeInsets padding;
21 24
@@ -36,18 +39,23 @@ class FriendInteractionButton extends StatelessWidget { @@ -36,18 +39,23 @@ class FriendInteractionButton extends StatelessWidget {
36 ), 39 ),
37 ); 40 );
38 41
39 - void _openInteraction() => Get.toNamed(Routes.INTERACT); 42 + void _openInteraction() => Get.toNamed(Routes.INTERACT, arguments: arguments);
40 } 43 }
41 44
42 class FriendInteractionFloatingEntry extends StatelessWidget { 45 class FriendInteractionFloatingEntry extends StatelessWidget {
43 - const FriendInteractionFloatingEntry({super.key, this.size = 72}); 46 + const FriendInteractionFloatingEntry({
  47 + super.key,
  48 + this.size = 72,
  49 + this.arguments,
  50 + });
44 51
45 final double size; 52 final double size;
  53 + final InteractRouteArguments? arguments;
46 54
47 @override 55 @override
48 Widget build(BuildContext context) => GestureDetector( 56 Widget build(BuildContext context) => GestureDetector(
49 behavior: HitTestBehavior.opaque, 57 behavior: HitTestBehavior.opaque,
50 - onTap: () => Get.toNamed(Routes.INTERACT), 58 + onTap: () => Get.toNamed(Routes.INTERACT, arguments: arguments),
51 child: Image.asset( 59 child: Image.asset(
52 'assets/images/interact/interaction_float.png', 60 'assets/images/interact/interaction_float.png',
53 width: size, 61 width: size,
@@ -9,10 +9,15 @@ class InteractionApi { @@ -9,10 +9,15 @@ class InteractionApi {
9 9
10 final DioClient _dioClient; 10 final DioClient _dioClient;
11 11
12 - Future<AppResult<InteractionRecordResponse>> getInteractionRecordList() { 12 + Future<AppResult<InteractionRecordResponse>> getInteractionRecordList(
  13 + int friendUserId,
  14 + ) {
13 return safeCall( 15 return safeCall(
14 call: () async { 16 call: () async {
15 - final response = await _dioClient.dio.get(ApiPaths.interactionRecords); 17 + final response = await _dioClient.dio.get(
  18 + ApiPaths.interactionRecords,
  19 + queryParameters: {'friend_user_id': friendUserId},
  20 + );
16 return InteractionRecordResponse.fromJson( 21 return InteractionRecordResponse.fromJson(
17 response.data as Map<String, dynamic>, 22 response.data as Map<String, dynamic>,
18 ); 23 );
@@ -67,8 +67,9 @@ abstract final class ApiPaths { @@ -67,8 +67,9 @@ abstract final class ApiPaths {
67 '/client/doublefeel/payment/subscriptions/unsubscribe/'; 67 '/client/doublefeel/payment/subscriptions/unsubscribe/';
68 68
69 // Interaction 69 // Interaction
70 - static const interactionRecords = '/client/doublefeel/interaction/records/';  
71 - static const interactionAction = '/client/doublefeel/interaction/action/'; 70 + static const interactionRecords =
  71 + '/client/doublefeel/interaction/v2/records/';
  72 + static const interactionAction = '/client/doublefeel/interaction/v2/action/';
72 73
73 // Config 74 // Config
74 static const dynamicConfigList = '/client/doublefeel/dynamic_config/list/'; 75 static const dynamicConfigList = '/client/doublefeel/dynamic_config/list/';
1 class InteractionData { 1 class InteractionData {
2 const InteractionData({ 2 const InteractionData({
3 - this.interactionType,  
4 - this.actionType, 3 + required this.friendUserId,
  4 + required this.interactionType,
  5 + required this.actionType,
5 this.action, 6 this.action,
6 }); 7 });
7 8
8 - final int? interactionType;  
9 - final int? actionType; 9 + /// Target of every V2 action and records request.
  10 + final int friendUserId;
  11 + final int interactionType;
  12 + final int actionType;
10 final InteractionDataAction? action; 13 final InteractionDataAction? action;
11 14
12 factory InteractionData.fromJson(Map<String, dynamic> json) { 15 factory InteractionData.fromJson(Map<String, dynamic> json) {
13 return InteractionData( 16 return InteractionData(
14 - interactionType: json['interaction_type'] as int?,  
15 - actionType: json['action_type'] as int?, 17 + friendUserId: json['friend_user_id'] as int,
  18 + interactionType: json['interaction_type'] as int,
  19 + actionType: json['action_type'] as int,
16 action: json['action_variables'] == null 20 action: json['action_variables'] == null
17 ? null 21 ? null
18 : InteractionDataAction.fromJson( 22 : InteractionDataAction.fromJson(
19 - json['action_variables'] as Map<String, dynamic>), 23 + json['action_variables'] as Map<String, dynamic>,
  24 + ),
20 ); 25 );
21 } 26 }
22 27
23 Map<String, dynamic> toJson() { 28 Map<String, dynamic> toJson() {
24 - final val = <String, dynamic>{};  
25 - if (interactionType != null) val['interaction_type'] = interactionType;  
26 - if (actionType != null) val['action_type'] = actionType; 29 + final val = <String, dynamic>{
  30 + 'friend_user_id': friendUserId,
  31 + 'interaction_type': interactionType,
  32 + 'action_type': actionType,
  33 + };
27 if (action != null) val['action_variables'] = action!.toJson(); 34 if (action != null) val['action_variables'] = action!.toJson();
28 return val; 35 return val;
29 } 36 }
@@ -83,7 +90,8 @@ class InteractionRecord { @@ -83,7 +90,8 @@ class InteractionRecord {
83 action: json['action_variables'] == null 90 action: json['action_variables'] == null
84 ? null 91 ? null
85 : InteractionDataAction.fromJson( 92 : InteractionDataAction.fromJson(
86 - json['action_variables'] as Map<String, dynamic>), 93 + json['action_variables'] as Map<String, dynamic>,
  94 + ),
87 text: json['text'] as String?, 95 text: json['text'] as String?,
88 ); 96 );
89 } 97 }