Commit 5e2e73951ae0494068032cd8eab11f677e1c8707

Authored by 权海
1 parent 13f762a6

feat(ui):修复一些问题

@@ -19,8 +19,7 @@ class ActivityBurnReportLogic extends ReportPeriodLogic { @@ -19,8 +19,7 @@ class ActivityBurnReportLogic extends ReportPeriodLogic {
19 ActivityBurnReportRepository? repository, 19 ActivityBurnReportRepository? repository,
20 }) : repository = repository ?? 20 }) : repository = repository ??
21 ActivityBurnReportRepositoryImpl( 21 ActivityBurnReportRepositoryImpl(
22 - dataSource:  
23 - ApiActivityBurnReportDataSource(_healthDataSource()), 22 + dataSource: ApiActivityBurnReportDataSource(_healthDataSource()),
24 ) { 23 ) {
25 targetUserId.value = initialTargetUserId; 24 targetUserId.value = initialTargetUserId;
26 } 25 }
@@ -52,7 +51,7 @@ class ActivityBurnReportLogic extends ReportPeriodLogic { @@ -52,7 +51,7 @@ class ActivityBurnReportLogic extends ReportPeriodLogic {
52 @override 51 @override
53 Future<void> loadReport() async { 52 Future<void> loadReport() async {
54 isLoading.value = !isMySelf; 53 isLoading.value = !isMySelf;
55 - 54 +
56 try { 55 try {
57 if (selectedPeriod.value == ReportPeriod.week) { 56 if (selectedPeriod.value == ReportPeriod.week) {
58 weeklyReport.value = await repository.getWeeklyReport( 57 weeklyReport.value = await repository.getWeeklyReport(
@@ -60,10 +59,11 @@ class ActivityBurnReportLogic extends ReportPeriodLogic { @@ -60,10 +59,11 @@ class ActivityBurnReportLogic extends ReportPeriodLogic {
60 targetUserId: targetUserId.value, 59 targetUserId: targetUserId.value,
61 ); 60 );
62 } else if (selectedPeriod.value == ReportPeriod.month) { 61 } else if (selectedPeriod.value == ReportPeriod.month) {
63 - monthlyReport.value = await repository.getMonthlyReport( 62 + final result = await repository.getMonthlyReport(
64 monthStart, 63 monthStart,
65 targetUserId: targetUserId.value, 64 targetUserId: targetUserId.value,
66 ); 65 );
  66 + monthlyReport.value = result;
67 } else { 67 } else {
68 report.value = await repository.getDailyReport( 68 report.value = await repository.getDailyReport(
69 selectedDate.value, 69 selectedDate.value,
@@ -197,7 +197,10 @@ class CreateWatchThemeController extends GetxController { @@ -197,7 +197,10 @@ class CreateWatchThemeController extends GetxController {
197 197
198 Future<void> showRenameStatusDialog(int index) async { 198 Future<void> showRenameStatusDialog(int index) async {
199 final result = await Get.dialog<String>( 199 final result = await Get.dialog<String>(
200 - WatchThemeRenameDialog(initialValue: customStatusNames[index]), 200 + WatchThemeRenameDialog(
  201 + initialValue: customStatusNames[index],
  202 + onConfirm: _validateStatusName,
  203 + ),
201 barrierDismissible: true, 204 barrierDismissible: true,
202 ); 205 );
203 if (result == null || result.trim().isEmpty) { 206 if (result == null || result.trim().isEmpty) {
@@ -206,6 +209,18 @@ class CreateWatchThemeController extends GetxController { @@ -206,6 +209,18 @@ class CreateWatchThemeController extends GetxController {
206 customStatusNames[index] = result.trim(); 209 customStatusNames[index] = result.trim();
207 } 210 }
208 211
  212 + Future<bool> _validateStatusName(String value) async {
  213 + final result = await _themeApi.checkTextContent(value);
  214 + if (result case AppSuccess<ContentCheckTextResult>(data: final data)
  215 + when data.isPass) {
  216 + return true;
  217 + }
  218 + if (result is AppSuccess<ContentCheckTextResult>) {
  219 + AppToast.show(l10n.watchThemeContentUnavailable);
  220 + }
  221 + return false;
  222 + }
  223 +
209 Future<void> handleCreateBack() async { 224 Future<void> handleCreateBack() async {
210 if (_creationCompleted) { 225 if (_creationCompleted) {
211 return; 226 return;
@@ -3,9 +3,14 @@ import 'package:get/get.dart'; @@ -3,9 +3,14 @@ import 'package:get/get.dart';
3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
4 4
5 class WatchThemeRenameDialog extends StatefulWidget { 5 class WatchThemeRenameDialog extends StatefulWidget {
6 - const WatchThemeRenameDialog({super.key, required this.initialValue}); 6 + const WatchThemeRenameDialog({
  7 + super.key,
  8 + required this.initialValue,
  9 + this.onConfirm,
  10 + });
7 11
8 final String initialValue; 12 final String initialValue;
  13 + final Future<bool> Function(String value)? onConfirm;
9 14
10 @override 15 @override
11 State<WatchThemeRenameDialog> createState() => _WatchThemeRenameDialogState(); 16 State<WatchThemeRenameDialog> createState() => _WatchThemeRenameDialogState();
@@ -13,6 +18,7 @@ class WatchThemeRenameDialog extends StatefulWidget { @@ -13,6 +18,7 @@ class WatchThemeRenameDialog extends StatefulWidget {
13 18
14 class _WatchThemeRenameDialogState extends State<WatchThemeRenameDialog> { 19 class _WatchThemeRenameDialogState extends State<WatchThemeRenameDialog> {
15 late final TextEditingController _controller; 20 late final TextEditingController _controller;
  21 + bool _isSubmitting = false;
16 22
17 @override 23 @override
18 void initState() { 24 void initState() {
@@ -94,11 +100,11 @@ class _WatchThemeRenameDialogState extends State<WatchThemeRenameDialog> { @@ -94,11 +100,11 @@ class _WatchThemeRenameDialogState extends State<WatchThemeRenameDialog> {
94 ValueListenableBuilder<TextEditingValue>( 100 ValueListenableBuilder<TextEditingValue>(
95 valueListenable: _controller, 101 valueListenable: _controller,
96 builder: (context, value, _) { 102 builder: (context, value, _) {
97 - final enabled = value.text.trim().isNotEmpty; 103 + final enabled = value.text.trim().isNotEmpty && !_isSubmitting;
98 return Opacity( 104 return Opacity(
99 opacity: enabled ? 1 : 0.4, 105 opacity: enabled ? 1 : 0.4,
100 child: GestureDetector( 106 child: GestureDetector(
101 - onTap: enabled ? () => Get.back(result: value.text) : null, 107 + onTap: enabled ? () => _confirm(value.text) : null,
102 child: Container( 108 child: Container(
103 width: 220, 109 width: 220,
104 height: 48, 110 height: 48,
@@ -125,6 +131,29 @@ class _WatchThemeRenameDialogState extends State<WatchThemeRenameDialog> { @@ -125,6 +131,29 @@ class _WatchThemeRenameDialogState extends State<WatchThemeRenameDialog> {
125 ), 131 ),
126 ); 132 );
127 } 133 }
  134 +
  135 + Future<void> _confirm(String value) async {
  136 + final trimmed = value.trim();
  137 + if (trimmed.isEmpty || _isSubmitting) {
  138 + return;
  139 + }
  140 + setState(() {
  141 + _isSubmitting = true;
  142 + });
  143 + try {
  144 + final canClose =
  145 + await (widget.onConfirm?.call(trimmed) ?? Future<bool>.value(true));
  146 + if (canClose) {
  147 + Get.back(result: trimmed);
  148 + }
  149 + } finally {
  150 + if (mounted) {
  151 + setState(() {
  152 + _isSubmitting = false;
  153 + });
  154 + }
  155 + }
  156 + }
128 } 157 }
129 158
130 class WatchThemeConfirmDialog extends StatelessWidget { 159 class WatchThemeConfirmDialog extends StatelessWidget {
@@ -10,6 +10,20 @@ class ThemeApi { @@ -10,6 +10,20 @@ class ThemeApi {
10 10
11 final DioClient _dioClient; 11 final DioClient _dioClient;
12 12
  13 + Future<AppResult<ContentCheckTextResult>> checkTextContent(String text) {
  14 + return safeCall(
  15 + call: () async {
  16 + final response = await _dioClient.dio.post(
  17 + ApiPaths.contentCheckText,
  18 + data: {'text': text},
  19 + );
  20 + return ContentCheckTextResult.fromJson(
  21 + response.data as Map<String, dynamic>,
  22 + );
  23 + },
  24 + );
  25 + }
  26 +
13 Future<AppResult<void>> createTheme({ 27 Future<AppResult<void>> createTheme({
14 required String themeName, 28 required String themeName,
15 required String energeticDescription, 29 required String energeticDescription,
@@ -153,3 +167,17 @@ class ThemeApi { @@ -153,3 +167,17 @@ class ThemeApi {
153 // Keep compatibility with the original Swift ThemeEndpoint spelling. 167 // Keep compatibility with the original Swift ThemeEndpoint spelling.
154 Future<AppResult<void>> appplyTheme(int themeId) => applyTheme(themeId); 168 Future<AppResult<void>> appplyTheme(int themeId) => applyTheme(themeId);
155 } 169 }
  170 +
  171 +class ContentCheckTextResult {
  172 + const ContentCheckTextResult({required this.suggestion});
  173 +
  174 + final String suggestion;
  175 +
  176 + bool get isPass => suggestion == 'pass';
  177 +
  178 + factory ContentCheckTextResult.fromJson(Map<String, dynamic> json) {
  179 + return ContentCheckTextResult(
  180 + suggestion: json['suggestion']?.toString() ?? '',
  181 + );
  182 + }
  183 +}
@@ -56,6 +56,7 @@ abstract final class ApiPaths { @@ -56,6 +56,7 @@ abstract final class ApiPaths {
56 56
57 // Config 57 // Config
58 static const dynamicConfigList = '/client/doublefeel/dynamic_config/list/'; 58 static const dynamicConfigList = '/client/doublefeel/dynamic_config/list/';
  59 + static const contentCheckText = '/client/doublefeel/content_check/text/';
59 60
60 // OBS 61 // OBS
61 static const obsTempToken = '/client/doublefeel/obs/temp_token/'; 62 static const obsTempToken = '/client/doublefeel/obs/temp_token/';
@@ -576,17 +576,17 @@ class LocalHealthDataConvert { @@ -576,17 +576,17 @@ class LocalHealthDataConvert {
576 move: (latest?.activeEnergyBurned ?? 0).ceil(), 576 move: (latest?.activeEnergyBurned ?? 0).ceil(),
577 standHours: latest?.appleStandHours ?? 0, 577 standHours: latest?.appleStandHours ?? 0,
578 exerciseSeconds: latest?.appleExerciseTime ?? 0, 578 exerciseSeconds: latest?.appleExerciseTime ?? 0,
579 - moveGoal: latest?.activeEnergyBurnedGoal ?? 480,  
580 - standGoal: latest?.standHoursGoal ?? 8, 579 + moveGoal: latest?.activeEnergyBurnedGoal,
  580 + standGoal: latest?.standHoursGoal,
581 exerciseGoalSeconds: latest?.exerciseTimeGoal, 581 exerciseGoalSeconds: latest?.exerciseTimeGoal,
582 ); 582 );
583 } 583 }
584 584
585 static bool _hasActivityGoal(HealthKitRawActivityDataPoint item) { 585 static bool _hasActivityGoal(HealthKitRawActivityDataPoint item) {
586 - return item.activeEnergyBurnedGoal != null ||  
587 - item.appleMoveTimeGoal != null ||  
588 - item.standHoursGoal != null ||  
589 - item.exerciseTimeGoal != null; 586 + return _isPositive(item.activeEnergyBurnedGoal) ||
  587 + _isPositive(item.appleMoveTimeGoal) ||
  588 + _isPositive(item.standHoursGoal) ||
  589 + _isPositive(item.exerciseTimeGoal);
590 } 590 }
591 591
592 static HealthRawHrvStressPoint? _latestHrvByRawEndTime( 592 static HealthRawHrvStressPoint? _latestHrvByRawEndTime(
@@ -641,10 +641,42 @@ class LocalHealthDataConvert { @@ -641,10 +641,42 @@ class LocalHealthDataConvert {
641 static _ActivityDaySummary? _latestActivitySummaryWithGoal( 641 static _ActivityDaySummary? _latestActivitySummaryWithGoal(
642 List<_ActivityDaySummary> daily, 642 List<_ActivityDaySummary> daily,
643 ) { 643 ) {
  644 + num? moveGoal;
  645 + num? standGoal;
  646 + num? exerciseGoalSeconds;
  647 + DateTime? latestGoalDay;
644 for (final item in daily.reversed) { 648 for (final item in daily.reversed) {
645 - if (item.hasGoal) return item; 649 + if (moveGoal == null && _isPositive(item.moveGoal)) {
  650 + moveGoal = item.moveGoal;
  651 + latestGoalDay ??= item.day;
  652 + }
  653 + if (standGoal == null && _isPositive(item.standGoal)) {
  654 + standGoal = item.standGoal;
  655 + latestGoalDay ??= item.day;
  656 + }
  657 + if (exerciseGoalSeconds == null &&
  658 + _isPositive(item.exerciseGoalSeconds)) {
  659 + exerciseGoalSeconds = item.exerciseGoalSeconds;
  660 + latestGoalDay ??= item.day;
  661 + }
  662 + if (moveGoal != null &&
  663 + standGoal != null &&
  664 + exerciseGoalSeconds != null) {
  665 + break;
  666 + }
646 } 667 }
647 - return null; 668 + if (moveGoal == null && standGoal == null && exerciseGoalSeconds == null) {
  669 + return null;
  670 + }
  671 + return _ActivityDaySummary(
  672 + day: latestGoalDay ?? daily.last.day,
  673 + move: 0,
  674 + standHours: 0,
  675 + exerciseSeconds: 0,
  676 + moveGoal: moveGoal,
  677 + standGoal: standGoal,
  678 + exerciseGoalSeconds: exerciseGoalSeconds,
  679 + );
648 } 680 }
649 681
650 static ActivityTargetInfo? _activityTargetInfo( 682 static ActivityTargetInfo? _activityTargetInfo(
@@ -945,6 +977,8 @@ class LocalHealthDataConvert { @@ -945,6 +977,8 @@ class LocalHealthDataConvert {
945 if (values.isEmpty) return null; 977 if (values.isEmpty) return null;
946 return values.reduce(math.min); 978 return values.reduce(math.min);
947 } 979 }
  980 +
  981 + static bool _isPositive(num? value) => value != null && value > 0;
948 } 982 }
949 983
950 class _ActivityDaySummary { 984 class _ActivityDaySummary {
@@ -967,9 +1001,9 @@ class _ActivityDaySummary { @@ -967,9 +1001,9 @@ class _ActivityDaySummary {
967 final num? exerciseGoalSeconds; 1001 final num? exerciseGoalSeconds;
968 1002
969 bool get hasGoal => 1003 bool get hasGoal =>
970 - moveGoal != null ||  
971 - standGoal != null ||  
972 - exerciseGoalSeconds != null; 1004 + (moveGoal ?? 0) > 0 ||
  1005 + (standGoal ?? 0) > 0 ||
  1006 + (exerciseGoalSeconds ?? 0) > 0;
973 } 1007 }
974 1008
975 class _HrvDaySummary { 1009 class _HrvDaySummary {
@@ -171,10 +171,13 @@ class LocalHealthDataSource implements HealthDataSource { @@ -171,10 +171,13 @@ class LocalHealthDataSource implements HealthDataSource {
171 startTime: queryStart, 171 startTime: queryStart,
172 endTime: queryEnd, 172 endTime: queryEnd,
173 ); 173 );
  174 + final resolvedActivity = _resolveActivityTarget(activity);
174 return AppSuccess( 175 return AppSuccess(
175 LocalHealthDataConvert.v2ActivityTarget( 176 LocalHealthDataConvert.v2ActivityTarget(
176 userId: queryUserId ?? 0, 177 userId: queryUserId ?? 0,
177 - activity: activity, 178 + activity: resolvedActivity == null
  179 + ? const <HealthKitRawActivityDataPoint>[]
  180 + : [resolvedActivity],
178 ), 181 ),
179 ); 182 );
180 } catch (error) { 183 } catch (error) {
@@ -317,4 +320,53 @@ class LocalHealthDataSource implements HealthDataSource { @@ -317,4 +320,53 @@ class LocalHealthDataSource implements HealthDataSource {
317 endTime: endTime, 320 endTime: endTime,
318 ); 321 );
319 } 322 }
  323 +
  324 + HealthKitRawActivityDataPoint? _resolveActivityTarget(
  325 + List<HealthKitRawActivityDataPoint> activity,
  326 + ) {
  327 + double? activeEnergyBurnedGoal;
  328 + double? exerciseTimeGoal;
  329 + double? standHoursGoal;
  330 + var resolvedEndTime = 0;
  331 + final reversed = [...activity]
  332 + ..sort((a, b) => b.endTime.compareTo(a.endTime));
  333 +
  334 + for (final item in reversed) {
  335 + if (activeEnergyBurnedGoal == null &&
  336 + _isPositive(item.activeEnergyBurnedGoal)) {
  337 + activeEnergyBurnedGoal = item.activeEnergyBurnedGoal;
  338 + resolvedEndTime =
  339 + resolvedEndTime < item.endTime ? item.endTime : resolvedEndTime;
  340 + }
  341 + if (exerciseTimeGoal == null && _isPositive(item.exerciseTimeGoal)) {
  342 + exerciseTimeGoal = item.exerciseTimeGoal;
  343 + resolvedEndTime =
  344 + resolvedEndTime < item.endTime ? item.endTime : resolvedEndTime;
  345 + }
  346 + if (standHoursGoal == null && _isPositive(item.standHoursGoal)) {
  347 + standHoursGoal = item.standHoursGoal;
  348 + resolvedEndTime =
  349 + resolvedEndTime < item.endTime ? item.endTime : resolvedEndTime;
  350 + }
  351 + if (activeEnergyBurnedGoal != null &&
  352 + exerciseTimeGoal != null &&
  353 + standHoursGoal != null) {
  354 + break;
  355 + }
  356 + }
  357 +
  358 + if (activeEnergyBurnedGoal == null &&
  359 + exerciseTimeGoal == null &&
  360 + standHoursGoal == null) {
  361 + return null;
  362 + }
  363 + return HealthKitRawActivityDataPoint(
  364 + endTime: resolvedEndTime,
  365 + activeEnergyBurnedGoal: activeEnergyBurnedGoal,
  366 + exerciseTimeGoal: exerciseTimeGoal,
  367 + standHoursGoal: standHoursGoal,
  368 + );
  369 + }
  370 +
  371 + bool _isPositive(num? value) => value != null && value > 0;
320 } 372 }
@@ -627,6 +627,7 @@ @@ -627,6 +627,7 @@
627 "watchThemeRenameStatus": "Rename Status", 627 "watchThemeRenameStatus": "Rename Status",
628 "watchThemeEnterNickname": "Enter a name", 628 "watchThemeEnterNickname": "Enter a name",
629 "watchThemeSave": "Save", 629 "watchThemeSave": "Save",
  630 + "watchThemeContentUnavailable": "This content is unavailable. Try another one.",
630 "watchThemeDialPreview": "Watch Face Preview", 631 "watchThemeDialPreview": "Watch Face Preview",
631 "watchThemeSwitchFriend": "Switch Friend", 632 "watchThemeSwitchFriend": "Switch Friend",
632 "watchThemeStatusPreview": "Status Preview", 633 "watchThemeStatusPreview": "Status Preview",
@@ -1020,6 +1020,7 @@ @@ -1020,6 +1020,7 @@
1020 "watchThemeRenameStatus": "修改状态名称", 1020 "watchThemeRenameStatus": "修改状态名称",
1021 "watchThemeEnterNickname": "请输入昵称", 1021 "watchThemeEnterNickname": "请输入昵称",
1022 "watchThemeSave": "保存", 1022 "watchThemeSave": "保存",
  1023 + "watchThemeContentUnavailable": "这个内容不可用,换一个吧",
1023 "watchThemeDialPreview": "表盘预览", 1024 "watchThemeDialPreview": "表盘预览",
1024 "watchThemeSwitchFriend": "切换好友", 1025 "watchThemeSwitchFriend": "切换好友",
1025 "watchThemeStatusPreview": "状态预览", 1026 "watchThemeStatusPreview": "状态预览",
@@ -3717,6 +3717,12 @@ abstract class AppLocalizations { @@ -3717,6 +3717,12 @@ abstract class AppLocalizations {
3717 /// **'保存'** 3717 /// **'保存'**
3718 String get watchThemeSave; 3718 String get watchThemeSave;
3719 3719
  3720 + /// No description provided for @watchThemeContentUnavailable.
  3721 + ///
  3722 + /// In zh, this message translates to:
  3723 + /// **'这个内容不可用,换一个吧'**
  3724 + String get watchThemeContentUnavailable;
  3725 +
3720 /// No description provided for @watchThemeDialPreview. 3726 /// No description provided for @watchThemeDialPreview.
3721 /// 3727 ///
3722 /// In zh, this message translates to: 3728 /// In zh, this message translates to:
@@ -2082,6 +2082,10 @@ class AppLocalizationsEn extends AppLocalizations { @@ -2082,6 +2082,10 @@ class AppLocalizationsEn extends AppLocalizations {
2082 String get watchThemeSave => 'Save'; 2082 String get watchThemeSave => 'Save';
2083 2083
2084 @override 2084 @override
  2085 + String get watchThemeContentUnavailable =>
  2086 + 'This content is unavailable. Try another one.';
  2087 +
  2088 + @override
2085 String get watchThemeDialPreview => 'Watch Face Preview'; 2089 String get watchThemeDialPreview => 'Watch Face Preview';
2086 2090
2087 @override 2091 @override
@@ -1988,6 +1988,9 @@ class AppLocalizationsZh extends AppLocalizations { @@ -1988,6 +1988,9 @@ class AppLocalizationsZh extends AppLocalizations {
1988 String get watchThemeSave => '保存'; 1988 String get watchThemeSave => '保存';
1989 1989
1990 @override 1990 @override
  1991 + String get watchThemeContentUnavailable => '这个内容不可用,换一个吧';
  1992 +
  1993 + @override
1991 String get watchThemeDialPreview => '表盘预览'; 1994 String get watchThemeDialPreview => '表盘预览';
1992 1995
1993 @override 1996 @override
@@ -485,6 +485,42 @@ void main() { @@ -485,6 +485,42 @@ void main() {
485 expect(target.stand, 12); 485 expect(target.stand, 12);
486 expect(statistics.totalExercise, 1800); 486 expect(statistics.totalExercise, 1800);
487 expect(statistics.totalStand, 6); 487 expect(statistics.totalStand, 6);
  488 + expect(statistics.activityTargetInfo?.move, 500);
  489 + expect(statistics.activityTargetInfo?.exercise, 3600);
  490 + expect(statistics.activityTargetInfo?.stand, 12);
  491 + });
  492 +
  493 + test('resolves activity goals independently from latest useful values', () {
  494 + final firstDay = DateTime(2026, 7, 15);
  495 + final secondDay = DateTime(2026, 7, 16);
  496 + final activity = [
  497 + HealthKitRawActivityDataPoint(
  498 + endTime: LocalHealthDataConvert.unixSeconds(
  499 + firstDay.add(const Duration(hours: 12)),
  500 + ),
  501 + activeEnergyBurnedGoal: 0,
  502 + exerciseTimeGoal: 3600,
  503 + standHoursGoal: 12,
  504 + ),
  505 + HealthKitRawActivityDataPoint(
  506 + endTime: LocalHealthDataConvert.unixSeconds(
  507 + secondDay.add(const Duration(hours: 12)),
  508 + ),
  509 + activeEnergyBurnedGoal: 600,
  510 + exerciseTimeGoal: 0,
  511 + standHoursGoal: null,
  512 + ),
  513 + ];
  514 +
  515 + final statistics = LocalHealthDataConvert.activityBurnStatistics(
  516 + days: [firstDay, secondDay],
  517 + previousDays: const [],
  518 + activity: activity,
  519 + heartRate: const [],
  520 + sleepIntervals: const [],
  521 + );
  522 +
  523 + expect(statistics.activityTargetInfo?.move, 600);
488 expect(statistics.activityTargetInfo?.exercise, 3600); 524 expect(statistics.activityTargetInfo?.exercise, 3600);
489 expect(statistics.activityTargetInfo?.stand, 12); 525 expect(statistics.activityTargetInfo?.stand, 12);
490 }); 526 });