Showing
13 changed files
with
1043 additions
and
355 deletions
| 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 | } |
-
Please register or login to post a comment