|
|
|
import 'dart:convert';
|
|
|
|
import 'dart:io';
|
|
|
|
import 'dart:math' as math;
|
|
|
|
|
|
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
|
|
|
|
import '../../l10n/gen/app_localizations.dart';
|
|
|
|
import '../../pigeon/platform_api.g.dart';
|
|
|
|
import 'health_raw_data_core_service.dart';
|
|
|
|
|
|
|
|
const healthRawTodayLink = 'doublefeel://flutter/home?tab=today';
|
|
|
|
const healthRawHrvChangeLink =
|
|
|
|
'doublefeel://flutter/home?tab=today&is_hrv_change=1';
|
|
|
|
|
|
|
|
class HealthRawLocalNotification {
|
|
|
|
const HealthRawLocalNotification({
|
|
|
|
required this.title,
|
|
|
|
required this.content,
|
|
|
|
required this.link,
|
|
|
|
required this.recordType,
|
|
|
|
required this.recordTime,
|
|
|
|
});
|
|
|
|
|
|
|
|
final String title;
|
|
|
|
final String content;
|
|
|
|
final String link;
|
|
|
|
final HealthRawLocalNotificationRecordType recordType;
|
|
|
|
final int recordTime;
|
|
|
|
}
|
|
|
|
|
|
|
|
enum HealthRawLocalNotificationRecordType {
|
|
|
|
sleep,
|
|
|
|
hrv,
|
|
|
|
realtimeStress,
|
|
|
|
}
|
|
|
|
|
|
|
|
class HealthRawLocalNotificationBuilder {
|
|
|
|
const HealthRawLocalNotificationBuilder(this.l10n);
|
|
|
|
|
|
|
|
static const int _hrvMinIntervalSeconds = 2 * 60 * 60;
|
|
|
|
static const int _realtimeWindowSeconds = 60 * 60;
|
|
|
|
static const int _realtimeMinPointCount = 10;
|
|
|
|
|
|
|
|
final AppLocalizations l10n;
|
|
|
|
|
|
|
|
List<HealthRawLocalNotification> build({
|
|
|
|
required HealthRawStressCalculationResult result,
|
|
|
|
required int? previousHrvRawEndTime,
|
|
|
|
required List<HealthRawRealtimeStressPoint> realtimeWindow,
|
|
|
|
required HealthRawLocalNotificationRecord? record,
|
|
|
|
}) {
|
|
|
|
return [
|
|
|
|
if (_sleepNotification(result.sleepResults) case final notification?)
|
|
|
|
notification,
|
|
|
|
if (_hrvNotification(
|
|
|
|
result.hrvStressPoints,
|
|
|
|
previousHrvRawEndTime,
|
|
|
|
record,
|
|
|
|
)
|
|
|
|
case final notification?)
|
|
|
|
notification,
|
|
|
|
if (_realtimeStressNotification(realtimeWindow, record)
|
|
|
|
case final notification?)
|
|
|
|
notification,
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
|
|
|
HealthRawLocalNotification? _sleepNotification(
|
|
|
|
List<HealthRawSleepResult> sleepResults,
|
|
|
|
) {
|
|
|
|
if (sleepResults.isEmpty) return null;
|
|
|
|
final latest = [...sleepResults]..sort((a, b) => a.date.compareTo(b.date));
|
|
|
|
final sleep = latest.last;
|
|
|
|
final state = _sleepStateLabel(sleep.sleepState);
|
|
|
|
if (state == null || sleep.sleepMinutes <= 0) return null;
|
|
|
|
final duration = _sleepDurationText(sleep.sleepMinutes);
|
|
|
|
return HealthRawLocalNotification(
|
|
|
|
title: l10n.healthLocalNotificationSleepTitle(duration, state),
|
|
|
|
content: l10n.healthLocalNotificationSleepContent,
|
|
|
|
link: healthRawTodayLink,
|
|
|
|
recordType: HealthRawLocalNotificationRecordType.sleep,
|
|
|
|
recordTime: sleep.date,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
HealthRawLocalNotification? _hrvNotification(
|
|
|
|
List<HealthRawHrvStressPoint> hrvPoints,
|
|
|
|
int? previousHrvRawEndTime,
|
|
|
|
HealthRawLocalNotificationRecord? record,
|
|
|
|
) {
|
|
|
|
if (hrvPoints.isEmpty) return null;
|
|
|
|
final sorted = [...hrvPoints]
|
|
|
|
..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
|
|
|
|
final latest = sorted.last;
|
|
|
|
final previousTime = sorted.length >= 2
|
|
|
|
? sorted[sorted.length - 2].rawEndTime
|
|
|
|
: previousHrvRawEndTime;
|
|
|
|
if (previousTime == null ||
|
|
|
|
latest.rawEndTime - previousTime < _hrvMinIntervalSeconds) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
if (record?.lastHrvTime == latest.rawEndTime) return null;
|
|
|
|
|
|
|
|
return HealthRawLocalNotification(
|
|
|
|
title: l10n.healthLocalNotificationHrvTitle(
|
|
|
|
latest.result.floor(),
|
|
|
|
_stressStateLabel(latest.state),
|
|
|
|
_timeText(latest.rawEndTime),
|
|
|
|
),
|
|
|
|
content: _hrvContent(latest),
|
|
|
|
link: healthRawHrvChangeLink,
|
|
|
|
recordType: HealthRawLocalNotificationRecordType.hrv,
|
|
|
|
recordTime: latest.rawEndTime,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
HealthRawLocalNotification? _realtimeStressNotification(
|
|
|
|
List<HealthRawRealtimeStressPoint> realtimeWindow,
|
|
|
|
HealthRawLocalNotificationRecord? record,
|
|
|
|
) {
|
|
|
|
final valid = realtimeWindow
|
|
|
|
.where((e) => e.result >= 1 && e.result <= 100)
|
|
|
|
.toList()
|
|
|
|
..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
|
|
|
|
if (valid.length < _realtimeMinPointCount) return null;
|
|
|
|
|
|
|
|
final latest = valid.last;
|
|
|
|
if (latest.isSleepLikely) return null;
|
|
|
|
if (record?.lastRealtimeStressTime case final lastPushTime?) {
|
|
|
|
if (latest.rawEndTime - lastPushTime < _realtimeWindowSeconds) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
final windowStart = math.max(
|
|
|
|
valid.first.rawEndTime,
|
|
|
|
latest.rawEndTime - _realtimeWindowSeconds + 1,
|
|
|
|
);
|
|
|
|
final average =
|
|
|
|
valid.map((e) => e.result).reduce((a, b) => a + b) / valid.length;
|
|
|
|
final state = healthRawRealtimeStressState(average);
|
|
|
|
return HealthRawLocalNotification(
|
|
|
|
title: l10n.healthLocalNotificationRealtimeStressTitle(
|
|
|
|
_stressStateLabel(state),
|
|
|
|
_timeText(windowStart),
|
|
|
|
_timeText(latest.rawEndTime),
|
|
|
|
),
|
|
|
|
content: _realtimeStressContent(state),
|
|
|
|
link: healthRawTodayLink,
|
|
|
|
recordType: HealthRawLocalNotificationRecordType.realtimeStress,
|
|
|
|
recordTime: latest.rawEndTime,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
String _sleepDurationText(int minutes) {
|
|
|
|
final hours = minutes ~/ 60;
|
|
|
|
final remainingMinutes = minutes % 60;
|
|
|
|
return l10n.healthLocalNotificationSleepDuration(hours, remainingMinutes);
|
|
|
|
}
|
|
|
|
|
|
|
|
String? _sleepStateLabel(int value) {
|
|
|
|
return switch (value) {
|
|
|
|
1 => l10n.sleepQualityGreat,
|
|
|
|
2 => l10n.sleepQualityGood,
|
|
|
|
3 => l10n.sleepQualityPoor,
|
|
|
|
_ => null,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
String _stressStateLabel(HealthRawStressState state) {
|
|
|
|
return switch (state) {
|
|
|
|
HealthRawStressState.excellent => l10n.inExcellentCondition,
|
|
|
|
HealthRawStressState.normal => l10n.statusNormal,
|
|
|
|
HealthRawStressState.attention => l10n.beMindfulOfStress,
|
|
|
|
HealthRawStressState.overload => l10n.pressureOverload,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
String _hrvContent(HealthRawHrvStressPoint latest) {
|
|
|
|
final isAboveBaseline = latest.result >= latest.baselineHrv;
|
|
|
|
return switch (latest.state) {
|
|
|
|
HealthRawStressState.excellent => isAboveBaseline
|
|
|
|
? l10n.latestHrvTipExcellentAboveBaseline
|
|
|
|
: l10n.latestHrvTipExcellentBelowBaseline,
|
|
|
|
HealthRawStressState.normal => isAboveBaseline
|
|
|
|
? l10n.latestHrvTipNormalAboveBaseline
|
|
|
|
: l10n.latestHrvTipNormalBelowBaseline,
|
|
|
|
HealthRawStressState.attention => isAboveBaseline
|
|
|
|
? l10n.latestHrvTipAttentionAboveBaseline
|
|
|
|
: l10n.latestHrvTipAttentionBelowBaseline,
|
|
|
|
HealthRawStressState.overload => isAboveBaseline
|
|
|
|
? l10n.latestHrvTipOverloadAboveBaseline
|
|
|
|
: l10n.latestHrvTipOverloadBelowBaseline,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
String _realtimeStressContent(HealthRawStressState state) {
|
|
|
|
return switch (state) {
|
|
|
|
HealthRawStressState.excellent =>
|
|
|
|
l10n.healthLocalNotificationRealtimeStressExcellentContent,
|
|
|
|
HealthRawStressState.normal =>
|
|
|
|
l10n.healthLocalNotificationRealtimeStressNormalContent,
|
|
|
|
HealthRawStressState.attention =>
|
|
|
|
l10n.healthLocalNotificationRealtimeStressAttentionContent,
|
|
|
|
HealthRawStressState.overload =>
|
|
|
|
l10n.healthLocalNotificationRealtimeStressOverloadContent,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
String _timeText(int seconds) {
|
|
|
|
final time = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
|
|
|
|
return '${_twoDigits(time.hour)}:${_twoDigits(time.minute)}';
|
|
|
|
}
|
|
|
|
|
|
|
|
String _twoDigits(int value) => value.toString().padLeft(2, '0');
|
|
|
|
}
|
|
|
|
|
|
|
|
class HealthRawLocalNotificationDispatcher {
|
|
|
|
HealthRawLocalNotificationDispatcher({
|
|
|
|
PlatformHostApi? platformApi,
|
|
|
|
HealthRawLocalNotificationRecordStore? recordStore,
|
|
|
|
}) : _platformApi = platformApi ?? PlatformHostApi(),
|
|
|
|
_recordStore = recordStore ?? HealthRawLocalNotificationRecordStore();
|
|
|
|
|
|
|
|
final PlatformHostApi _platformApi;
|
|
|
|
final HealthRawLocalNotificationRecordStore _recordStore;
|
|
|
|
|
|
|
|
Future<void> sendAll({
|
|
|
|
required int userId,
|
|
|
|
required Iterable<HealthRawLocalNotification> notifications,
|
|
|
|
}) async {
|
|
|
|
var record = await _recordStore.read(userId);
|
|
|
|
for (final notification in notifications) {
|
|
|
|
final sent = await _platformApi.sendLocalNotification(
|
|
|
|
notification.title,
|
|
|
|
notification.content,
|
|
|
|
notification.link,
|
|
|
|
);
|
|
|
|
if (!sent) continue;
|
|
|
|
record = record.withNotification(notification);
|
|
|
|
await _recordStore.write(userId, record);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<HealthRawLocalNotificationRecord> readRecord(int userId) {
|
|
|
|
return _recordStore.read(userId);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class HealthRawLocalNotificationRecord {
|
|
|
|
const HealthRawLocalNotificationRecord({
|
|
|
|
this.lastSleepTime,
|
|
|
|
this.lastHrvTime,
|
|
|
|
this.lastRealtimeStressTime,
|
|
|
|
});
|
|
|
|
|
|
|
|
final int? lastSleepTime;
|
|
|
|
final int? lastHrvTime;
|
|
|
|
final int? lastRealtimeStressTime;
|
|
|
|
|
|
|
|
factory HealthRawLocalNotificationRecord.fromJson(Map<String, Object?> json) {
|
|
|
|
return HealthRawLocalNotificationRecord(
|
|
|
|
lastSleepTime: (json['last_sleep_time'] as num?)?.toInt(),
|
|
|
|
lastHrvTime: (json['last_hrv_time'] as num?)?.toInt(),
|
|
|
|
lastRealtimeStressTime:
|
|
|
|
(json['last_realtime_stress_time'] as num?)?.toInt(),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Map<String, Object?> toJson() {
|
|
|
|
return <String, Object?>{
|
|
|
|
if (lastSleepTime != null) 'last_sleep_time': lastSleepTime,
|
|
|
|
if (lastHrvTime != null) 'last_hrv_time': lastHrvTime,
|
|
|
|
if (lastRealtimeStressTime != null)
|
|
|
|
'last_realtime_stress_time': lastRealtimeStressTime,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
HealthRawLocalNotificationRecord withNotification(
|
|
|
|
HealthRawLocalNotification notification,
|
|
|
|
) {
|
|
|
|
return switch (notification.recordType) {
|
|
|
|
HealthRawLocalNotificationRecordType.sleep => copyWith(
|
|
|
|
lastSleepTime: notification.recordTime,
|
|
|
|
),
|
|
|
|
HealthRawLocalNotificationRecordType.hrv => copyWith(
|
|
|
|
lastHrvTime: notification.recordTime,
|
|
|
|
),
|
|
|
|
HealthRawLocalNotificationRecordType.realtimeStress => copyWith(
|
|
|
|
lastRealtimeStressTime: notification.recordTime,
|
|
|
|
),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
HealthRawLocalNotificationRecord copyWith({
|
|
|
|
int? lastSleepTime,
|
|
|
|
int? lastHrvTime,
|
|
|
|
int? lastRealtimeStressTime,
|
|
|
|
}) {
|
|
|
|
return HealthRawLocalNotificationRecord(
|
|
|
|
lastSleepTime: lastSleepTime ?? this.lastSleepTime,
|
|
|
|
lastHrvTime: lastHrvTime ?? this.lastHrvTime,
|
|
|
|
lastRealtimeStressTime:
|
|
|
|
lastRealtimeStressTime ?? this.lastRealtimeStressTime,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class HealthRawLocalNotificationRecordStore {
|
|
|
|
HealthRawLocalNotificationRecordStore({Directory? rootDirectory})
|
|
|
|
: _rootDirectory = rootDirectory;
|
|
|
|
|
|
|
|
static const _fileName = 'health_raw_local_notification_records.json';
|
|
|
|
|
|
|
|
final Directory? _rootDirectory;
|
|
|
|
|
|
|
|
Future<HealthRawLocalNotificationRecord> read(int userId) async {
|
|
|
|
final records = await _readRecords();
|
|
|
|
final userRecord = records[userId.toString()];
|
|
|
|
if (userRecord is Map<String, Object?>) {
|
|
|
|
return HealthRawLocalNotificationRecord.fromJson(userRecord);
|
|
|
|
}
|
|
|
|
if (userRecord is Map) {
|
|
|
|
return HealthRawLocalNotificationRecord.fromJson(
|
|
|
|
userRecord.cast<String, Object?>(),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return const HealthRawLocalNotificationRecord();
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> write(
|
|
|
|
int userId,
|
|
|
|
HealthRawLocalNotificationRecord record,
|
|
|
|
) async {
|
|
|
|
final records = await _readRecords();
|
|
|
|
records[userId.toString()] = record.toJson();
|
|
|
|
final file = await _file();
|
|
|
|
await file.writeAsString(jsonEncode(records), flush: true);
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<Map<String, Object?>> _readRecords() async {
|
|
|
|
final file = await _file();
|
|
|
|
if (!await file.exists()) return <String, Object?>{};
|
|
|
|
final content = await file.readAsString();
|
|
|
|
if (content.trim().isEmpty) return <String, Object?>{};
|
|
|
|
final decoded = jsonDecode(content);
|
|
|
|
if (decoded is Map<String, Object?>) return decoded;
|
|
|
|
if (decoded is Map) return decoded.cast<String, Object?>();
|
|
|
|
return <String, Object?>{};
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<File> _file() async {
|
|
|
|
final dir = _rootDirectory ?? await getApplicationDocumentsDirectory();
|
|
|
|
if (!await dir.exists()) {
|
|
|
|
await dir.create(recursive: true);
|
|
|
|
}
|
|
|
|
return File('${dir.path}/$_fileName');
|
|
|
|
}
|
|
|
|
} |
...
|
...
|
|