Commit 5e2e73951ae0494068032cd8eab11f677e1c8707

Authored by 权海
1 parent 13f762a6

feat(ui):修复一些问题

... ... @@ -19,8 +19,7 @@ class ActivityBurnReportLogic extends ReportPeriodLogic {
ActivityBurnReportRepository? repository,
}) : repository = repository ??
ActivityBurnReportRepositoryImpl(
dataSource:
ApiActivityBurnReportDataSource(_healthDataSource()),
dataSource: ApiActivityBurnReportDataSource(_healthDataSource()),
) {
targetUserId.value = initialTargetUserId;
}
... ... @@ -52,7 +51,7 @@ class ActivityBurnReportLogic extends ReportPeriodLogic {
@override
Future<void> loadReport() async {
isLoading.value = !isMySelf;
try {
if (selectedPeriod.value == ReportPeriod.week) {
weeklyReport.value = await repository.getWeeklyReport(
... ... @@ -60,10 +59,11 @@ class ActivityBurnReportLogic extends ReportPeriodLogic {
targetUserId: targetUserId.value,
);
} else if (selectedPeriod.value == ReportPeriod.month) {
monthlyReport.value = await repository.getMonthlyReport(
final result = await repository.getMonthlyReport(
monthStart,
targetUserId: targetUserId.value,
);
monthlyReport.value = result;
} else {
report.value = await repository.getDailyReport(
selectedDate.value,
... ...
... ... @@ -197,7 +197,10 @@ class CreateWatchThemeController extends GetxController {
Future<void> showRenameStatusDialog(int index) async {
final result = await Get.dialog<String>(
WatchThemeRenameDialog(initialValue: customStatusNames[index]),
WatchThemeRenameDialog(
initialValue: customStatusNames[index],
onConfirm: _validateStatusName,
),
barrierDismissible: true,
);
if (result == null || result.trim().isEmpty) {
... ... @@ -206,6 +209,18 @@ class CreateWatchThemeController extends GetxController {
customStatusNames[index] = result.trim();
}
Future<bool> _validateStatusName(String value) async {
final result = await _themeApi.checkTextContent(value);
if (result case AppSuccess<ContentCheckTextResult>(data: final data)
when data.isPass) {
return true;
}
if (result is AppSuccess<ContentCheckTextResult>) {
AppToast.show(l10n.watchThemeContentUnavailable);
}
return false;
}
Future<void> handleCreateBack() async {
if (_creationCompleted) {
return;
... ...
... ... @@ -3,9 +3,14 @@ import 'package:get/get.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
class WatchThemeRenameDialog extends StatefulWidget {
const WatchThemeRenameDialog({super.key, required this.initialValue});
const WatchThemeRenameDialog({
super.key,
required this.initialValue,
this.onConfirm,
});
final String initialValue;
final Future<bool> Function(String value)? onConfirm;
@override
State<WatchThemeRenameDialog> createState() => _WatchThemeRenameDialogState();
... ... @@ -13,6 +18,7 @@ class WatchThemeRenameDialog extends StatefulWidget {
class _WatchThemeRenameDialogState extends State<WatchThemeRenameDialog> {
late final TextEditingController _controller;
bool _isSubmitting = false;
@override
void initState() {
... ... @@ -94,11 +100,11 @@ class _WatchThemeRenameDialogState extends State<WatchThemeRenameDialog> {
ValueListenableBuilder<TextEditingValue>(
valueListenable: _controller,
builder: (context, value, _) {
final enabled = value.text.trim().isNotEmpty;
final enabled = value.text.trim().isNotEmpty && !_isSubmitting;
return Opacity(
opacity: enabled ? 1 : 0.4,
child: GestureDetector(
onTap: enabled ? () => Get.back(result: value.text) : null,
onTap: enabled ? () => _confirm(value.text) : null,
child: Container(
width: 220,
height: 48,
... ... @@ -125,6 +131,29 @@ class _WatchThemeRenameDialogState extends State<WatchThemeRenameDialog> {
),
);
}
Future<void> _confirm(String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty || _isSubmitting) {
return;
}
setState(() {
_isSubmitting = true;
});
try {
final canClose =
await (widget.onConfirm?.call(trimmed) ?? Future<bool>.value(true));
if (canClose) {
Get.back(result: trimmed);
}
} finally {
if (mounted) {
setState(() {
_isSubmitting = false;
});
}
}
}
}
class WatchThemeConfirmDialog extends StatelessWidget {
... ...
... ... @@ -10,6 +10,20 @@ class ThemeApi {
final DioClient _dioClient;
Future<AppResult<ContentCheckTextResult>> checkTextContent(String text) {
return safeCall(
call: () async {
final response = await _dioClient.dio.post(
ApiPaths.contentCheckText,
data: {'text': text},
);
return ContentCheckTextResult.fromJson(
response.data as Map<String, dynamic>,
);
},
);
}
Future<AppResult<void>> createTheme({
required String themeName,
required String energeticDescription,
... ... @@ -153,3 +167,17 @@ class ThemeApi {
// Keep compatibility with the original Swift ThemeEndpoint spelling.
Future<AppResult<void>> appplyTheme(int themeId) => applyTheme(themeId);
}
class ContentCheckTextResult {
const ContentCheckTextResult({required this.suggestion});
final String suggestion;
bool get isPass => suggestion == 'pass';
factory ContentCheckTextResult.fromJson(Map<String, dynamic> json) {
return ContentCheckTextResult(
suggestion: json['suggestion']?.toString() ?? '',
);
}
}
... ...
... ... @@ -56,6 +56,7 @@ abstract final class ApiPaths {
// Config
static const dynamicConfigList = '/client/doublefeel/dynamic_config/list/';
static const contentCheckText = '/client/doublefeel/content_check/text/';
// OBS
static const obsTempToken = '/client/doublefeel/obs/temp_token/';
... ...
... ... @@ -576,17 +576,17 @@ class LocalHealthDataConvert {
move: (latest?.activeEnergyBurned ?? 0).ceil(),
standHours: latest?.appleStandHours ?? 0,
exerciseSeconds: latest?.appleExerciseTime ?? 0,
moveGoal: latest?.activeEnergyBurnedGoal ?? 480,
standGoal: latest?.standHoursGoal ?? 8,
moveGoal: latest?.activeEnergyBurnedGoal,
standGoal: latest?.standHoursGoal,
exerciseGoalSeconds: latest?.exerciseTimeGoal,
);
}
static bool _hasActivityGoal(HealthKitRawActivityDataPoint item) {
return item.activeEnergyBurnedGoal != null ||
item.appleMoveTimeGoal != null ||
item.standHoursGoal != null ||
item.exerciseTimeGoal != null;
return _isPositive(item.activeEnergyBurnedGoal) ||
_isPositive(item.appleMoveTimeGoal) ||
_isPositive(item.standHoursGoal) ||
_isPositive(item.exerciseTimeGoal);
}
static HealthRawHrvStressPoint? _latestHrvByRawEndTime(
... ... @@ -641,10 +641,42 @@ class LocalHealthDataConvert {
static _ActivityDaySummary? _latestActivitySummaryWithGoal(
List<_ActivityDaySummary> daily,
) {
num? moveGoal;
num? standGoal;
num? exerciseGoalSeconds;
DateTime? latestGoalDay;
for (final item in daily.reversed) {
if (item.hasGoal) return item;
if (moveGoal == null && _isPositive(item.moveGoal)) {
moveGoal = item.moveGoal;
latestGoalDay ??= item.day;
}
if (standGoal == null && _isPositive(item.standGoal)) {
standGoal = item.standGoal;
latestGoalDay ??= item.day;
}
if (exerciseGoalSeconds == null &&
_isPositive(item.exerciseGoalSeconds)) {
exerciseGoalSeconds = item.exerciseGoalSeconds;
latestGoalDay ??= item.day;
}
if (moveGoal != null &&
standGoal != null &&
exerciseGoalSeconds != null) {
break;
}
}
return null;
if (moveGoal == null && standGoal == null && exerciseGoalSeconds == null) {
return null;
}
return _ActivityDaySummary(
day: latestGoalDay ?? daily.last.day,
move: 0,
standHours: 0,
exerciseSeconds: 0,
moveGoal: moveGoal,
standGoal: standGoal,
exerciseGoalSeconds: exerciseGoalSeconds,
);
}
static ActivityTargetInfo? _activityTargetInfo(
... ... @@ -945,6 +977,8 @@ class LocalHealthDataConvert {
if (values.isEmpty) return null;
return values.reduce(math.min);
}
static bool _isPositive(num? value) => value != null && value > 0;
}
class _ActivityDaySummary {
... ... @@ -967,9 +1001,9 @@ class _ActivityDaySummary {
final num? exerciseGoalSeconds;
bool get hasGoal =>
moveGoal != null ||
standGoal != null ||
exerciseGoalSeconds != null;
(moveGoal ?? 0) > 0 ||
(standGoal ?? 0) > 0 ||
(exerciseGoalSeconds ?? 0) > 0;
}
class _HrvDaySummary {
... ...
... ... @@ -171,10 +171,13 @@ class LocalHealthDataSource implements HealthDataSource {
startTime: queryStart,
endTime: queryEnd,
);
final resolvedActivity = _resolveActivityTarget(activity);
return AppSuccess(
LocalHealthDataConvert.v2ActivityTarget(
userId: queryUserId ?? 0,
activity: activity,
activity: resolvedActivity == null
? const <HealthKitRawActivityDataPoint>[]
: [resolvedActivity],
),
);
} catch (error) {
... ... @@ -317,4 +320,53 @@ class LocalHealthDataSource implements HealthDataSource {
endTime: endTime,
);
}
HealthKitRawActivityDataPoint? _resolveActivityTarget(
List<HealthKitRawActivityDataPoint> activity,
) {
double? activeEnergyBurnedGoal;
double? exerciseTimeGoal;
double? standHoursGoal;
var resolvedEndTime = 0;
final reversed = [...activity]
..sort((a, b) => b.endTime.compareTo(a.endTime));
for (final item in reversed) {
if (activeEnergyBurnedGoal == null &&
_isPositive(item.activeEnergyBurnedGoal)) {
activeEnergyBurnedGoal = item.activeEnergyBurnedGoal;
resolvedEndTime =
resolvedEndTime < item.endTime ? item.endTime : resolvedEndTime;
}
if (exerciseTimeGoal == null && _isPositive(item.exerciseTimeGoal)) {
exerciseTimeGoal = item.exerciseTimeGoal;
resolvedEndTime =
resolvedEndTime < item.endTime ? item.endTime : resolvedEndTime;
}
if (standHoursGoal == null && _isPositive(item.standHoursGoal)) {
standHoursGoal = item.standHoursGoal;
resolvedEndTime =
resolvedEndTime < item.endTime ? item.endTime : resolvedEndTime;
}
if (activeEnergyBurnedGoal != null &&
exerciseTimeGoal != null &&
standHoursGoal != null) {
break;
}
}
if (activeEnergyBurnedGoal == null &&
exerciseTimeGoal == null &&
standHoursGoal == null) {
return null;
}
return HealthKitRawActivityDataPoint(
endTime: resolvedEndTime,
activeEnergyBurnedGoal: activeEnergyBurnedGoal,
exerciseTimeGoal: exerciseTimeGoal,
standHoursGoal: standHoursGoal,
);
}
bool _isPositive(num? value) => value != null && value > 0;
}
... ...
... ... @@ -627,6 +627,7 @@
"watchThemeRenameStatus": "Rename Status",
"watchThemeEnterNickname": "Enter a name",
"watchThemeSave": "Save",
"watchThemeContentUnavailable": "This content is unavailable. Try another one.",
"watchThemeDialPreview": "Watch Face Preview",
"watchThemeSwitchFriend": "Switch Friend",
"watchThemeStatusPreview": "Status Preview",
... ...
... ... @@ -1020,6 +1020,7 @@
"watchThemeRenameStatus": "修改状态名称",
"watchThemeEnterNickname": "请输入昵称",
"watchThemeSave": "保存",
"watchThemeContentUnavailable": "这个内容不可用,换一个吧",
"watchThemeDialPreview": "表盘预览",
"watchThemeSwitchFriend": "切换好友",
"watchThemeStatusPreview": "状态预览",
... ...
... ... @@ -3717,6 +3717,12 @@ abstract class AppLocalizations {
/// **'保存'**
String get watchThemeSave;
/// No description provided for @watchThemeContentUnavailable.
///
/// In zh, this message translates to:
/// **'这个内容不可用,换一个吧'**
String get watchThemeContentUnavailable;
/// No description provided for @watchThemeDialPreview.
///
/// In zh, this message translates to:
... ...
... ... @@ -2082,6 +2082,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get watchThemeSave => 'Save';
@override
String get watchThemeContentUnavailable =>
'This content is unavailable. Try another one.';
@override
String get watchThemeDialPreview => 'Watch Face Preview';
@override
... ...
... ... @@ -1988,6 +1988,9 @@ class AppLocalizationsZh extends AppLocalizations {
String get watchThemeSave => '保存';
@override
String get watchThemeContentUnavailable => '这个内容不可用,换一个吧';
@override
String get watchThemeDialPreview => '表盘预览';
@override
... ...
... ... @@ -485,6 +485,42 @@ void main() {
expect(target.stand, 12);
expect(statistics.totalExercise, 1800);
expect(statistics.totalStand, 6);
expect(statistics.activityTargetInfo?.move, 500);
expect(statistics.activityTargetInfo?.exercise, 3600);
expect(statistics.activityTargetInfo?.stand, 12);
});
test('resolves activity goals independently from latest useful values', () {
final firstDay = DateTime(2026, 7, 15);
final secondDay = DateTime(2026, 7, 16);
final activity = [
HealthKitRawActivityDataPoint(
endTime: LocalHealthDataConvert.unixSeconds(
firstDay.add(const Duration(hours: 12)),
),
activeEnergyBurnedGoal: 0,
exerciseTimeGoal: 3600,
standHoursGoal: 12,
),
HealthKitRawActivityDataPoint(
endTime: LocalHealthDataConvert.unixSeconds(
secondDay.add(const Duration(hours: 12)),
),
activeEnergyBurnedGoal: 600,
exerciseTimeGoal: 0,
standHoursGoal: null,
),
];
final statistics = LocalHealthDataConvert.activityBurnStatistics(
days: [firstDay, secondDay],
previousDays: const [],
activity: activity,
heartRate: const [],
sleepIntervals: const [],
);
expect(statistics.activityTargetInfo?.move, 600);
expect(statistics.activityTargetInfo?.exercise, 3600);
expect(statistics.activityTargetInfo?.stand, 12);
});
... ...