Commit 26c97b6e080f42e60f52e1ca23d4cc62eeaf62a9

Authored by 刘宏哲
1 parent f9956c3e

feat(app): update ui

... ... @@ -9,6 +9,8 @@ import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/services/thinking_data_service.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:get/get.dart';
... ... @@ -21,7 +23,11 @@ class FriendsController extends GetxController {
FriendsRepository? repository,
Future<void> Function(WatchAppOtherInfo info)? updateWatchOtherUserInfo,
}) : _repository =
repository ?? FriendsRepositoryImpl(Get.find<FriendApi>()),
repository ??
FriendsRepositoryImpl(
Get.find<FriendApi>(),
Get.find<UserPreferencesStorage>(),
),
_updateWatchOtherUserInfo = updateWatchOtherUserInfo ??
((info) => PlatformHostApi().refreshWatchAppAndWidgets());
... ... @@ -34,12 +40,38 @@ class FriendsController extends GetxController {
final selfHealthData = Rxn<SelfFriendHealthData>();
Future<void>? _friendsRequest;
bool _isPageVisible = false;
late final Worker _preferencesWorker;
bool _showRealtimeStress = false;
bool get isFull => friends.length >= maxFriends;
@override
void onInit() {
super.onInit();
_showRealtimeStress = _currentShowRealtimeStress;
_preferencesWorker = ever<UserPreferences>(
Get.find<UserPreferencesStorage>().preferences,
_onPreferencesChanged,
);
unawaited(refreshData());
}
@override
void onClose() {
_preferencesWorker.dispose();
super.onClose();
}
bool get _currentShowRealtimeStress {
final preferences = Get.find<UserPreferencesStorage>().preferences.value;
return preferences.vipInfo?.isVip == true &&
(preferences.showRealtimeStress ?? true);
}
void _onPreferencesChanged(UserPreferences _) {
final showRealtimeStress = _currentShowRealtimeStress;
if (showRealtimeStress == _showRealtimeStress) return;
_showRealtimeStress = showRealtimeStress;
unawaited(refreshData());
}
... ...
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'
as api_models;
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../models/friend_health_data.dart';
... ... @@ -34,9 +36,10 @@ class FriendListData {
}
class FriendsRepositoryImpl implements FriendsRepository {
const FriendsRepositoryImpl(this._friendApi);
const FriendsRepositoryImpl(this._friendApi, [this._userPreferencesStorage]);
final FriendApi _friendApi;
final UserPreferencesStorage? _userPreferencesStorage;
@override
Future<List<FriendHealthData>> getFriends(
... ... @@ -96,6 +99,7 @@ class FriendsRepositoryImpl implements FriendsRepository {
final healthData = friend.healthData;
final nickname = friend.friendNickname?.trim();
final remark = friend.remarkName?.trim();
final stressData = _stressData(healthData);
return FriendHealthData(
friendItem: friend,
... ... @@ -105,13 +109,13 @@ class FriendsRepositoryImpl implements FriendsRepository {
? l10n.friendsUnknownFriend
: nickname,
remark: remark == null || remark.isEmpty ? null : remark,
updatedAt: _updatedAt(healthData?.lastDataTime ?? friend.updateTime),
updatedAt: _updatedAt(stressData.updatedAt),
sleepQualityScore: healthData?.sleepEvaluate,
steps: healthData?.totalSteps == null
? null
: l10n.friendsStepCount(healthData!.totalSteps!),
stressState:
FriendStressState.fromValue(healthData?.comprehensiveStressState),
stressState: stressData.state,
stressValueText: stressData.valueText,
isOnWatchFace: friend.isShowInDial,
);
}
... ... @@ -120,22 +124,85 @@ class FriendsRepositoryImpl implements FriendsRepository {
api_models.FriendHealthData? healthData,
) {
if (healthData == null) return null;
final stressData = _stressData(healthData);
return SelfFriendHealthData(
updatedAt: _updatedAt(healthData.lastDataTime),
updatedAt: _updatedAt(stressData.updatedAt),
sleepQualityScore: healthData.sleepEvaluate,
steps: healthData.totalSteps == null
? null
: l10n.friendsStepCount(healthData.totalSteps!),
stressState:
FriendStressState.fromValue(healthData.comprehensiveStressState),
stressState: stressData.state,
stressValueText: stressData.valueText,
);
}
_FriendStressData _stressData(api_models.FriendHealthData? healthData) {
if (healthData == null) return const _FriendStressData();
if (_showRealtimeStress) {
final realtime = _latestRealtimeStress(healthData.realtimeStress);
return _FriendStressData(
state: FriendStressState.fromValue(realtime?.state),
valueText: _valueText(realtime?.value, '%'),
updatedAt: realtime?.time,
);
}
return _FriendStressData(
state: FriendStressState.fromValue(healthData.hrvState),
valueText: _valueText(healthData.latestHrv, 'ms'),
updatedAt: healthData.lastDataTime,
);
}
bool get _showRealtimeStress {
final preferences =
(_userPreferencesStorage ?? Get.find<UserPreferencesStorage>())
.preferences
.value;
return preferences.vipInfo?.isVip == true &&
(preferences.showRealtimeStress ?? true);
}
api_models.FriendRealtimeStressItem? _latestRealtimeStress(
List<api_models.FriendRealtimeStressItem>? samples,
) {
if (samples == null || samples.isEmpty) return null;
return samples.reduce(
(latest, sample) => (sample.time ?? -1) > (latest.time ?? -1)
? sample
: latest,
);
}
String? _valueText(double? value, String unit) {
if (value == null) return null;
final text = value == value.truncateToDouble()
? value.toInt().toString()
: value.toString();
return '$text$unit';
}
String _updatedAt(num? timestamp) {
if (timestamp == null) return l10n.friendsWaitingForData;
final milliseconds =
timestamp < 1000000000000 ? timestamp * 1000 : timestamp;
final time = DateTime.fromMillisecondsSinceEpoch(milliseconds.toInt());
return l10n.friendsUpdatedAt(DateFormat('HH:mm').format(time));
final formattedTime = DateFormat('HH:mm').format(time);
return _showRealtimeStress
? l10n.friendsRealtimeStressUpdatedAt(formattedTime)
: l10n.friendsHrvUpdatedAt(formattedTime);
}
}
class _FriendStressData {
const _FriendStressData({
this.state = FriendStressState.wait,
this.valueText,
this.updatedAt,
});
final FriendStressState state;
final String? valueText;
final num? updatedAt;
}
... ...
... ... @@ -15,6 +15,7 @@ class FriendHealthData {
required this.sleepQualityScore,
required this.steps,
required this.stressState,
this.stressValueText,
this.isOnWatchFace = false,
});
... ... @@ -27,6 +28,7 @@ class FriendHealthData {
final int? sleepQualityScore;
final String? steps;
final FriendStressState stressState;
final String? stressValueText;
final bool isOnWatchFace;
String get displayName {
... ... @@ -45,6 +47,7 @@ class FriendHealthData {
int? sleepQualityScore,
String? steps,
FriendStressState? stressState,
String? stressValueText,
bool? isOnWatchFace,
}) {
return FriendHealthData(
... ... @@ -57,6 +60,7 @@ class FriendHealthData {
sleepQualityScore: sleepQualityScore ?? this.sleepQualityScore,
steps: steps ?? this.steps,
stressState: stressState ?? this.stressState,
stressValueText: stressValueText ?? this.stressValueText,
isOnWatchFace: isOnWatchFace ?? this.isOnWatchFace,
);
}
... ... @@ -68,10 +72,12 @@ class SelfFriendHealthData {
required this.sleepQualityScore,
required this.steps,
required this.stressState,
this.stressValueText,
});
final String updatedAt;
final int? sleepQualityScore;
final String? steps;
final FriendStressState stressState;
final String? stressValueText;
}
... ...
... ... @@ -55,6 +55,7 @@ class FriendsTab extends GetView<FriendsController> {
steps: healthData?.steps,
stressState:
healthData?.stressState ?? FriendStressState.wait,
stressValueText: healthData?.stressValueText,
isLoading: isSelfInitialLoading,
);
... ... @@ -166,6 +167,7 @@ class FriendsTab extends GetView<FriendsController> {
sleepQualityScore: friend.sleepQualityScore,
steps: friend.steps,
stressState: friend.stressState,
stressValueText: friend.stressValueText,
isOnWatchFace: friend.isOnWatchFace,
onTap: () => _openFriendHome(friend),
onMoreSelected: (action) {
... ... @@ -260,6 +262,7 @@ class _SelfHealthCard extends StatelessWidget {
required this.sleepQualityScore,
required this.steps,
required this.stressState,
required this.stressValueText,
required this.isLoading,
});
... ... @@ -269,6 +272,7 @@ class _SelfHealthCard extends StatelessWidget {
final int? sleepQualityScore;
final String? steps;
final FriendStressState stressState;
final String? stressValueText;
final bool isLoading;
@override
... ... @@ -283,6 +287,7 @@ class _SelfHealthCard extends StatelessWidget {
sleepQualityScore: sleepQualityScore,
steps: steps,
stressState: stressState,
stressValueText: stressValueText,
isSelf: true,
borderRadius: borderRadius,
contentPadding: const EdgeInsets.fromLTRB(36, 20, 35, 20),
... ...
... ... @@ -22,6 +22,7 @@ class FriendHealthCard extends StatelessWidget {
required this.sleepQualityScore,
required this.steps,
required this.stressState,
this.stressValueText,
this.isSelf = false,
this.isOnWatchFace = false,
this.isSelected = false,
... ... @@ -39,6 +40,7 @@ class FriendHealthCard extends StatelessWidget {
final int? sleepQualityScore;
final String? steps;
final FriendStressState stressState;
final String? stressValueText;
final bool isSelf;
final bool isOnWatchFace;
final bool isSelected;
... ... @@ -92,6 +94,7 @@ class FriendHealthCard extends StatelessWidget {
Expanded(
child: _StatusFigure(
stressState: stressState,
stressValueText: stressValueText,
),
),
const SizedBox(width: 18),
... ... @@ -332,9 +335,11 @@ class _AvatarFallback extends StatelessWidget {
class _StatusFigure extends StatelessWidget {
const _StatusFigure({
required this.stressState,
this.stressValueText,
});
final FriendStressState stressState;
final String? stressValueText;
@override
Widget build(BuildContext context) {
... ... @@ -371,7 +376,9 @@ class _StatusFigure extends StatelessWidget {
),
const SizedBox(height: 3),
Text(
stressState.label,
stressState.hasData && stressValueText != null
? '${stressState.label}·$stressValueText'
: stressState.label,
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 14,
... ...
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/constants/intent_keys.dart';
import 'package:doublefeel_flutter/core/network/api/user_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
import 'package:get/get.dart';
... ... @@ -12,15 +15,34 @@ class PrivacySettingsController extends GetxController {
final enableAddWithUCode = false.obs;
final isUpdatingEnableAddWithUCode = false.obs;
final isVip = false.obs;
final showRealtimeStress = false.obs;
late final Worker _preferencesWorker;
@override
void onInit() {
enableAddWithUCode.value = _userPreferencesStorage
.preferences.value.meUserInfo?.enableAddWithUCode ==
1;
_syncFromPreferences(_userPreferencesStorage.preferences.value);
_preferencesWorker = ever<UserPreferences>(
_userPreferencesStorage.preferences,
_syncFromPreferences,
);
super.onInit();
}
@override
void onClose() {
_preferencesWorker.dispose();
super.onClose();
}
void _syncFromPreferences(UserPreferences preferences) {
enableAddWithUCode.value =
preferences.meUserInfo?.enableAddWithUCode == 1;
isVip.value = preferences.vipInfo?.isVip == true;
showRealtimeStress.value =
isVip.value && (preferences.showRealtimeStress ?? true);
}
Future<void> updateEnableAddWithUCode(bool enabled) async {
if (isUpdatingEnableAddWithUCode.value) return;
... ... @@ -40,6 +62,20 @@ class PrivacySettingsController extends GetxController {
}
}
Future<void> updateShowRealtimeStress(bool enabled) async {
final isVip = _userPreferencesStorage.preferences.value.vipInfo?.isVip ==
true;
if (!isVip && enabled) {
await Get.toNamed(Routes.PURCHASE, arguments: {
IntentKeys.channelType: '隐私设置-显示实时压力',
});
_syncFromPreferences(_userPreferencesStorage.preferences.value);
return;
}
await _userPreferencesStorage.updateShowRealtimeStress(enabled);
}
void executeBackLogic() {
Get.back();
}
... ...
... ... @@ -55,15 +55,52 @@ class PrivacySettingsView extends GetView<PrivacySettingsController> {
children: [
SizedBox(height: 16.dp),
noAddByIdView(context),
SizedBox(height: 12.dp),
showRealtimeStressView(context),
],
),
);
}
Widget noAddByIdView(BuildContext context) {
return _settingRow(
title: context.l10n.privacySettingsDisableAddById,
value: controller.enableAddWithUCode,
onChanged: () => controller.isUpdatingEnableAddWithUCode.value
? null
: controller.updateEnableAddWithUCode,
);
}
Widget showRealtimeStressView(BuildContext context) {
return _settingRow(
title: context.l10n.privacySettingsShowRealtimeStress,
value: controller.showRealtimeStress,
onChanged: () => controller.updateShowRealtimeStress,
height: 56.dp,
titleAccessory: () => controller.isVip.value
? const SizedBox.shrink()
: SizedBox(
width: 43.dp,
height: 16.dp,
child: Image.asset(
'assets/images/common/ic_pro_badge.webp',
fit: BoxFit.fill,
),
),
);
}
Widget _settingRow({
required String title,
required RxBool value,
required ValueChanged<bool>? Function() onChanged,
Widget Function()? titleAccessory,
double? height,
}) {
return Container(
width: double.infinity,
height: 48.dp,
height: height ?? 48.dp,
margin: EdgeInsets.symmetric(horizontal: 16.dp),
padding: EdgeInsets.symmetric(horizontal: 20.dp),
decoration: BoxDecoration(
... ... @@ -73,21 +110,27 @@ class PrivacySettingsView extends GetView<PrivacySettingsController> {
child: Row(
children: [
Text(
context.l10n.privacySettingsDisableAddById,
title,
style: const TextStyle(
fontSize: 14,
color: AppColors.textPrimary,
),
),
if (titleAccessory != null) ...[
SizedBox(width: 4.dp),
Obx(titleAccessory),
],
const Spacer(),
Obx(
() => CupertinoSwitch(
value: controller.enableAddWithUCode.value,
activeTrackColor: AppColors.primary,
inactiveTrackColor: const Color(0xFFE5E5EA),
onChanged: controller.isUpdatingEnableAddWithUCode.value
? null
: controller.updateEnableAddWithUCode,
() => Transform.scale(
scaleX: 52 / 51,
scaleY: 28 / 31,
child: CupertinoSwitch(
value: value.value,
activeTrackColor: AppColors.primary,
inactiveTrackColor: const Color(0xFFE5E5EA),
onChanged: onChanged(),
),
),
),
],
... ...
... ... @@ -103,6 +103,10 @@ class UserPreferencesStorage {
await _persist(preferences.value.copyWith(vipInfo: vipInfo));
}
Future<void> updateShowRealtimeStress(bool enabled) async {
await _persist(preferences.value.copyWith(showRealtimeStress: enabled));
}
Future<void> updateFromLogin({
required String accessToken,
UserInfoResponse? me,
... ...
... ... @@ -9,6 +9,7 @@ class UserPreferences {
this.accessToken = '',
this.rongcloudToken = '',
this.vipInfo,
this.showRealtimeStress,
});
final UserInfoResponse? meUserInfo;
... ... @@ -17,6 +18,10 @@ class UserPreferences {
final String rongcloudToken;
final UserPreferencesVipInfo? vipInfo;
/// `null` preserves the product default: enabled for members and disabled
/// for non-members.
final bool? showRealtimeStress;
static const empty = UserPreferences();
factory UserPreferences.fromJson(Map<String, dynamic> json) {
... ... @@ -35,6 +40,7 @@ class UserPreferences {
? null
: UserPreferencesVipInfo.fromJson(
json['vip_info'] as Map<String, dynamic>),
showRealtimeStress: json['show_realtime_stress'] as bool?,
);
}
... ... @@ -47,6 +53,9 @@ class UserPreferences {
val['access_token'] = accessToken;
val['rongcloud_token'] = rongcloudToken;
if (vipInfo != null) val['vip_info'] = vipInfo!.toJson();
if (showRealtimeStress != null) {
val['show_realtime_stress'] = showRealtimeStress;
}
return val;
}
... ... @@ -56,6 +65,7 @@ class UserPreferences {
String? accessToken,
String? rongcloudToken,
UserPreferencesVipInfo? vipInfo,
bool? showRealtimeStress,
bool clearPartner = false,
}) {
return UserPreferences(
... ... @@ -65,6 +75,7 @@ class UserPreferences {
accessToken: accessToken ?? this.accessToken,
rongcloudToken: rongcloudToken ?? this.rongcloudToken,
vipInfo: vipInfo ?? this.vipInfo,
showRealtimeStress: showRealtimeStress ?? this.showRealtimeStress,
);
}
}
... ...
... ... @@ -423,6 +423,8 @@
"friendsRemarkSuffix": " ({remark})",
"friendsUnknownFriend": "Unknown friend",
"friendsUpdatedAt": "Updated at {time}",
"friendsRealtimeStressUpdatedAt": "Real-time stress updated at {time}",
"friendsHrvUpdatedAt": "HRV updated at {time}",
"friendsStepCount": "{count} steps",
"friendsStressAttention": "Stress alert",
"friendsWaitingForData": "Waiting for data",
... ... @@ -456,6 +458,7 @@
"friendsDeleteConfirmAction": "Remove",
"privacySettingsTitle": "Privacy Settings",
"privacySettingsDisableAddById": "Don't allow others to add me by ID",
"privacySettingsShowRealtimeStress": "Show real-time stress",
"premiumActivatedTitle": "DoubleFeel Pro is now active",
"premiumActivatedDescription": "You can now monitor stress, sleep, and HRV in real time, build healthier habits, and share health updates with close contacts so the people who matter can stay informed.",
"premiumActivatedContinue": "Continue",
... ...
... ... @@ -771,6 +771,22 @@
}
}
},
"friendsRealtimeStressUpdatedAt": "实时压力更新于{time}",
"@friendsRealtimeStressUpdatedAt": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"friendsHrvUpdatedAt": "HRV更新于{time}",
"@friendsHrvUpdatedAt": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"friendsStepCount": "{count}步",
"@friendsStepCount": {
"placeholders": {
... ... @@ -825,6 +841,7 @@
"friendsDeleteConfirmAction": "确认解除",
"privacySettingsTitle": "隐私设置",
"privacySettingsDisableAddById": "不允许通过ID添加我",
"privacySettingsShowRealtimeStress": "显示实时压力",
"premiumActivatedTitle": "恭喜你已开通 DoubleFeel Pro",
"premiumActivatedDescription": "你现在可以实时监测压力、睡眠与 HRV,养成健康生活习惯,同时把关心分享给亲密联系人,让重要的人及时了解你的状态。",
"premiumActivatedContinue": "继续",
... ...
... ... @@ -2637,6 +2637,18 @@ abstract class AppLocalizations {
/// **'更新于{time}'**
String friendsUpdatedAt(String time);
/// No description provided for @friendsRealtimeStressUpdatedAt.
///
/// In zh, this message translates to:
/// **'实时压力更新于{time}'**
String friendsRealtimeStressUpdatedAt(String time);
/// No description provided for @friendsHrvUpdatedAt.
///
/// In zh, this message translates to:
/// **'HRV更新于{time}'**
String friendsHrvUpdatedAt(String time);
/// No description provided for @friendsStepCount.
///
/// In zh, this message translates to:
... ... @@ -2835,6 +2847,12 @@ abstract class AppLocalizations {
/// **'不允许通过ID添加我'**
String get privacySettingsDisableAddById;
/// No description provided for @privacySettingsShowRealtimeStress.
///
/// In zh, this message translates to:
/// **'显示实时压力'**
String get privacySettingsShowRealtimeStress;
/// No description provided for @premiumActivatedTitle.
///
/// In zh, this message translates to:
... ...
... ... @@ -1467,6 +1467,16 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String friendsRealtimeStressUpdatedAt(String time) {
return 'Real-time stress updated at $time';
}
@override
String friendsHrvUpdatedAt(String time) {
return 'HRV updated at $time';
}
@override
String friendsStepCount(int count) {
return '$count steps';
}
... ... @@ -1576,6 +1586,9 @@ class AppLocalizationsEn extends AppLocalizations {
'Don\'t allow others to add me by ID';
@override
String get privacySettingsShowRealtimeStress => 'Show real-time stress';
@override
String get premiumActivatedTitle => 'DoubleFeel Pro is now active';
@override
... ...
... ... @@ -1409,6 +1409,16 @@ class AppLocalizationsZh extends AppLocalizations {
}
@override
String friendsRealtimeStressUpdatedAt(String time) {
return '实时压力更新于$time';
}
@override
String friendsHrvUpdatedAt(String time) {
return 'HRV更新于$time';
}
@override
String friendsStepCount(int count) {
return '$count步';
}
... ... @@ -1514,6 +1524,9 @@ class AppLocalizationsZh extends AppLocalizations {
String get privacySettingsDisableAddById => '不允许通过ID添加我';
@override
String get privacySettingsShowRealtimeStress => '显示实时压力';
@override
String get premiumActivatedTitle => '恭喜你已开通 DoubleFeel Pro';
@override
... ...