refactor(device_management): migrate health to Riverpod + improve charts
This commit is contained in:
@@ -1,23 +1,22 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:legacy_theme/legacy_theme.dart';
|
||||
import 'package:device_management/src/core/presentation/widgets/time_range_selector.dart';
|
||||
import 'package:device_management/src/features/health/presentation/providers/health_controller.dart';
|
||||
import 'package:device_management/src/features/health/presentation/providers/health_state.dart';
|
||||
import 'package:device_management/src/features/health/presentation/widgets/blood_pressure_tab.dart';
|
||||
import 'package:device_management/src/features/health/presentation/widgets/health_summary_cards.dart';
|
||||
import 'package:device_management/src/features/health/presentation/widgets/heart_rate_tab.dart';
|
||||
import 'package:device_management/src/features/health/presentation/widgets/oxygen_tab.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:legacy_device_state/legacy_device_state.dart';
|
||||
import 'package:legacy_theme/legacy_theme.dart';
|
||||
import 'package:legacy_ui/legacy_ui.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:navigation/navigation.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
import 'package:utils/utils.dart';
|
||||
|
||||
import 'state/health_view_model.dart';
|
||||
import 'state/health_view_state.dart';
|
||||
import 'widgets/blood_pressure_tab.dart';
|
||||
import 'widgets/health_summary_cards.dart';
|
||||
import 'widgets/heart_rate_tab.dart';
|
||||
import 'widgets/oxygen_tab.dart';
|
||||
import '../../../core/presentation/widgets/time_range_selector.dart';
|
||||
|
||||
class HealthScreen extends ConsumerStatefulWidget {
|
||||
final NavigationContract navigationContract;
|
||||
|
||||
@@ -43,7 +42,7 @@ class _HealthScreenState extends ConsumerState<HealthScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickCustomRange(HealthViewModel vm) async {
|
||||
Future<void> _pickCustomRange(HealthController notifier) async {
|
||||
final now = DateTime.now();
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
@@ -51,125 +50,120 @@ class _HealthScreenState extends ConsumerState<HealthScreen>
|
||||
lastDate: now,
|
||||
);
|
||||
if (picked != null) {
|
||||
vm.selectCustomRange(picked.start, picked.end);
|
||||
notifier.selectCustomRange(picked.start, picked.end);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(healthViewModelProvider);
|
||||
final vm = ref.read(healthViewModelProvider.notifier);
|
||||
final state = ref.watch(healthControllerProvider);
|
||||
final notifier = ref.read(healthControllerProvider.notifier);
|
||||
final device = ref.watch(selectedDeviceProvider).value;
|
||||
|
||||
ref.listen(healthViewModelProvider.select((s) => s.errorEvent), (
|
||||
previous,
|
||||
next,
|
||||
) {
|
||||
if (next != null) {
|
||||
final message = switch (next) {
|
||||
HealthErrorEvent.loadData => context.translate(I18n.errorHealthData),
|
||||
HealthErrorEvent.loadMore => context.translate(I18n.errorHealthData),
|
||||
HealthErrorEvent.measure => context.translate(
|
||||
I18n.errorHealthMeasure,
|
||||
),
|
||||
HealthErrorEvent.heartRateFrequency => context.translate(
|
||||
ref.listen(
|
||||
healthControllerProvider.select((s) => s.errorEvent),
|
||||
(_, next) async {
|
||||
if (next == null) return;
|
||||
final key = switch (next) {
|
||||
HealthErrorEvent.loadData ||
|
||||
HealthErrorEvent.loadMore =>
|
||||
I18n.errorHealthData,
|
||||
HealthErrorEvent.measure => I18n.errorHealthMeasure,
|
||||
HealthErrorEvent.heartRateFrequency =>
|
||||
I18n.errorHeartRateFrequency,
|
||||
),
|
||||
};
|
||||
showTopSnackbar(context, message: message, type: MessageType.error);
|
||||
}
|
||||
});
|
||||
await showErrorDialog(context, key);
|
||||
notifier.clearError();
|
||||
},
|
||||
);
|
||||
|
||||
return LegacyPageLayout(
|
||||
title: context.translate(I18n.healthTitle),
|
||||
body: state.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: state.isMeasuringCountdown
|
||||
? _MeasuringOverlay(
|
||||
remainingSeconds: state.measureRemainingSeconds,
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
HealthSummaryCards(
|
||||
heartbeats: state.latestHeartbeats,
|
||||
oxygens: state.latestOxygens,
|
||||
tabController: _tabController,
|
||||
),
|
||||
TimeRangeSelector(
|
||||
selected: state.timeRange,
|
||||
onSelected: (range) => vm.selectTimeRange(range),
|
||||
onCustomTap: () => _pickCustomRange(vm),
|
||||
),
|
||||
if (device?.capabilities?.heartbeats != null &&
|
||||
device!.capabilities!.heartbeats!.options.isNotEmpty)
|
||||
_HeartRateFrequencySelector(
|
||||
currentFrequency: device.settings.frequencyHeartRate,
|
||||
options: device.capabilities!.heartbeats!.options,
|
||||
onChanged: (frequency) async {
|
||||
if (!await guardDeviceCommand(context, ref)) return;
|
||||
final success = await vm.updateHeartRateFrequency(
|
||||
frequency: frequency,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
if (success) {
|
||||
showTopSnackbar(
|
||||
context,
|
||||
message: context.translate(
|
||||
I18n.locationFrequencyUpdated,
|
||||
args: {'minutes': '${frequency ~/ 60}'},
|
||||
? _MeasuringOverlay(
|
||||
remainingSeconds: state.measureRemainingSeconds,
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
HealthSummaryCards(
|
||||
heartbeats: state.latestHeartbeats,
|
||||
oxygens: state.latestOxygens,
|
||||
tabController: _tabController,
|
||||
),
|
||||
TimeRangeSelector(
|
||||
selected: state.timeRange,
|
||||
onSelected: notifier.selectTimeRange,
|
||||
onCustomTap: () => _pickCustomRange(notifier),
|
||||
),
|
||||
if (device?.capabilities?.heartbeats != null &&
|
||||
device!.capabilities!.heartbeats!.options.isNotEmpty)
|
||||
_HeartRateFrequencySelector(
|
||||
currentFrequency: device.settings.frequencyHeartRate,
|
||||
options: device.capabilities!.heartbeats!.options,
|
||||
onChanged: (frequency) async {
|
||||
if (!await guardDeviceCommand(context, ref)) return;
|
||||
final success = await notifier
|
||||
.updateHeartRateFrequency(frequency: frequency);
|
||||
if (!context.mounted) return;
|
||||
if (success) {
|
||||
await showSuccessDialog(
|
||||
context,
|
||||
I18n.locationFrequencyUpdated,
|
||||
args: {'minutes': '${frequency ~/ 60}'},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: context.sfColors.legacyPrimary,
|
||||
unselectedLabelColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.5),
|
||||
indicatorColor: context.sfColors.legacyPrimary,
|
||||
labelStyle: TextStyle(
|
||||
fontSize: SizeUtils.getByScreen(small: 13, big: 12),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
tabs: [
|
||||
Tab(text: context.translate(I18n.heartRate)),
|
||||
Tab(text: context.translate(I18n.bloodPressure)),
|
||||
Tab(text: context.translate(I18n.oxygenLevel)),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
HeartRateTab(
|
||||
chartData: state.chartHeartbeats,
|
||||
historyData: state.historyHeartbeats,
|
||||
stats: state.heartRateStats,
|
||||
hasMore: state.hasMoreHistory,
|
||||
isLoadingMore: state.isLoadingMore,
|
||||
),
|
||||
type: MessageType.success,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: context.sfColors.legacyPrimary,
|
||||
unselectedLabelColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
indicatorColor: context.sfColors.legacyPrimary,
|
||||
labelStyle: TextStyle(
|
||||
fontSize: SizeUtils.getByScreen(small: 13, big: 12),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
tabs: [
|
||||
Tab(text: context.translate(I18n.heartRate)),
|
||||
Tab(text: context.translate(I18n.bloodPressure)),
|
||||
Tab(text: context.translate(I18n.oxygenLevel)),
|
||||
BloodPressureTab(
|
||||
chartData: state.chartHeartbeats,
|
||||
historyData: state.historyHeartbeats,
|
||||
hasMore: state.hasMoreHistory,
|
||||
isLoadingMore: state.isLoadingMore,
|
||||
),
|
||||
OxygenTab(
|
||||
chartData: state.chartOxygens,
|
||||
historyData: state.historyOxygens,
|
||||
stats: state.oxygenStats,
|
||||
hasMore: state.hasMoreHistory,
|
||||
isLoadingMore: state.isLoadingMore,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
HeartRateTab(
|
||||
chartData: state.chartHeartbeats,
|
||||
historyData: state.historyHeartbeats,
|
||||
stats: state.heartRateStats,
|
||||
hasMore: state.hasMoreHistory,
|
||||
isLoadingMore: state.isLoadingMore,
|
||||
),
|
||||
BloodPressureTab(
|
||||
chartData: state.chartHeartbeats,
|
||||
historyData: state.historyHeartbeats,
|
||||
hasMore: state.hasMoreHistory,
|
||||
isLoadingMore: state.isLoadingMore,
|
||||
),
|
||||
OxygenTab(
|
||||
chartData: state.chartOxygens,
|
||||
historyData: state.historyOxygens,
|
||||
stats: state.oxygenStats,
|
||||
hasMore: state.hasMoreHistory,
|
||||
isLoadingMore: state.isLoadingMore,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
footer: _SaveSection(),
|
||||
footer: const _SaveSection(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -179,24 +173,22 @@ class _SaveSection extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final vm = ref.read(healthViewModelProvider.notifier);
|
||||
final isMeasuring = ref.watch(
|
||||
healthViewModelProvider.select((s) => s.isMeasuring),
|
||||
);
|
||||
final isCountdown = ref.watch(
|
||||
healthViewModelProvider.select((s) => s.isMeasuringCountdown),
|
||||
);
|
||||
final notifier = ref.read(healthControllerProvider.notifier);
|
||||
final isMeasuring =
|
||||
ref.watch(healthControllerProvider.select((s) => s.isMeasuring));
|
||||
final isCountdown = ref
|
||||
.watch(healthControllerProvider.select((s) => s.isMeasuringCountdown));
|
||||
|
||||
if (isCountdown) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
|
||||
child: PrimaryButton(
|
||||
onPressed: isMeasuring
|
||||
? null
|
||||
: () async {
|
||||
if (!await guardDeviceCommand(context, ref)) return;
|
||||
vm.measure();
|
||||
notifier.measure();
|
||||
},
|
||||
text: isMeasuring ? '...' : context.translate(I18n.measure),
|
||||
color: context.sfColors.legacyPrimary,
|
||||
|
||||
@@ -1,59 +1,42 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:device_management/src/core/data/datasources/health_query_builder.dart';
|
||||
import 'package:device_management/src/core/presentation/time_range.dart';
|
||||
import 'package:device_management/src/core/providers/health_repository_provider.dart';
|
||||
import 'package:device_management/src/features/health/presentation/providers/health_state.dart';
|
||||
import 'package:device_management/src/features/health/presentation/providers/measure_end_time_provider.dart';
|
||||
import 'package:legacy_device_state/legacy_device_state.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:sf_shared/sf_shared.dart';
|
||||
import 'package:sf_tracking/sf_tracking.dart';
|
||||
|
||||
import '../../../../core/data/datasources/health_query_builder.dart';
|
||||
import '../../../../core/presentation/time_range.dart';
|
||||
import '../../../../core/domain/repositories/health_repository.dart';
|
||||
import '../../../../core/providers/health_repository_provider.dart';
|
||||
import 'health_view_state.dart';
|
||||
part 'health_controller.g.dart';
|
||||
|
||||
final _measureEndTimeProvider =
|
||||
NotifierProvider<_MeasureEndTimeNotifier, DateTime?>(
|
||||
_MeasureEndTimeNotifier.new,
|
||||
);
|
||||
const int _historyPageSize = 20;
|
||||
const int _measureDurationSeconds = 60;
|
||||
|
||||
class _MeasureEndTimeNotifier extends Notifier<DateTime?> {
|
||||
@override
|
||||
DateTime? build() => null;
|
||||
|
||||
void set(DateTime? value) => state = value;
|
||||
}
|
||||
|
||||
final healthViewModelProvider =
|
||||
NotifierProvider.autoDispose<HealthViewModel, HealthViewState>(
|
||||
HealthViewModel.new,
|
||||
);
|
||||
|
||||
class HealthViewModel extends Notifier<HealthViewState> {
|
||||
late final HealthRepository _repository;
|
||||
late final CommandsRepository _commandsRepository;
|
||||
late final SfTrackingRepository _tracking;
|
||||
@riverpod
|
||||
class HealthController extends _$HealthController {
|
||||
Timer? _measureTimer;
|
||||
|
||||
static const int _historyPageSize = 20;
|
||||
static const int _measureDurationSeconds = 60;
|
||||
|
||||
@override
|
||||
HealthViewState build() {
|
||||
_repository = ref.read(healthRepositoryProvider);
|
||||
_commandsRepository = ref.read(commandsRepositoryProvider);
|
||||
_tracking = ref.read(sfTrackingProvider);
|
||||
HealthState build() {
|
||||
ref.onDispose(() => _measureTimer?.cancel());
|
||||
_init();
|
||||
_resumeMeasureIfNeeded();
|
||||
return const HealthViewState();
|
||||
return const HealthState();
|
||||
}
|
||||
|
||||
String? get _identificator =>
|
||||
ref.read(selectedDeviceProvider).value?.identificator;
|
||||
|
||||
void _resumeMeasureIfNeeded() {
|
||||
final endTime = ref.read(_measureEndTimeProvider);
|
||||
final endTime = ref.read(measureEndTimeProvider);
|
||||
if (endTime == null) return;
|
||||
|
||||
final remaining = endTime.difference(DateTime.now()).inSeconds;
|
||||
if (remaining <= 0) {
|
||||
ref.read(_measureEndTimeProvider.notifier).set(null);
|
||||
ref.read(measureEndTimeProvider.notifier).set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,13 +49,13 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
});
|
||||
}
|
||||
|
||||
String? get _identificator => ref.read(selectedDeviceProvider).value?.identificator;
|
||||
|
||||
Future<void> selectTimeRange(TimeRange range) async {
|
||||
if (range == state.timeRange) return;
|
||||
state = state.copyWith(timeRange: range, isLoading: true, errorEvent: null);
|
||||
unawaited(
|
||||
_tracking.legacyDeviceHealthTimeRangeChanged(_timeRangeName(range)),
|
||||
ref
|
||||
.read(sfTrackingProvider)
|
||||
.legacyDeviceHealthTimeRangeChanged(_timeRangeName(range)),
|
||||
);
|
||||
await _loadFilteredData();
|
||||
}
|
||||
@@ -85,22 +68,20 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
isLoading: true,
|
||||
errorEvent: null,
|
||||
);
|
||||
unawaited(_tracking.legacyDeviceHealthTimeRangeChanged('custom'));
|
||||
unawaited(
|
||||
ref
|
||||
.read(sfTrackingProvider)
|
||||
.legacyDeviceHealthTimeRangeChanged('custom'),
|
||||
);
|
||||
await _loadFilteredData();
|
||||
}
|
||||
|
||||
String _timeRangeName(TimeRange range) {
|
||||
switch (range) {
|
||||
case TimeRange.today:
|
||||
return 'today';
|
||||
case TimeRange.sevenDays:
|
||||
return 'seven_days';
|
||||
case TimeRange.thirtyDays:
|
||||
return 'thirty_days';
|
||||
case TimeRange.custom:
|
||||
return 'custom';
|
||||
}
|
||||
}
|
||||
String _timeRangeName(TimeRange range) => switch (range) {
|
||||
TimeRange.today => 'today',
|
||||
TimeRange.sevenDays => 'seven_days',
|
||||
TimeRange.thirtyDays => 'thirty_days',
|
||||
TimeRange.custom => 'custom',
|
||||
};
|
||||
|
||||
Future<void> loadMoreHistory() async {
|
||||
if (state.isLoadingMore || !state.hasMoreHistory) return;
|
||||
@@ -108,13 +89,14 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
if (identificator == null) return;
|
||||
|
||||
state = state.copyWith(isLoadingMore: true);
|
||||
final repo = ref.read(healthRepositoryProvider);
|
||||
|
||||
try {
|
||||
final nextPage = state.currentHistoryPage + 1;
|
||||
final filters = _buildTimeFilters();
|
||||
|
||||
final (heartbeats, oxygens) = await (
|
||||
_repository.getHeartbeats(
|
||||
repo.getHeartbeats(
|
||||
identificator: identificator,
|
||||
queryParameters: HealthQueryBuilder.build(
|
||||
orderDirection: OrderDirection.desc,
|
||||
@@ -123,7 +105,7 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
filters: filters,
|
||||
),
|
||||
),
|
||||
_repository.getOxygens(
|
||||
repo.getOxygens(
|
||||
identificator: identificator,
|
||||
queryParameters: HealthQueryBuilder.build(
|
||||
orderDirection: OrderDirection.desc,
|
||||
@@ -139,12 +121,11 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
historyHeartbeats: [...state.historyHeartbeats, ...heartbeats],
|
||||
historyOxygens: [...state.historyOxygens, ...oxygens],
|
||||
currentHistoryPage: nextPage,
|
||||
hasMoreHistory:
|
||||
heartbeats.length >= _historyPageSize ||
|
||||
hasMoreHistory: heartbeats.length >= _historyPageSize ||
|
||||
oxygens.length >= _historyPageSize,
|
||||
isLoadingMore: false,
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
isLoadingMore: false,
|
||||
@@ -159,17 +140,18 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
state = state.copyWith(isLoading: false);
|
||||
return;
|
||||
}
|
||||
final repo = ref.read(healthRepositoryProvider);
|
||||
|
||||
try {
|
||||
final (latestHeartbeats, latestOxygens) = await (
|
||||
_repository.getHeartbeats(
|
||||
repo.getHeartbeats(
|
||||
identificator: identificator,
|
||||
queryParameters: HealthQueryBuilder.build(
|
||||
orderDirection: OrderDirection.desc,
|
||||
pageSize: 5,
|
||||
),
|
||||
),
|
||||
_repository.getOxygens(
|
||||
repo.getOxygens(
|
||||
identificator: identificator,
|
||||
queryParameters: HealthQueryBuilder.build(
|
||||
orderDirection: OrderDirection.desc,
|
||||
@@ -185,7 +167,7 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
);
|
||||
|
||||
await _loadFilteredData();
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
@@ -200,6 +182,7 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
state = state.copyWith(isLoading: false);
|
||||
return;
|
||||
}
|
||||
final repo = ref.read(healthRepositoryProvider);
|
||||
|
||||
try {
|
||||
final filters = _buildTimeFilters();
|
||||
@@ -210,7 +193,7 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
histHeartbeats,
|
||||
histOxygens,
|
||||
) = await (
|
||||
_repository.getHeartbeats(
|
||||
repo.getHeartbeats(
|
||||
identificator: identificator,
|
||||
queryParameters: HealthQueryBuilder.build(
|
||||
orderDirection: OrderDirection.asc,
|
||||
@@ -219,7 +202,7 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
filters: filters,
|
||||
),
|
||||
),
|
||||
_repository.getOxygens(
|
||||
repo.getOxygens(
|
||||
identificator: identificator,
|
||||
queryParameters: HealthQueryBuilder.build(
|
||||
orderDirection: OrderDirection.asc,
|
||||
@@ -228,7 +211,7 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
filters: filters,
|
||||
),
|
||||
),
|
||||
_repository.getHeartbeats(
|
||||
repo.getHeartbeats(
|
||||
identificator: identificator,
|
||||
queryParameters: HealthQueryBuilder.build(
|
||||
orderDirection: OrderDirection.desc,
|
||||
@@ -237,7 +220,7 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
filters: filters,
|
||||
),
|
||||
),
|
||||
_repository.getOxygens(
|
||||
repo.getOxygens(
|
||||
identificator: identificator,
|
||||
queryParameters: HealthQueryBuilder.build(
|
||||
orderDirection: OrderDirection.desc,
|
||||
@@ -255,17 +238,17 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
historyHeartbeats: histHeartbeats,
|
||||
historyOxygens: histOxygens,
|
||||
currentHistoryPage: 1,
|
||||
hasMoreHistory:
|
||||
histHeartbeats.length >= _historyPageSize ||
|
||||
hasMoreHistory: histHeartbeats.length >= _historyPageSize ||
|
||||
histOxygens.length >= _historyPageSize,
|
||||
heartRateStats: _computeStats(
|
||||
chartHeartbeats.map((e) => e.heartbeats).toList(),
|
||||
),
|
||||
oxygenStats: _computeStats(chartOxygens.map((e) => e.oxygen).toList()),
|
||||
oxygenStats:
|
||||
_computeStats(chartOxygens.map((e) => e.oxygen).toList()),
|
||||
isLoading: false,
|
||||
errorEvent: null,
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
@@ -277,7 +260,6 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
List<HealthFilter>? _buildTimeFilters() {
|
||||
final range = _getTimeRange();
|
||||
if (range == null) return null;
|
||||
|
||||
final (start, end) = range;
|
||||
return HealthQueryBuilder.timeRangeFilters(start: start, end: end);
|
||||
}
|
||||
@@ -328,11 +310,13 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
ref.syncDeviceSettings(device, updatedSettings);
|
||||
|
||||
unawaited(
|
||||
_tracking.legacyDeviceHealthHeartRateFrequencyChanged(frequency),
|
||||
ref
|
||||
.read(sfTrackingProvider)
|
||||
.legacyDeviceHealthHeartRateFrequencyChanged(frequency),
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
if (!ref.mounted) return false;
|
||||
state = state.copyWith(errorEvent: HealthErrorEvent.heartRateFrequency);
|
||||
return false;
|
||||
@@ -346,21 +330,21 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
try {
|
||||
state = state.copyWith(isMeasuring: true, errorEvent: null);
|
||||
|
||||
final request = SendCommandRequestModel(
|
||||
device: device.identificator,
|
||||
command: DeviceCommand.requestHeartRate,
|
||||
);
|
||||
await _commandsRepository.send(request: request);
|
||||
await ref.read(commandsRepositoryProvider).send(
|
||||
request: SendCommandRequestModel(
|
||||
device: device.identificator,
|
||||
command: DeviceCommand.requestHeartRate,
|
||||
),
|
||||
);
|
||||
if (!ref.mounted) return;
|
||||
|
||||
unawaited(_tracking.legacyDeviceHealthMeasurementStarted());
|
||||
unawaited(
|
||||
ref.read(sfTrackingProvider).legacyDeviceHealthMeasurementStarted(),
|
||||
);
|
||||
|
||||
ref
|
||||
.read(_measureEndTimeProvider.notifier)
|
||||
.set(
|
||||
DateTime.now().add(
|
||||
const Duration(seconds: _measureDurationSeconds),
|
||||
),
|
||||
ref.read(measureEndTimeProvider.notifier).set(
|
||||
DateTime.now()
|
||||
.add(const Duration(seconds: _measureDurationSeconds)),
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
@@ -370,7 +354,7 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
);
|
||||
|
||||
_startCountdownTimer();
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
isMeasuring: false,
|
||||
@@ -390,7 +374,7 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
if (remaining <= 0) {
|
||||
timer.cancel();
|
||||
_measureTimer = null;
|
||||
ref.read(_measureEndTimeProvider.notifier).set(null);
|
||||
ref.read(measureEndTimeProvider.notifier).set(null);
|
||||
state = state.copyWith(
|
||||
isMeasuringCountdown: false,
|
||||
measureRemainingSeconds: 0,
|
||||
@@ -401,4 +385,9 @@ class HealthViewModel extends Notifier<HealthViewState> {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
if (state.errorEvent == null) return;
|
||||
state = state.copyWith(errorEvent: null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'health_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(HealthController)
|
||||
const healthControllerProvider = HealthControllerProvider._();
|
||||
|
||||
final class HealthControllerProvider
|
||||
extends $NotifierProvider<HealthController, HealthState> {
|
||||
const HealthControllerProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'healthControllerProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$healthControllerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
HealthController create() => HealthController();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(HealthState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<HealthState>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$healthControllerHash() => r'601f8ead6d6b580f0fd731c967566a31a97ab603';
|
||||
|
||||
abstract class _$HealthController extends $Notifier<HealthState> {
|
||||
HealthState build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<HealthState, HealthState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<HealthState, HealthState>,
|
||||
HealthState,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:device_management/src/core/presentation/time_range.dart';
|
||||
import 'package:device_management/src/features/health/domain/entities/heartbeat_entity.dart';
|
||||
import 'package:device_management/src/features/health/domain/entities/oxygen_entity.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../../../../core/presentation/time_range.dart';
|
||||
import '../../domain/entities/heartbeat_entity.dart';
|
||||
import '../../domain/entities/oxygen_entity.dart';
|
||||
|
||||
part 'health_view_state.freezed.dart';
|
||||
part 'health_state.freezed.dart';
|
||||
|
||||
enum HealthErrorEvent { loadData, loadMore, measure, heartRateFrequency }
|
||||
|
||||
@@ -18,8 +17,8 @@ abstract class HealthStats with _$HealthStats {
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class HealthViewState with _$HealthViewState {
|
||||
const factory HealthViewState({
|
||||
abstract class HealthState with _$HealthState {
|
||||
const factory HealthState({
|
||||
@Default([]) List<HeartbeatEntity> latestHeartbeats,
|
||||
@Default([]) List<OxygenEntity> latestOxygens,
|
||||
@Default([]) List<HeartbeatEntity> chartHeartbeats,
|
||||
@@ -31,13 +30,13 @@ abstract class HealthViewState with _$HealthViewState {
|
||||
@Default(HealthStats()) HealthStats heartRateStats,
|
||||
@Default(HealthStats()) HealthStats oxygenStats,
|
||||
@Default(TimeRange.today) TimeRange timeRange,
|
||||
@Default(null) DateTime? customStart,
|
||||
@Default(null) DateTime? customEnd,
|
||||
DateTime? customStart,
|
||||
DateTime? customEnd,
|
||||
@Default(true) bool isLoading,
|
||||
@Default(false) bool isLoadingMore,
|
||||
@Default(false) bool isMeasuring,
|
||||
@Default(false) bool isMeasuringCountdown,
|
||||
@Default(0) int measureRemainingSeconds,
|
||||
HealthErrorEvent? errorEvent,
|
||||
}) = _HealthViewState;
|
||||
}) = _HealthState;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'health_view_state.dart';
|
||||
part of 'health_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
@@ -275,20 +275,20 @@ as int,
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$HealthViewState {
|
||||
mixin _$HealthState {
|
||||
|
||||
List<HeartbeatEntity> get latestHeartbeats; List<OxygenEntity> get latestOxygens; List<HeartbeatEntity> get chartHeartbeats; List<OxygenEntity> get chartOxygens; List<HeartbeatEntity> get historyHeartbeats; List<OxygenEntity> get historyOxygens; int get currentHistoryPage; bool get hasMoreHistory; HealthStats get heartRateStats; HealthStats get oxygenStats; TimeRange get timeRange; DateTime? get customStart; DateTime? get customEnd; bool get isLoading; bool get isLoadingMore; bool get isMeasuring; bool get isMeasuringCountdown; int get measureRemainingSeconds; HealthErrorEvent? get errorEvent;
|
||||
/// Create a copy of HealthViewState
|
||||
/// Create a copy of HealthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$HealthViewStateCopyWith<HealthViewState> get copyWith => _$HealthViewStateCopyWithImpl<HealthViewState>(this as HealthViewState, _$identity);
|
||||
$HealthStateCopyWith<HealthState> get copyWith => _$HealthStateCopyWithImpl<HealthState>(this as HealthState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is HealthViewState&&const DeepCollectionEquality().equals(other.latestHeartbeats, latestHeartbeats)&&const DeepCollectionEquality().equals(other.latestOxygens, latestOxygens)&&const DeepCollectionEquality().equals(other.chartHeartbeats, chartHeartbeats)&&const DeepCollectionEquality().equals(other.chartOxygens, chartOxygens)&&const DeepCollectionEquality().equals(other.historyHeartbeats, historyHeartbeats)&&const DeepCollectionEquality().equals(other.historyOxygens, historyOxygens)&&(identical(other.currentHistoryPage, currentHistoryPage) || other.currentHistoryPage == currentHistoryPage)&&(identical(other.hasMoreHistory, hasMoreHistory) || other.hasMoreHistory == hasMoreHistory)&&(identical(other.heartRateStats, heartRateStats) || other.heartRateStats == heartRateStats)&&(identical(other.oxygenStats, oxygenStats) || other.oxygenStats == oxygenStats)&&(identical(other.timeRange, timeRange) || other.timeRange == timeRange)&&(identical(other.customStart, customStart) || other.customStart == customStart)&&(identical(other.customEnd, customEnd) || other.customEnd == customEnd)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&(identical(other.isMeasuring, isMeasuring) || other.isMeasuring == isMeasuring)&&(identical(other.isMeasuringCountdown, isMeasuringCountdown) || other.isMeasuringCountdown == isMeasuringCountdown)&&(identical(other.measureRemainingSeconds, measureRemainingSeconds) || other.measureRemainingSeconds == measureRemainingSeconds)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is HealthState&&const DeepCollectionEquality().equals(other.latestHeartbeats, latestHeartbeats)&&const DeepCollectionEquality().equals(other.latestOxygens, latestOxygens)&&const DeepCollectionEquality().equals(other.chartHeartbeats, chartHeartbeats)&&const DeepCollectionEquality().equals(other.chartOxygens, chartOxygens)&&const DeepCollectionEquality().equals(other.historyHeartbeats, historyHeartbeats)&&const DeepCollectionEquality().equals(other.historyOxygens, historyOxygens)&&(identical(other.currentHistoryPage, currentHistoryPage) || other.currentHistoryPage == currentHistoryPage)&&(identical(other.hasMoreHistory, hasMoreHistory) || other.hasMoreHistory == hasMoreHistory)&&(identical(other.heartRateStats, heartRateStats) || other.heartRateStats == heartRateStats)&&(identical(other.oxygenStats, oxygenStats) || other.oxygenStats == oxygenStats)&&(identical(other.timeRange, timeRange) || other.timeRange == timeRange)&&(identical(other.customStart, customStart) || other.customStart == customStart)&&(identical(other.customEnd, customEnd) || other.customEnd == customEnd)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&(identical(other.isMeasuring, isMeasuring) || other.isMeasuring == isMeasuring)&&(identical(other.isMeasuringCountdown, isMeasuringCountdown) || other.isMeasuringCountdown == isMeasuringCountdown)&&(identical(other.measureRemainingSeconds, measureRemainingSeconds) || other.measureRemainingSeconds == measureRemainingSeconds)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent));
|
||||
}
|
||||
|
||||
|
||||
@@ -297,15 +297,15 @@ int get hashCode => Object.hashAll([runtimeType,const DeepCollectionEquality().h
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'HealthViewState(latestHeartbeats: $latestHeartbeats, latestOxygens: $latestOxygens, chartHeartbeats: $chartHeartbeats, chartOxygens: $chartOxygens, historyHeartbeats: $historyHeartbeats, historyOxygens: $historyOxygens, currentHistoryPage: $currentHistoryPage, hasMoreHistory: $hasMoreHistory, heartRateStats: $heartRateStats, oxygenStats: $oxygenStats, timeRange: $timeRange, customStart: $customStart, customEnd: $customEnd, isLoading: $isLoading, isLoadingMore: $isLoadingMore, isMeasuring: $isMeasuring, isMeasuringCountdown: $isMeasuringCountdown, measureRemainingSeconds: $measureRemainingSeconds, errorEvent: $errorEvent)';
|
||||
return 'HealthState(latestHeartbeats: $latestHeartbeats, latestOxygens: $latestOxygens, chartHeartbeats: $chartHeartbeats, chartOxygens: $chartOxygens, historyHeartbeats: $historyHeartbeats, historyOxygens: $historyOxygens, currentHistoryPage: $currentHistoryPage, hasMoreHistory: $hasMoreHistory, heartRateStats: $heartRateStats, oxygenStats: $oxygenStats, timeRange: $timeRange, customStart: $customStart, customEnd: $customEnd, isLoading: $isLoading, isLoadingMore: $isLoadingMore, isMeasuring: $isMeasuring, isMeasuringCountdown: $isMeasuringCountdown, measureRemainingSeconds: $measureRemainingSeconds, errorEvent: $errorEvent)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $HealthViewStateCopyWith<$Res> {
|
||||
factory $HealthViewStateCopyWith(HealthViewState value, $Res Function(HealthViewState) _then) = _$HealthViewStateCopyWithImpl;
|
||||
abstract mixin class $HealthStateCopyWith<$Res> {
|
||||
factory $HealthStateCopyWith(HealthState value, $Res Function(HealthState) _then) = _$HealthStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
List<HeartbeatEntity> latestHeartbeats, List<OxygenEntity> latestOxygens, List<HeartbeatEntity> chartHeartbeats, List<OxygenEntity> chartOxygens, List<HeartbeatEntity> historyHeartbeats, List<OxygenEntity> historyOxygens, int currentHistoryPage, bool hasMoreHistory, HealthStats heartRateStats, HealthStats oxygenStats, TimeRange timeRange, DateTime? customStart, DateTime? customEnd, bool isLoading, bool isLoadingMore, bool isMeasuring, bool isMeasuringCountdown, int measureRemainingSeconds, HealthErrorEvent? errorEvent
|
||||
@@ -316,14 +316,14 @@ $HealthStatsCopyWith<$Res> get heartRateStats;$HealthStatsCopyWith<$Res> get oxy
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$HealthViewStateCopyWithImpl<$Res>
|
||||
implements $HealthViewStateCopyWith<$Res> {
|
||||
_$HealthViewStateCopyWithImpl(this._self, this._then);
|
||||
class _$HealthStateCopyWithImpl<$Res>
|
||||
implements $HealthStateCopyWith<$Res> {
|
||||
_$HealthStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final HealthViewState _self;
|
||||
final $Res Function(HealthViewState) _then;
|
||||
final HealthState _self;
|
||||
final $Res Function(HealthState) _then;
|
||||
|
||||
/// Create a copy of HealthViewState
|
||||
/// Create a copy of HealthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? latestHeartbeats = null,Object? latestOxygens = null,Object? chartHeartbeats = null,Object? chartOxygens = null,Object? historyHeartbeats = null,Object? historyOxygens = null,Object? currentHistoryPage = null,Object? hasMoreHistory = null,Object? heartRateStats = null,Object? oxygenStats = null,Object? timeRange = null,Object? customStart = freezed,Object? customEnd = freezed,Object? isLoading = null,Object? isLoadingMore = null,Object? isMeasuring = null,Object? isMeasuringCountdown = null,Object? measureRemainingSeconds = null,Object? errorEvent = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
@@ -349,7 +349,7 @@ as int,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // igno
|
||||
as HealthErrorEvent?,
|
||||
));
|
||||
}
|
||||
/// Create a copy of HealthViewState
|
||||
/// Create a copy of HealthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
@@ -358,7 +358,7 @@ $HealthStatsCopyWith<$Res> get heartRateStats {
|
||||
return $HealthStatsCopyWith<$Res>(_self.heartRateStats, (value) {
|
||||
return _then(_self.copyWith(heartRateStats: value));
|
||||
});
|
||||
}/// Create a copy of HealthViewState
|
||||
}/// Create a copy of HealthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
@@ -371,8 +371,8 @@ $HealthStatsCopyWith<$Res> get oxygenStats {
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [HealthViewState].
|
||||
extension HealthViewStatePatterns on HealthViewState {
|
||||
/// Adds pattern-matching-related methods to [HealthState].
|
||||
extension HealthStatePatterns on HealthState {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
@@ -385,10 +385,10 @@ extension HealthViewStatePatterns on HealthViewState {
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _HealthViewState value)? $default,{required TResult orElse(),}){
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _HealthState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _HealthViewState() when $default != null:
|
||||
case _HealthState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
@@ -407,10 +407,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _HealthViewState value) $default,){
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _HealthState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _HealthViewState():
|
||||
case _HealthState():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
@@ -428,10 +428,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _HealthViewState value)? $default,){
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _HealthState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _HealthViewState() when $default != null:
|
||||
case _HealthState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
@@ -451,7 +451,7 @@ return $default(_that);case _:
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<HeartbeatEntity> latestHeartbeats, List<OxygenEntity> latestOxygens, List<HeartbeatEntity> chartHeartbeats, List<OxygenEntity> chartOxygens, List<HeartbeatEntity> historyHeartbeats, List<OxygenEntity> historyOxygens, int currentHistoryPage, bool hasMoreHistory, HealthStats heartRateStats, HealthStats oxygenStats, TimeRange timeRange, DateTime? customStart, DateTime? customEnd, bool isLoading, bool isLoadingMore, bool isMeasuring, bool isMeasuringCountdown, int measureRemainingSeconds, HealthErrorEvent? errorEvent)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _HealthViewState() when $default != null:
|
||||
case _HealthState() when $default != null:
|
||||
return $default(_that.latestHeartbeats,_that.latestOxygens,_that.chartHeartbeats,_that.chartOxygens,_that.historyHeartbeats,_that.historyOxygens,_that.currentHistoryPage,_that.hasMoreHistory,_that.heartRateStats,_that.oxygenStats,_that.timeRange,_that.customStart,_that.customEnd,_that.isLoading,_that.isLoadingMore,_that.isMeasuring,_that.isMeasuringCountdown,_that.measureRemainingSeconds,_that.errorEvent);case _:
|
||||
return orElse();
|
||||
|
||||
@@ -472,7 +472,7 @@ return $default(_that.latestHeartbeats,_that.latestOxygens,_that.chartHeartbeats
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<HeartbeatEntity> latestHeartbeats, List<OxygenEntity> latestOxygens, List<HeartbeatEntity> chartHeartbeats, List<OxygenEntity> chartOxygens, List<HeartbeatEntity> historyHeartbeats, List<OxygenEntity> historyOxygens, int currentHistoryPage, bool hasMoreHistory, HealthStats heartRateStats, HealthStats oxygenStats, TimeRange timeRange, DateTime? customStart, DateTime? customEnd, bool isLoading, bool isLoadingMore, bool isMeasuring, bool isMeasuringCountdown, int measureRemainingSeconds, HealthErrorEvent? errorEvent) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _HealthViewState():
|
||||
case _HealthState():
|
||||
return $default(_that.latestHeartbeats,_that.latestOxygens,_that.chartHeartbeats,_that.chartOxygens,_that.historyHeartbeats,_that.historyOxygens,_that.currentHistoryPage,_that.hasMoreHistory,_that.heartRateStats,_that.oxygenStats,_that.timeRange,_that.customStart,_that.customEnd,_that.isLoading,_that.isLoadingMore,_that.isMeasuring,_that.isMeasuringCountdown,_that.measureRemainingSeconds,_that.errorEvent);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
@@ -492,7 +492,7 @@ return $default(_that.latestHeartbeats,_that.latestOxygens,_that.chartHeartbeats
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<HeartbeatEntity> latestHeartbeats, List<OxygenEntity> latestOxygens, List<HeartbeatEntity> chartHeartbeats, List<OxygenEntity> chartOxygens, List<HeartbeatEntity> historyHeartbeats, List<OxygenEntity> historyOxygens, int currentHistoryPage, bool hasMoreHistory, HealthStats heartRateStats, HealthStats oxygenStats, TimeRange timeRange, DateTime? customStart, DateTime? customEnd, bool isLoading, bool isLoadingMore, bool isMeasuring, bool isMeasuringCountdown, int measureRemainingSeconds, HealthErrorEvent? errorEvent)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _HealthViewState() when $default != null:
|
||||
case _HealthState() when $default != null:
|
||||
return $default(_that.latestHeartbeats,_that.latestOxygens,_that.chartHeartbeats,_that.chartOxygens,_that.historyHeartbeats,_that.historyOxygens,_that.currentHistoryPage,_that.hasMoreHistory,_that.heartRateStats,_that.oxygenStats,_that.timeRange,_that.customStart,_that.customEnd,_that.isLoading,_that.isLoadingMore,_that.isMeasuring,_that.isMeasuringCountdown,_that.measureRemainingSeconds,_that.errorEvent);case _:
|
||||
return null;
|
||||
|
||||
@@ -504,8 +504,8 @@ return $default(_that.latestHeartbeats,_that.latestOxygens,_that.chartHeartbeats
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _HealthViewState implements HealthViewState {
|
||||
const _HealthViewState({final List<HeartbeatEntity> latestHeartbeats = const [], final List<OxygenEntity> latestOxygens = const [], final List<HeartbeatEntity> chartHeartbeats = const [], final List<OxygenEntity> chartOxygens = const [], final List<HeartbeatEntity> historyHeartbeats = const [], final List<OxygenEntity> historyOxygens = const [], this.currentHistoryPage = 1, this.hasMoreHistory = false, this.heartRateStats = const HealthStats(), this.oxygenStats = const HealthStats(), this.timeRange = TimeRange.today, this.customStart = null, this.customEnd = null, this.isLoading = true, this.isLoadingMore = false, this.isMeasuring = false, this.isMeasuringCountdown = false, this.measureRemainingSeconds = 0, this.errorEvent}): _latestHeartbeats = latestHeartbeats,_latestOxygens = latestOxygens,_chartHeartbeats = chartHeartbeats,_chartOxygens = chartOxygens,_historyHeartbeats = historyHeartbeats,_historyOxygens = historyOxygens;
|
||||
class _HealthState implements HealthState {
|
||||
const _HealthState({final List<HeartbeatEntity> latestHeartbeats = const [], final List<OxygenEntity> latestOxygens = const [], final List<HeartbeatEntity> chartHeartbeats = const [], final List<OxygenEntity> chartOxygens = const [], final List<HeartbeatEntity> historyHeartbeats = const [], final List<OxygenEntity> historyOxygens = const [], this.currentHistoryPage = 1, this.hasMoreHistory = false, this.heartRateStats = const HealthStats(), this.oxygenStats = const HealthStats(), this.timeRange = TimeRange.today, this.customStart, this.customEnd, this.isLoading = true, this.isLoadingMore = false, this.isMeasuring = false, this.isMeasuringCountdown = false, this.measureRemainingSeconds = 0, this.errorEvent}): _latestHeartbeats = latestHeartbeats,_latestOxygens = latestOxygens,_chartHeartbeats = chartHeartbeats,_chartOxygens = chartOxygens,_historyHeartbeats = historyHeartbeats,_historyOxygens = historyOxygens;
|
||||
|
||||
|
||||
final List<HeartbeatEntity> _latestHeartbeats;
|
||||
@@ -555,8 +555,8 @@ class _HealthViewState implements HealthViewState {
|
||||
@override@JsonKey() final HealthStats heartRateStats;
|
||||
@override@JsonKey() final HealthStats oxygenStats;
|
||||
@override@JsonKey() final TimeRange timeRange;
|
||||
@override@JsonKey() final DateTime? customStart;
|
||||
@override@JsonKey() final DateTime? customEnd;
|
||||
@override final DateTime? customStart;
|
||||
@override final DateTime? customEnd;
|
||||
@override@JsonKey() final bool isLoading;
|
||||
@override@JsonKey() final bool isLoadingMore;
|
||||
@override@JsonKey() final bool isMeasuring;
|
||||
@@ -564,17 +564,17 @@ class _HealthViewState implements HealthViewState {
|
||||
@override@JsonKey() final int measureRemainingSeconds;
|
||||
@override final HealthErrorEvent? errorEvent;
|
||||
|
||||
/// Create a copy of HealthViewState
|
||||
/// Create a copy of HealthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$HealthViewStateCopyWith<_HealthViewState> get copyWith => __$HealthViewStateCopyWithImpl<_HealthViewState>(this, _$identity);
|
||||
_$HealthStateCopyWith<_HealthState> get copyWith => __$HealthStateCopyWithImpl<_HealthState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _HealthViewState&&const DeepCollectionEquality().equals(other._latestHeartbeats, _latestHeartbeats)&&const DeepCollectionEquality().equals(other._latestOxygens, _latestOxygens)&&const DeepCollectionEquality().equals(other._chartHeartbeats, _chartHeartbeats)&&const DeepCollectionEquality().equals(other._chartOxygens, _chartOxygens)&&const DeepCollectionEquality().equals(other._historyHeartbeats, _historyHeartbeats)&&const DeepCollectionEquality().equals(other._historyOxygens, _historyOxygens)&&(identical(other.currentHistoryPage, currentHistoryPage) || other.currentHistoryPage == currentHistoryPage)&&(identical(other.hasMoreHistory, hasMoreHistory) || other.hasMoreHistory == hasMoreHistory)&&(identical(other.heartRateStats, heartRateStats) || other.heartRateStats == heartRateStats)&&(identical(other.oxygenStats, oxygenStats) || other.oxygenStats == oxygenStats)&&(identical(other.timeRange, timeRange) || other.timeRange == timeRange)&&(identical(other.customStart, customStart) || other.customStart == customStart)&&(identical(other.customEnd, customEnd) || other.customEnd == customEnd)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&(identical(other.isMeasuring, isMeasuring) || other.isMeasuring == isMeasuring)&&(identical(other.isMeasuringCountdown, isMeasuringCountdown) || other.isMeasuringCountdown == isMeasuringCountdown)&&(identical(other.measureRemainingSeconds, measureRemainingSeconds) || other.measureRemainingSeconds == measureRemainingSeconds)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _HealthState&&const DeepCollectionEquality().equals(other._latestHeartbeats, _latestHeartbeats)&&const DeepCollectionEquality().equals(other._latestOxygens, _latestOxygens)&&const DeepCollectionEquality().equals(other._chartHeartbeats, _chartHeartbeats)&&const DeepCollectionEquality().equals(other._chartOxygens, _chartOxygens)&&const DeepCollectionEquality().equals(other._historyHeartbeats, _historyHeartbeats)&&const DeepCollectionEquality().equals(other._historyOxygens, _historyOxygens)&&(identical(other.currentHistoryPage, currentHistoryPage) || other.currentHistoryPage == currentHistoryPage)&&(identical(other.hasMoreHistory, hasMoreHistory) || other.hasMoreHistory == hasMoreHistory)&&(identical(other.heartRateStats, heartRateStats) || other.heartRateStats == heartRateStats)&&(identical(other.oxygenStats, oxygenStats) || other.oxygenStats == oxygenStats)&&(identical(other.timeRange, timeRange) || other.timeRange == timeRange)&&(identical(other.customStart, customStart) || other.customStart == customStart)&&(identical(other.customEnd, customEnd) || other.customEnd == customEnd)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&(identical(other.isMeasuring, isMeasuring) || other.isMeasuring == isMeasuring)&&(identical(other.isMeasuringCountdown, isMeasuringCountdown) || other.isMeasuringCountdown == isMeasuringCountdown)&&(identical(other.measureRemainingSeconds, measureRemainingSeconds) || other.measureRemainingSeconds == measureRemainingSeconds)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent));
|
||||
}
|
||||
|
||||
|
||||
@@ -583,15 +583,15 @@ int get hashCode => Object.hashAll([runtimeType,const DeepCollectionEquality().h
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'HealthViewState(latestHeartbeats: $latestHeartbeats, latestOxygens: $latestOxygens, chartHeartbeats: $chartHeartbeats, chartOxygens: $chartOxygens, historyHeartbeats: $historyHeartbeats, historyOxygens: $historyOxygens, currentHistoryPage: $currentHistoryPage, hasMoreHistory: $hasMoreHistory, heartRateStats: $heartRateStats, oxygenStats: $oxygenStats, timeRange: $timeRange, customStart: $customStart, customEnd: $customEnd, isLoading: $isLoading, isLoadingMore: $isLoadingMore, isMeasuring: $isMeasuring, isMeasuringCountdown: $isMeasuringCountdown, measureRemainingSeconds: $measureRemainingSeconds, errorEvent: $errorEvent)';
|
||||
return 'HealthState(latestHeartbeats: $latestHeartbeats, latestOxygens: $latestOxygens, chartHeartbeats: $chartHeartbeats, chartOxygens: $chartOxygens, historyHeartbeats: $historyHeartbeats, historyOxygens: $historyOxygens, currentHistoryPage: $currentHistoryPage, hasMoreHistory: $hasMoreHistory, heartRateStats: $heartRateStats, oxygenStats: $oxygenStats, timeRange: $timeRange, customStart: $customStart, customEnd: $customEnd, isLoading: $isLoading, isLoadingMore: $isLoadingMore, isMeasuring: $isMeasuring, isMeasuringCountdown: $isMeasuringCountdown, measureRemainingSeconds: $measureRemainingSeconds, errorEvent: $errorEvent)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$HealthViewStateCopyWith<$Res> implements $HealthViewStateCopyWith<$Res> {
|
||||
factory _$HealthViewStateCopyWith(_HealthViewState value, $Res Function(_HealthViewState) _then) = __$HealthViewStateCopyWithImpl;
|
||||
abstract mixin class _$HealthStateCopyWith<$Res> implements $HealthStateCopyWith<$Res> {
|
||||
factory _$HealthStateCopyWith(_HealthState value, $Res Function(_HealthState) _then) = __$HealthStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
List<HeartbeatEntity> latestHeartbeats, List<OxygenEntity> latestOxygens, List<HeartbeatEntity> chartHeartbeats, List<OxygenEntity> chartOxygens, List<HeartbeatEntity> historyHeartbeats, List<OxygenEntity> historyOxygens, int currentHistoryPage, bool hasMoreHistory, HealthStats heartRateStats, HealthStats oxygenStats, TimeRange timeRange, DateTime? customStart, DateTime? customEnd, bool isLoading, bool isLoadingMore, bool isMeasuring, bool isMeasuringCountdown, int measureRemainingSeconds, HealthErrorEvent? errorEvent
|
||||
@@ -602,17 +602,17 @@ $Res call({
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$HealthViewStateCopyWithImpl<$Res>
|
||||
implements _$HealthViewStateCopyWith<$Res> {
|
||||
__$HealthViewStateCopyWithImpl(this._self, this._then);
|
||||
class __$HealthStateCopyWithImpl<$Res>
|
||||
implements _$HealthStateCopyWith<$Res> {
|
||||
__$HealthStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _HealthViewState _self;
|
||||
final $Res Function(_HealthViewState) _then;
|
||||
final _HealthState _self;
|
||||
final $Res Function(_HealthState) _then;
|
||||
|
||||
/// Create a copy of HealthViewState
|
||||
/// Create a copy of HealthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? latestHeartbeats = null,Object? latestOxygens = null,Object? chartHeartbeats = null,Object? chartOxygens = null,Object? historyHeartbeats = null,Object? historyOxygens = null,Object? currentHistoryPage = null,Object? hasMoreHistory = null,Object? heartRateStats = null,Object? oxygenStats = null,Object? timeRange = null,Object? customStart = freezed,Object? customEnd = freezed,Object? isLoading = null,Object? isLoadingMore = null,Object? isMeasuring = null,Object? isMeasuringCountdown = null,Object? measureRemainingSeconds = null,Object? errorEvent = freezed,}) {
|
||||
return _then(_HealthViewState(
|
||||
return _then(_HealthState(
|
||||
latestHeartbeats: null == latestHeartbeats ? _self._latestHeartbeats : latestHeartbeats // ignore: cast_nullable_to_non_nullable
|
||||
as List<HeartbeatEntity>,latestOxygens: null == latestOxygens ? _self._latestOxygens : latestOxygens // ignore: cast_nullable_to_non_nullable
|
||||
as List<OxygenEntity>,chartHeartbeats: null == chartHeartbeats ? _self._chartHeartbeats : chartHeartbeats // ignore: cast_nullable_to_non_nullable
|
||||
@@ -636,7 +636,7 @@ as HealthErrorEvent?,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of HealthViewState
|
||||
/// Create a copy of HealthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
@@ -645,7 +645,7 @@ $HealthStatsCopyWith<$Res> get heartRateStats {
|
||||
return $HealthStatsCopyWith<$Res>(_self.heartRateStats, (value) {
|
||||
return _then(_self.copyWith(heartRateStats: value));
|
||||
});
|
||||
}/// Create a copy of HealthViewState
|
||||
}/// Create a copy of HealthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'measure_end_time_provider.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class MeasureEndTime extends _$MeasureEndTime {
|
||||
@override
|
||||
DateTime? build() => null;
|
||||
|
||||
void set(DateTime? value) => state = value;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'measure_end_time_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(MeasureEndTime)
|
||||
const measureEndTimeProvider = MeasureEndTimeProvider._();
|
||||
|
||||
final class MeasureEndTimeProvider
|
||||
extends $NotifierProvider<MeasureEndTime, DateTime?> {
|
||||
const MeasureEndTimeProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'measureEndTimeProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$measureEndTimeHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
MeasureEndTime create() => MeasureEndTime();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(DateTime? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<DateTime?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$measureEndTimeHash() => r'7226ccb70bfa78afe3a8285fb5fbdc17809e3a77';
|
||||
|
||||
abstract class _$MeasureEndTime extends $Notifier<DateTime?> {
|
||||
DateTime? build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<DateTime?, DateTime?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<DateTime?, DateTime?>,
|
||||
DateTime?,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import 'package:utils/utils.dart';
|
||||
|
||||
import '../../domain/entities/heartbeat_entity.dart';
|
||||
import '../health_colors.dart';
|
||||
import '../state/health_view_model.dart';
|
||||
import '../providers/health_controller.dart';
|
||||
import 'health_chart_helpers.dart';
|
||||
import 'health_empty_state.dart';
|
||||
import 'health_history_section.dart';
|
||||
@@ -27,7 +27,9 @@ class BloodPressureTab extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final vm = ref.read(healthViewModelProvider.notifier);
|
||||
final vm = ref.read(healthControllerProvider.notifier);
|
||||
final timeRange =
|
||||
ref.watch(healthControllerProvider.select((s) => s.timeRange));
|
||||
|
||||
final chartWithPressure = chartData
|
||||
.where((h) => h.highBloodPressure != null && h.lowBloodPressure != null)
|
||||
@@ -46,16 +48,19 @@ class BloodPressureTab extends ConsumerWidget {
|
||||
),
|
||||
children: [
|
||||
HealthLineChart(
|
||||
spots: toFlSpots(
|
||||
points: toPoints(
|
||||
chartWithPressure,
|
||||
(h) => h.occurredAt,
|
||||
(h) => h.highBloodPressure!.toDouble(),
|
||||
),
|
||||
lineColor: Theme.of(context).colorScheme.error,
|
||||
secondarySpots: toFlSpots(
|
||||
secondaryPoints: toPoints(
|
||||
chartWithPressure,
|
||||
(h) => h.occurredAt,
|
||||
(h) => h.lowBloodPressure!.toDouble(),
|
||||
),
|
||||
secondaryLineColor: const Color(0xFFFF9800),
|
||||
timeRange: timeRange,
|
||||
),
|
||||
HealthHistorySection<HeartbeatEntity>(
|
||||
items: historyWithPressure,
|
||||
|
||||
@@ -1,9 +1,49 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
|
||||
List<FlSpot> toFlSpots<T>(List<T> items, double Function(T) getValue) {
|
||||
class HealthPoint {
|
||||
const HealthPoint({required this.timestampMs, required this.value});
|
||||
final int timestampMs;
|
||||
final double value;
|
||||
}
|
||||
|
||||
List<HealthPoint> toPoints<T>(
|
||||
List<T> items,
|
||||
int Function(T) getTimestamp,
|
||||
double Function(T) getValue,
|
||||
) {
|
||||
return items
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => FlSpot(e.key.toDouble(), getValue(e.value)))
|
||||
.map(
|
||||
(e) => HealthPoint(
|
||||
timestampMs: getTimestamp(e),
|
||||
value: getValue(e),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Splits points into contiguous segments, breaking whenever the gap between
|
||||
/// consecutive points exceeds [maxGap]. Keeps the line honest when the device
|
||||
/// stopped measuring for a while.
|
||||
List<List<FlSpot>> toSegments(
|
||||
List<HealthPoint> points, {
|
||||
required Duration maxGap,
|
||||
}) {
|
||||
if (points.isEmpty) return const [];
|
||||
|
||||
final maxGapMs = maxGap.inMilliseconds;
|
||||
final segments = <List<FlSpot>>[];
|
||||
var current = <FlSpot>[];
|
||||
int? previousMs;
|
||||
|
||||
for (final p in points) {
|
||||
final x = p.timestampMs.toDouble();
|
||||
if (previousMs != null && p.timestampMs - previousMs > maxGapMs) {
|
||||
if (current.isNotEmpty) segments.add(current);
|
||||
current = <FlSpot>[];
|
||||
}
|
||||
current.add(FlSpot(x, p.value));
|
||||
previousMs = p.timestampMs;
|
||||
}
|
||||
if (current.isNotEmpty) segments.add(current);
|
||||
return segments;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
import 'package:device_management/src/core/presentation/time_range.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:utils/utils.dart';
|
||||
|
||||
class HealthLineChart extends StatelessWidget {
|
||||
final List<FlSpot> spots;
|
||||
final Color lineColor;
|
||||
import 'health_chart_helpers.dart';
|
||||
|
||||
final List<FlSpot>? secondarySpots;
|
||||
class HealthLineChart extends StatelessWidget {
|
||||
final List<HealthPoint> points;
|
||||
final Color lineColor;
|
||||
final List<HealthPoint>? secondaryPoints;
|
||||
final Color? secondaryLineColor;
|
||||
final TimeRange timeRange;
|
||||
|
||||
const HealthLineChart({
|
||||
super.key,
|
||||
required this.spots,
|
||||
required this.points,
|
||||
required this.lineColor,
|
||||
this.secondarySpots,
|
||||
required this.timeRange,
|
||||
this.secondaryPoints,
|
||||
this.secondaryLineColor,
|
||||
});
|
||||
|
||||
Duration get _maxGap => timeRange == TimeRange.today
|
||||
? const Duration(hours: 2)
|
||||
: const Duration(days: 2);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (spots.isEmpty) {
|
||||
if (points.isEmpty) {
|
||||
return SizedBox(
|
||||
height: SizeUtils.getByScreen(small: 180, big: 160),
|
||||
child: Center(
|
||||
@@ -27,7 +35,9 @@ class HealthLineChart extends StatelessWidget {
|
||||
'--',
|
||||
style: TextStyle(
|
||||
fontSize: SizeUtils.getByScreen(small: 16, big: 14),
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
@@ -35,12 +45,20 @@ class HealthLineChart extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final primarySegments = toSegments(points, maxGap: _maxGap);
|
||||
final secondarySegments = secondaryPoints == null
|
||||
? const <List<FlSpot>>[]
|
||||
: toSegments(secondaryPoints!, maxGap: _maxGap);
|
||||
|
||||
final lines = <LineChartBarData>[
|
||||
_buildLine(spots, lineColor),
|
||||
if (secondarySpots != null && secondarySpots!.isNotEmpty)
|
||||
_buildLine(secondarySpots!, secondaryLineColor ?? lineColor),
|
||||
for (final seg in primarySegments) _buildLine(seg, lineColor),
|
||||
for (final seg in secondarySegments)
|
||||
_buildLine(seg, secondaryLineColor ?? lineColor),
|
||||
];
|
||||
|
||||
final (minX, maxX) = _rangeX(points, secondaryPoints);
|
||||
final xInterval = maxX > minX ? (maxX - minX) / 4 : 1.0;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: SizeUtils.getByScreen(small: 16, big: 14),
|
||||
@@ -49,13 +67,17 @@ class HealthLineChart extends StatelessWidget {
|
||||
height: SizeUtils.getByScreen(small: 180, big: 160),
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
minX: minX,
|
||||
maxX: maxX,
|
||||
lineBarsData: lines,
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: _computeInterval(spots),
|
||||
horizontalInterval: _verticalInterval(),
|
||||
getDrawingHorizontalLine: (value) => FlLine(
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.08),
|
||||
strokeWidth: 1,
|
||||
),
|
||||
@@ -69,14 +91,38 @@ class HealthLineChart extends StatelessWidget {
|
||||
value.toInt().toString(),
|
||||
style: TextStyle(
|
||||
fontSize: SizeUtils.getByScreen(small: 10, big: 9),
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
interval: xInterval,
|
||||
reservedSize: SizeUtils.getByScreen(small: 22, big: 20),
|
||||
getTitlesWidget: (value, meta) {
|
||||
if (value == meta.min || value == meta.max) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
_formatX(value),
|
||||
style: TextStyle(
|
||||
fontSize: SizeUtils.getByScreen(small: 10, big: 9),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
@@ -93,9 +139,9 @@ class HealthLineChart extends StatelessWidget {
|
||||
getTooltipItems: (spots) => spots
|
||||
.map(
|
||||
(spot) => LineTooltipItem(
|
||||
spot.y.toInt().toString(),
|
||||
'${spot.y.toInt()}\n${_formatX(spot.x)}',
|
||||
TextStyle(
|
||||
fontSize: SizeUtils.getByScreen(small: 12, big: 11),
|
||||
fontSize: SizeUtils.getByScreen(small: 11, big: 10),
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
@@ -110,11 +156,43 @@ class HealthLineChart extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
(double, double) _rangeX(
|
||||
List<HealthPoint> primary,
|
||||
List<HealthPoint>? secondary,
|
||||
) {
|
||||
final all = [...primary, ...?secondary];
|
||||
final timestamps = all.map((p) => p.timestampMs);
|
||||
final minMs = timestamps.reduce((a, b) => a < b ? a : b);
|
||||
final maxMs = timestamps.reduce((a, b) => a > b ? a : b);
|
||||
if (minMs == maxMs) {
|
||||
return ((minMs - 60000).toDouble(), (maxMs + 60000).toDouble());
|
||||
}
|
||||
return (minMs.toDouble(), maxMs.toDouble());
|
||||
}
|
||||
|
||||
double _verticalInterval() {
|
||||
final all = [...points, ...?secondaryPoints];
|
||||
if (all.length < 2) return 1;
|
||||
final values = all.map((p) => p.value);
|
||||
final range = values.reduce((a, b) => a > b ? a : b) -
|
||||
values.reduce((a, b) => a < b ? a : b);
|
||||
if (range <= 0) return 1;
|
||||
return (range / 4).ceilToDouble();
|
||||
}
|
||||
|
||||
String _formatX(double x) {
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(x.toInt());
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
if (timeRange == TimeRange.today) {
|
||||
return '${two(date.hour)}:${two(date.minute)}';
|
||||
}
|
||||
return '${two(date.day)}/${two(date.month)}';
|
||||
}
|
||||
|
||||
LineChartBarData _buildLine(List<FlSpot> data, Color color) {
|
||||
return LineChartBarData(
|
||||
spots: data,
|
||||
isCurved: true,
|
||||
preventCurveOverShooting: true,
|
||||
isCurved: false,
|
||||
color: color,
|
||||
barWidth: 2,
|
||||
dotData: FlDotData(
|
||||
@@ -128,14 +206,4 @@ class HealthLineChart extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _computeInterval(List<FlSpot> data) {
|
||||
if (data.length < 2) return 1;
|
||||
final yValues = data.map((s) => s.y);
|
||||
final range =
|
||||
yValues.reduce((a, b) => a > b ? a : b) -
|
||||
yValues.reduce((a, b) => a < b ? a : b);
|
||||
if (range <= 0) return 1;
|
||||
return (range / 4).ceilToDouble();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:sf_localizations/sf_localizations.dart';
|
||||
import 'package:utils/utils.dart';
|
||||
|
||||
import '../state/health_view_state.dart';
|
||||
import '../providers/health_state.dart';
|
||||
|
||||
class HealthStatsRow extends StatelessWidget {
|
||||
final HealthStats stats;
|
||||
|
||||
@@ -5,8 +5,8 @@ import 'package:utils/utils.dart';
|
||||
|
||||
import '../../domain/entities/heartbeat_entity.dart';
|
||||
import '../health_colors.dart';
|
||||
import '../state/health_view_model.dart';
|
||||
import '../state/health_view_state.dart';
|
||||
import '../providers/health_controller.dart';
|
||||
import '../providers/health_state.dart';
|
||||
import 'health_chart_helpers.dart';
|
||||
import 'health_empty_state.dart';
|
||||
import 'health_history_section.dart';
|
||||
@@ -31,7 +31,9 @@ class HeartRateTab extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final vm = ref.read(healthViewModelProvider.notifier);
|
||||
final vm = ref.read(healthControllerProvider.notifier);
|
||||
final timeRange =
|
||||
ref.watch(healthControllerProvider.select((s) => s.timeRange));
|
||||
|
||||
if (chartData.isEmpty && historyData.isEmpty) {
|
||||
return HealthEmptyState(icon: Icons.favorite_rounded);
|
||||
@@ -43,8 +45,13 @@ class HeartRateTab extends ConsumerWidget {
|
||||
),
|
||||
children: [
|
||||
HealthLineChart(
|
||||
spots: toFlSpots(chartData, (h) => h.heartbeats.toDouble()),
|
||||
points: toPoints(
|
||||
chartData,
|
||||
(h) => h.occurredAt,
|
||||
(h) => h.heartbeats.toDouble(),
|
||||
),
|
||||
lineColor: Theme.of(context).colorScheme.error,
|
||||
timeRange: timeRange,
|
||||
),
|
||||
HealthStatsRow(
|
||||
stats: stats,
|
||||
|
||||
@@ -5,8 +5,8 @@ import 'package:utils/utils.dart';
|
||||
|
||||
import '../../domain/entities/oxygen_entity.dart';
|
||||
import '../health_colors.dart';
|
||||
import '../state/health_view_model.dart';
|
||||
import '../state/health_view_state.dart';
|
||||
import '../providers/health_controller.dart';
|
||||
import '../providers/health_state.dart';
|
||||
import 'health_chart_helpers.dart';
|
||||
import 'health_empty_state.dart';
|
||||
import 'health_history_section.dart';
|
||||
@@ -31,7 +31,9 @@ class OxygenTab extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final vm = ref.read(healthViewModelProvider.notifier);
|
||||
final vm = ref.read(healthControllerProvider.notifier);
|
||||
final timeRange =
|
||||
ref.watch(healthControllerProvider.select((s) => s.timeRange));
|
||||
|
||||
if (chartData.isEmpty && historyData.isEmpty) {
|
||||
return HealthEmptyState(icon: Icons.air_rounded);
|
||||
@@ -43,8 +45,13 @@ class OxygenTab extends ConsumerWidget {
|
||||
),
|
||||
children: [
|
||||
HealthLineChart(
|
||||
spots: toFlSpots(chartData, (o) => o.oxygen.toDouble()),
|
||||
points: toPoints(
|
||||
chartData,
|
||||
(o) => o.occurredAt,
|
||||
(o) => o.oxygen.toDouble(),
|
||||
),
|
||||
lineColor: const Color(0xFF4CAF50),
|
||||
timeRange: timeRange,
|
||||
),
|
||||
HealthStatsRow(
|
||||
stats: stats,
|
||||
|
||||
Reference in New Issue
Block a user