Commit 21c67bdd2ef5f595b488918ffc3b7b8a4dda7e60

Authored by 刘宏哲
1 parent 9724003b

feat(app): bug fixed

... ... @@ -13,6 +13,9 @@ import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
class LocalHealthDataConvert {
const LocalHealthDataConvert._();
static const _sleepDayCutoffMinutes = 12 * 60;
static const _minutesPerDay = 24 * 60;
static List<DateTime> rangeDays(int dateRangeType, int startDate) {
final start = dateFromKey(startDate);
final count = switch (dateRangeType) {
... ... @@ -120,8 +123,14 @@ class LocalHealthDataConvert {
],
bestSleepInfo: _bestSleepInfo(validSleep),
worstSleepInfo: _worstSleepInfo(validSleep),
earliestSleepInfo: _earliestSleepInfo(validSleep),
latestSleepInfo: _latestSleepInfo(validSleep),
earliestSleepInfo: _earliestSleepInfo(
validSleep,
allowedSleepDays: days,
),
latestSleepInfo: _latestSleepInfo(
validSleep,
allowedSleepDays: days,
),
hrList: shouldIncludeHeartRate
? [
// SleepHrItem(time: 1784091398, value: 63),
... ... @@ -208,8 +217,14 @@ class LocalHealthDataConvert {
],
bestSleepInfo: _bestSleepResultInfo(validSleep, best: true),
worstSleepInfo: _worstSleepResultInfo(validSleep),
earliestSleepInfo: _earliestSleepResultInfo(validSleep),
latestSleepInfo: _latestSleepResultInfo(validSleep),
earliestSleepInfo: _earliestSleepResultInfo(
sleepResults,
allowedSleepDays: days,
),
latestSleepInfo: _latestSleepResultInfo(
sleepResults,
allowedSleepDays: days,
),
hrList: shouldIncludeHeartRate
? [
for (final point in allSleepHr)
... ... @@ -827,16 +842,23 @@ class LocalHealthDataConvert {
}
static EarliestSleepInfo? _earliestSleepInfo(
List<HealthSleepDayCalculation> daily,
) {
final sorted = daily.where((e) => e.asleepTime != null).toList()
..sort((a, b) => a.asleepTime!.compareTo(b.asleepTime!));
final item = sorted.isEmpty ? null : sorted.first;
return item == null
List<HealthSleepDayCalculation> daily, {
required Iterable<DateTime> allowedSleepDays,
}) {
final bedtime = _extremeBedtime(
_bedtimesBySleepDay(
daily,
(item) => item.asleepTime,
earliest: true,
allowedSleepDays: allowedSleepDays,
),
earliest: true,
);
return bedtime == null
? null
: EarliestSleepInfo(
timeKey: dateKey(item.day),
asleepTime: item.asleepTime,
timeKey: dateKey(bedtime.sleepDay),
asleepTime: bedtime.timestamp,
);
}
... ... @@ -878,45 +900,130 @@ class LocalHealthDataConvert {
}
static EarliestSleepInfo? _earliestSleepResultInfo(
List<HealthRawSleepResult> sleepResults,
) {
final sorted = [...sleepResults]
..sort((a, b) => a.startDate.compareTo(b.startDate));
final item = sorted.isEmpty ? null : sorted.first;
if (item == null) return null;
List<HealthRawSleepResult> sleepResults, {
required Iterable<DateTime> allowedSleepDays,
}) {
final bedtime = _extremeBedtime(
_bedtimesBySleepDay(
sleepResults,
(item) => item.startDate,
earliest: true,
allowedSleepDays: allowedSleepDays,
),
earliest: true,
);
if (bedtime == null) return null;
return EarliestSleepInfo(
timeKey: dateKey(dayOf(item.date)),
asleepTime: item.startDate,
timeKey: dateKey(bedtime.sleepDay),
asleepTime: bedtime.timestamp,
);
}
static LatestSleepInfo? _latestSleepResultInfo(
List<HealthRawSleepResult> sleepResults,
) {
final sorted = [...sleepResults]
..sort((a, b) => b.startDate.compareTo(a.startDate));
final item = sorted.isEmpty ? null : sorted.first;
if (item == null) return null;
List<HealthRawSleepResult> sleepResults, {
required Iterable<DateTime> allowedSleepDays,
}) {
final bedtime = _extremeBedtime(
_bedtimesBySleepDay(
sleepResults,
(item) => item.startDate,
earliest: false,
allowedSleepDays: allowedSleepDays,
),
earliest: false,
);
if (bedtime == null) return null;
return LatestSleepInfo(
timeKey: dateKey(dayOf(item.date)),
asleepTime: item.startDate,
timeKey: dateKey(bedtime.sleepDay),
asleepTime: bedtime.timestamp,
);
}
static LatestSleepInfo? _latestSleepInfo(
List<HealthSleepDayCalculation> daily,
) {
final sorted = daily.where((e) => e.asleepTime != null).toList()
..sort((a, b) => b.asleepTime!.compareTo(a.asleepTime!));
final item = sorted.isEmpty ? null : sorted.first;
return item == null
List<HealthSleepDayCalculation> daily, {
required Iterable<DateTime> allowedSleepDays,
}) {
final bedtime = _extremeBedtime(
_bedtimesBySleepDay(
daily,
(item) => item.asleepTime,
earliest: false,
allowedSleepDays: allowedSleepDays,
),
earliest: false,
);
return bedtime == null
? null
: LatestSleepInfo(
timeKey: dateKey(item.day),
asleepTime: item.asleepTime,
timeKey: dateKey(bedtime.sleepDay),
asleepTime: bedtime.timestamp,
);
}
static List<_SleepBedtime> _bedtimesBySleepDay<T>(
Iterable<T> records,
int? Function(T record) timestampOf, {
required bool earliest,
required Iterable<DateTime> allowedSleepDays,
}) {
final allowedSleepDayKeys = {
for (final day in allowedSleepDays) dateKey(day),
};
final bedtimesBySleepDay = <int, _SleepBedtime>{};
for (final record in records) {
final timestamp = timestampOf(record);
if (timestamp == null) continue;
final bedtime = _SleepBedtime(
timestamp: timestamp,
sleepDay: _sleepDayOf(timestamp),
normalizedMinutes: _normalizedBedtimeMinutes(timestamp),
);
final key = dateKey(bedtime.sleepDay);
if (!allowedSleepDayKeys.contains(key)) continue;
final existing = bedtimesBySleepDay[key];
if (existing == null ||
(earliest
? bedtime.timestamp < existing.timestamp
: bedtime.timestamp > existing.timestamp)) {
bedtimesBySleepDay[key] = bedtime;
}
}
return bedtimesBySleepDay.values.toList();
}
static _SleepBedtime? _extremeBedtime(
Iterable<_SleepBedtime> bedtimes, {
required bool earliest,
}) {
_SleepBedtime? selected;
for (final bedtime in bedtimes) {
if (selected == null ||
(earliest
? bedtime.normalizedMinutes < selected.normalizedMinutes
: bedtime.normalizedMinutes > selected.normalizedMinutes)) {
selected = bedtime;
}
}
return selected;
}
static DateTime _sleepDayOf(int timestamp) {
final time = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
final day = DateTime(time.year, time.month, time.day);
return time.hour * 60 + time.minute < _sleepDayCutoffMinutes
? day.subtract(const Duration(days: 1))
: day;
}
/// Maps 12:00–23:59 to 0–719 and 00:00–11:59 to 720–1439.
static int _normalizedBedtimeMinutes(int timestamp) {
final time = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
final minutes = time.hour * 60 + time.minute;
return minutes < _sleepDayCutoffMinutes
? minutes + _minutesPerDay - _sleepDayCutoffMinutes
: minutes - _sleepDayCutoffMinutes;
}
static int? _modeState(Iterable<int> states) {
final counts = <int, int>{};
for (final state in states) {
... ... @@ -1057,3 +1164,15 @@ class _HrvDaySummary {
bool get hasData => hrvAverage != null || hrAverage != null || state != null;
}
class _SleepBedtime {
const _SleepBedtime({
required this.timestamp,
required this.sleepDay,
required this.normalizedMinutes,
});
final int timestamp;
final DateTime sleepDay;
final int normalizedMinutes;
}
... ...
... ... @@ -119,6 +119,165 @@ void main() {
expect(statistics.avgSleepEvaluate, 2);
});
test('compares weekly bedtime extremes by sleep-day clock time', () {
final july27 = DateTime(2026, 7, 27);
final july28 = DateTime(2026, 7, 28);
final july29 = DateTime(2026, 7, 29);
final latestBedtime = DateTime(2026, 7, 28, 3, 59, 1);
final earliestBedtime = DateTime(2026, 7, 29, 2, 55, 28);
HealthRawSleepResult result(DateTime day, DateTime bedtime) {
return HealthRawSleepResult(
userId: 1,
date: LocalHealthDataConvert.unixSeconds(
day.add(const Duration(hours: 8))),
startDate: LocalHealthDataConvert.unixSeconds(bedtime),
sleepScore: 80,
sleepState: 1,
inBedMinutes: 420,
awakMinutes: 20,
sleepMinutes: 400,
);
}
final statistics = LocalHealthDataConvert.sleepStatisticsFromResults(
dateRangeType: 0,
days: [july27, july28, july29],
previousDays: const [],
sleepResults: [
result(july28, latestBedtime),
result(july29, earliestBedtime),
],
heartRate: const [],
);
expect(
statistics.earliestSleepInfo?.asleepTime,
LocalHealthDataConvert.unixSeconds(earliestBedtime),
);
expect(statistics.earliestSleepInfo?.timeKey, 20260728);
expect(
statistics.latestSleepInfo?.asleepTime,
LocalHealthDataConvert.unixSeconds(latestBedtime),
);
expect(statistics.latestSleepInfo?.timeKey, 20260727);
});
test('uses the first and last records within a sleep day', () {
final july28 = DateTime(2026, 7, 28);
final july29 = DateTime(2026, 7, 29);
final bedtime = DateTime(2026, 7, 28, 22, 59, 59);
final continuedSleep = DateTime(2026, 7, 29, 3, 59, 59);
HealthRawSleepResult result(DateTime start) {
return HealthRawSleepResult(
userId: 1,
date: LocalHealthDataConvert.unixSeconds(
july29.add(const Duration(hours: 8)),
),
startDate: LocalHealthDataConvert.unixSeconds(start),
sleepScore: 80,
sleepState: 1,
inBedMinutes: 420,
awakMinutes: 20,
sleepMinutes: 400,
);
}
final statistics = LocalHealthDataConvert.sleepStatisticsFromResults(
dateRangeType: 0,
days: [july28, july29],
previousDays: const [],
sleepResults: [
result(bedtime),
result(continuedSleep),
],
heartRate: const [],
);
final expectedBedtime = LocalHealthDataConvert.unixSeconds(bedtime);
expect(statistics.earliestSleepInfo?.asleepTime, expectedBedtime);
expect(
statistics.latestSleepInfo?.asleepTime,
LocalHealthDataConvert.unixSeconds(continuedSleep),
);
expect(statistics.earliestSleepInfo?.timeKey, 20260728);
expect(statistics.latestSleepInfo?.timeKey, 20260728);
});
test('uses noon as the sleep day cutoff', () {
final july28 = DateTime(2026, 7, 28);
final july29 = DateTime(2026, 7, 29);
final july30 = DateTime(2026, 7, 30);
final beforeNoon = DateTime(2026, 7, 29, 11, 59, 59);
final atNoon = DateTime(2026, 7, 29, 12);
HealthRawSleepResult result(DateTime day, DateTime bedtime) {
return HealthRawSleepResult(
userId: 1,
date: LocalHealthDataConvert.unixSeconds(
day.add(const Duration(hours: 8)),
),
startDate: LocalHealthDataConvert.unixSeconds(bedtime),
sleepScore: 80,
sleepState: 1,
inBedMinutes: 420,
awakMinutes: 20,
sleepMinutes: 400,
);
}
final statistics = LocalHealthDataConvert.sleepStatisticsFromResults(
dateRangeType: 0,
days: [july28, july29, july30],
previousDays: const [],
sleepResults: [
result(july29, beforeNoon),
result(july30, atNoon),
],
heartRate: const [],
);
expect(
statistics.earliestSleepInfo?.asleepTime,
LocalHealthDataConvert.unixSeconds(atNoon),
);
expect(statistics.earliestSleepInfo?.timeKey, 20260729);
expect(
statistics.latestSleepInfo?.asleepTime,
LocalHealthDataConvert.unixSeconds(beforeNoon),
);
expect(statistics.latestSleepInfo?.timeKey, 20260728);
});
test('excludes records outside the report sleep-day range', () {
final july29 = DateTime(2026, 7, 29);
final overnightStart = DateTime(2026, 7, 29, 3);
final result = HealthRawSleepResult(
userId: 1,
date: LocalHealthDataConvert.unixSeconds(
july29.add(const Duration(hours: 8)),
),
startDate: LocalHealthDataConvert.unixSeconds(overnightStart),
sleepScore: 80,
sleepState: 1,
inBedMinutes: 420,
awakMinutes: 20,
sleepMinutes: 400,
);
final statistics = LocalHealthDataConvert.sleepStatisticsFromResults(
dateRangeType: 0,
days: [july29],
previousDays: const [],
sleepResults: [result],
heartRate: const [],
);
expect(statistics.earliestSleepInfo, isNull);
expect(statistics.latestSleepInfo, isNull);
});
test('sleep duration excludes awake intervals inside sleep range', () {
final day = DateTime(2026, 7, 16);
final sleepStart = DateTime(2026, 7, 15, 23, 56);
... ...