Commit 0b4778a

derdilla <82763757+derdilla@users.noreply.github.com>
2025-10-18 14:15:20
Allow filtering a records time of day (#605)
* Add time range to IntervalStore * Implement filter UI * Respect time of day filter * Cleanup code
1 parent eb95d0b
app/lib/data_util/full_entry_builder.dart
@@ -2,6 +2,7 @@ import 'package:blood_pressure_app/data_util/repository_builder.dart';
 import 'package:blood_pressure_app/model/storage/interval_store.dart';
 import 'package:flutter/material.dart';
 import 'package:health_data_store/health_data_store.dart';
+import 'package:provider/provider.dart';
 
 /// Shorthand class for getting a [rangeType]s [FullEntry] values.
 class FullEntryBuilder extends StatelessWidget {
@@ -32,6 +33,23 @@ class FullEntryBuilder extends StatelessWidget {
       onData: (BuildContext context, List<MedicineIntake> intakes) => RepositoryBuilder<Note, NoteRepository>(
         rangeType: rangeType,
         onData: (BuildContext context, List<Note> notes) {
+          final manager = context.watch<IntervalStoreManager>();
+          final timeLimitRange = manager.get(rangeType).timeLimitRange;
+          if (timeLimitRange != null) {
+            records = records.where((r) {
+              final time = TimeOfDay.fromDateTime(r.time);
+              return time.isAfter(timeLimitRange.start) && time.isBefore(timeLimitRange.end);
+            }).toList();
+            intakes = intakes.where((i) {
+              final time = TimeOfDay.fromDateTime(i.time);
+              return time.isAfter(timeLimitRange.start) && time.isBefore(timeLimitRange.end);
+            }).toList();
+            notes = notes.where((n) {
+              final time = TimeOfDay.fromDateTime(n.time);
+              return time.isAfter(timeLimitRange.start) && time.isBefore(timeLimitRange.end);
+            }).toList();
+          }
+
           if (onData != null) return onData!(context, records, intakes, notes);
 
           final entries = FullEntryList.merged(records, notes, intakes);
app/lib/features/data_picker/filter_button.dart
@@ -0,0 +1,92 @@
+import 'dart:math';
+
+import 'package:blood_pressure_app/l10n/app_localizations.dart';
+import 'package:blood_pressure_app/model/storage/interval_store.dart';
+import 'package:flutter/material.dart';
+
+/// Button that shows an menu to configure a display intervalls filter.
+class FilterButton extends StatefulWidget {
+  /// Create button that shows an menu to configure a display intervalls filter.
+  const FilterButton({super.key, required this.interval});
+
+  /// The interval to configure
+  final IntervalStorage interval;
+
+  @override
+  State<FilterButton> createState() => _FilterButtonState();
+}
+
+class _FilterButtonState extends State<FilterButton> {
+  TimeOfDay _extractTime(double decimalHours) {
+    final totalMinutes = (decimalHours * 60).round();
+    final duration = Duration(minutes: totalMinutes);
+    final hours = duration.inHours;
+    final minutes = duration.inMinutes.remainder(60);
+    return TimeOfDay(hour: hours, minute: minutes);
+  }
+
+  TimeOfDay get _start => widget.interval.timeLimitRange?.start
+      ?? TimeOfDay(hour: 0, minute: 0);
+  TimeOfDay get _end => widget.interval.timeLimitRange?.end
+      ?? TimeOfDay(hour: 23, minute: 59);
+
+  @override
+  Widget build(BuildContext context) => MenuAnchor(
+    menuChildren: [
+      Padding(
+        padding: const EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 0.0),
+        child: Row(
+          mainAxisAlignment: MainAxisAlignment.spaceBetween,
+          crossAxisAlignment: CrossAxisAlignment.end,
+          children: [
+            Text(TimeOfDay(hour: 0, minute: 0).format(context)),
+            Text(TimeOfDay(hour: 23, minute: 59).format(context)),
+          ],
+        ),
+      ),
+      SliderTheme(
+        data: SliderThemeData(
+          showValueIndicator: ShowValueIndicator.onDrag,
+          padding: EdgeInsets.symmetric(horizontal: 12.0)
+        ),
+        child: RangeSlider(
+          // 15 minute intervalls
+          divisions: 24.0 ~/ 0.25,
+          min: 0.0,
+          max: 23.99,
+          labels: RangeLabels(_start.format(context), _end.format(context)),
+          values: RangeValues(_start.hour + _start.minute.toDouble() / 60.0,
+                              _end.hour + _end.minute.toDouble() / 60.0),
+          onChanged: (v) {
+            setState(() {
+              widget.interval.timeLimitRange = TimeRange(
+                start: _extractTime(min(v.start, v.end)),
+                end: _extractTime(max(v.start, v.end)),
+              );
+            });
+          },
+        ),
+      ),
+      Align(
+        alignment: Alignment.centerRight,
+        child: Padding(
+          padding: EdgeInsets.all(8.0),
+          child: TextButton(
+            onPressed: () => widget.interval.timeLimitRange = null,
+            child: Text(AppLocalizations.of(context)!.reset),
+          ),
+        ),
+      )
+    ],
+    builder: (BuildContext context, MenuController controller, Widget? child) => IconButton(
+      onPressed: () {
+        if (controller.isOpen) {
+          controller.close();
+        } else {
+          controller.open();
+        }
+      },
+      icon: Icon(Icons.filter_alt),
+    ),
+  );
+}
app/lib/data_util/interval_picker.dart → app/lib/features/data_picker/interval_picker.dart
@@ -1,7 +1,9 @@
+import 'package:blood_pressure_app/features/data_picker/filter_button.dart';
 import 'package:blood_pressure_app/model/datarange_extension.dart';
 import 'package:blood_pressure_app/model/storage/interval_store.dart';
 import 'package:flutter/material.dart';
 import 'package:blood_pressure_app/l10n/app_localizations.dart';
+import 'package:health_data_store/health_data_store.dart';
 import 'package:intl/intl.dart';
 import 'package:provider/provider.dart';
 import 'package:week_of_year/date_week_extensions.dart';
@@ -28,15 +30,15 @@ class IntervalPicker extends StatelessWidget {
   @override
   Widget build(BuildContext context) => Consumer<IntervalStoreManager>(
     builder: (context, intervallStoreManager, _) {
-      final intervall = intervallStoreManager.get(type);
+      final interval = intervallStoreManager.get(type);
       final loc = AppLocalizations.of(context)!;
-      final start = intervall.currentRange.start;
-      final end = intervall.currentRange.end;
-      final String intervallDisplayText = switch (intervall.stepSize) {
+      final start = interval.currentRange.start;
+      final end = interval.currentRange.end;
+      final String intervallDisplayText = switch (interval.stepSize) {
         TimeStep.day => DateFormat.yMMMd().format(start),
         TimeStep.week => loc.weekOfYear(start.weekOfYear, start.year),
-        TimeStep.month => DateFormat.yMMM().format(intervall.currentRange.start),
-        TimeStep.year => DateFormat.y().format(intervall.currentRange.start),
+        TimeStep.month => DateFormat.yMMM().format(interval.currentRange.start),
+        TimeStep.year => DateFormat.y().format(interval.currentRange.start),
         TimeStep.lifetime => '-',
         TimeStep.last7Days || TimeStep.last30Days || TimeStep.custom =>
           '${DateFormat.yMMMd().format(start)} - ${DateFormat.yMMMd().format(end)}',
@@ -48,12 +50,12 @@ class IntervalPicker extends StatelessWidget {
           Row(
             children: [
               MaterialButton(
-                onPressed: () => intervall.moveDataRangeByStep(-1),
+                onPressed: () => interval.moveDataRangeByStep(-1),
                 child: const Icon(Icons.chevron_left, size: 48),
               ),
               Expanded(
                 child: DropdownButton<TimeStep>(
-                  value: intervall.stepSize,
+                  value: interval.stepSize,
                   isExpanded: true,
                   onChanged: (TimeStep? value) async {
                     if (value == TimeStep.custom) {
@@ -64,14 +66,14 @@ class IntervalPicker extends StatelessWidget {
                         currentDate: customRangePickerCurrentDay,
                       );
                       if (res != null) {
-                        intervall.changeStepSize(value!);
+                        interval.changeStepSize(value!);
                         final dateRange = res.dateRange.copyWith(
                           end: res.end.copyWith(hour: 23, minute: 59, second: 59),
                         );
-                        intervall.currentRange = dateRange;
+                        interval.currentRange = dateRange;
                       }
                     } else if (value != null) {
-                      intervall.changeStepSize(value);
+                      interval.changeStepSize(value);
                     }
                   },
                   items: [
@@ -80,8 +82,9 @@ class IntervalPicker extends StatelessWidget {
                   ]
                 ),
               ),
+              FilterButton(interval: interval),
               MaterialButton(
-                onPressed: () => intervall.moveDataRangeByStep(1),
+                onPressed: () => interval.moveDataRangeByStep(1),
                 child: const Icon(Icons.chevron_right, size: 48),
               ),
             ],
app/lib/features/export_import/export_button.dart
@@ -3,6 +3,7 @@ import 'dart:convert';
 import 'dart:io';
 import 'dart:typed_data';
 
+import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:blood_pressure_app/logging.dart';
 import 'package:blood_pressure_app/model/export_import/csv_converter.dart';
 import 'package:blood_pressure_app/model/export_import/excel_converter.dart';
@@ -18,7 +19,6 @@ import 'package:collection/collection.dart';
 import 'package:file_picker/file_picker.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
-import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:health_data_store/health_data_store.dart';
 import 'package:logging/logging.dart';
 import 'package:path/path.dart';
@@ -129,11 +129,36 @@ Future<List<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?)
   final noteRepo = RepositoryProvider.of<NoteRepository>(context);
   final intakeRepo = RepositoryProvider.of<MedicineIntakeRepository>(context);
   final weightRepo = RepositoryProvider.of<BodyweightRepository>(context);
-
-  final records = await bpRepo.get(range);
-  final notes = await noteRepo.get(range);
-  final intakes = await intakeRepo.get(range);
-  final weights = await weightRepo.get(range);
+  final intervalManager = context.read<IntervalStoreManager>();
+
+  List<BloodPressureRecord> records = await bpRepo.get(range);
+  List<Note> notes = await noteRepo.get(range);
+  List<MedicineIntake> intakes = await intakeRepo.get(range);
+  List<BodyweightRecord> weights = await weightRepo.get(range);
+
+  // Apply time of day filter
+
+  final timeLimitRange = intervalManager
+      .get(IntervalStoreManagerLocation.exportPage)
+      .timeLimitRange;
+  if (timeLimitRange != null) {
+    records = records.where((r) {
+      final time = TimeOfDay.fromDateTime(r.time);
+      return time.isAfter(timeLimitRange.start) && time.isBefore(timeLimitRange.end);
+    }).toList();
+    intakes = intakes.where((i) {
+      final time = TimeOfDay.fromDateTime(i.time);
+      return time.isAfter(timeLimitRange.start) && time.isBefore(timeLimitRange.end);
+    }).toList();
+    notes = notes.where((n) {
+      final time = TimeOfDay.fromDateTime(n.time);
+      return time.isAfter(timeLimitRange.start) && time.isBefore(timeLimitRange.end);
+    }).toList();
+    weights = weights.where((w) {
+      final time = TimeOfDay.fromDateTime(w.time);
+      return time.isAfter(timeLimitRange.start) && time.isBefore(timeLimitRange.end);
+    }).toList();
+  }
 
   _logger.finest('_getEntries - range=$range');
 
app/lib/features/measurement_list/weight_list.dart
@@ -23,6 +23,14 @@ class WeightList extends StatelessWidget {
     return RepositoryBuilder<BodyweightRecord, BodyweightRepository>(
       rangeType: rangeType,
       onData: (context, records) {
+        final manager = context.watch<IntervalStoreManager>();
+        final timeLimitRange = manager.get(rangeType).timeLimitRange;
+        if (timeLimitRange != null) {
+          records = records.where((r) {
+            final time = TimeOfDay.fromDateTime(r.time);
+            return time.isAfter(timeLimitRange.start) && time.isBefore(timeLimitRange.end);
+          }).toList();
+        }
         records.sort((a, b) => b.time.compareTo(a.time));
         return ListView.builder(
           itemCount: records.length,
app/lib/features/settings/export_import_screen.dart
@@ -1,7 +1,7 @@
 import 'dart:io';
 
 import 'package:blood_pressure_app/components/disabled.dart';
-import 'package:blood_pressure_app/data_util/interval_picker.dart';
+import 'package:blood_pressure_app/features/data_picker/interval_picker.dart';
 import 'package:blood_pressure_app/features/export_import/active_field_customization.dart';
 import 'package:blood_pressure_app/features/export_import/export_button.dart';
 import 'package:blood_pressure_app/features/export_import/export_warn_banner.dart';
app/lib/l10n/app_en.arb
@@ -572,5 +572,7 @@
     "exportSuccess": "Export successful",
     "@exportSuccess": {},
     "xsl": "Excel (xsl)",
-    "@xsl": {}
+    "@xsl": {},
+    "reset": "Reset",
+    "@reset": {}
 }
app/lib/model/storage/interval_store.dart
@@ -1,9 +1,9 @@
 import 'dart:convert';
 
+import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:blood_pressure_app/model/storage/convert_util.dart';
 import 'package:blood_pressure_app/model/storage/db/settings_loader.dart';
 import 'package:flutter/material.dart';
-import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:health_data_store/health_data_store.dart';
 
 /// Class for storing the current interval, as it is needed in start page, statistics and export.
@@ -12,6 +12,7 @@ class IntervalStorage extends ChangeNotifier {
   factory IntervalStorage.fromMap(Map<String, dynamic> map) => IntervalStorage(
     stepSize: TimeStep.deserialize(map['stepSize']),
     range: ConvertUtil.parseRange(map['start'], map['end']),
+    timeRange: TimeRange.fromJson(map['timeRange'])
   );
 
   /// Create a instance from a [String] created by [toJson].
@@ -24,20 +25,24 @@ class IntervalStorage extends ChangeNotifier {
   }
 
   /// Create a storage to interact with a display intervall.
-  IntervalStorage({TimeStep? stepSize, DateRange? range}) :
+  IntervalStorage({TimeStep? stepSize, DateRange? range, TimeRange? timeRange}) :
     _stepSize = stepSize ?? TimeStep.last7Days {
     _currentRange = range ?? _getMostRecentDisplayIntervall();
+    _timeRange = timeRange;
   }
 
   TimeStep _stepSize;
 
   late DateRange _currentRange;
 
+  TimeRange? _timeRange;
+
   /// Serialize the object to a restoreable map.
   Map<String, dynamic> toMap() => <String, dynamic>{
     'stepSize': stepSize.serialize(),
     'start': currentRange.start.millisecondsSinceEpoch,
     'end': currentRange.end.millisecondsSinceEpoch,
+    'timeRange': _timeRange?.toJson(),
   };
 
   /// Serialize the object to a restoreable string.
@@ -46,6 +51,17 @@ class IntervalStorage extends ChangeNotifier {
   /// The stepSize gets set through the changeStepSize method.
   TimeStep get stepSize => _stepSize;
 
+  // TODO: programmatically ensure this is respected:
+  /// The [TimeRange] used to limit data selection if non-null.
+  ///
+  /// Data points must fall on or between the start and end times to be selected.
+  TimeRange? get timeLimitRange => _timeRange;
+
+  set timeLimitRange(TimeRange? value) {
+    _timeRange = value;
+    notifyListeners();
+  }
+
   /// sets the stepSize to the new value and resets the currentRange to the most recent one. 
   void changeStepSize(TimeStep value) {
     _stepSize = value;
@@ -150,7 +166,7 @@ enum TimeStep {
   custom;
 
   /// Recreate a TimeStep from a number created with [TimeStep.serialize].
-  factory TimeStep.deserialize(value) {
+  factory TimeStep.deserialize(Object? value) {
     final int? intValue = ConvertUtil.parseInt(value);
     assert(intValue == null || intValue >= 0 && intValue <= 7);
     return switch (intValue) {
@@ -216,7 +232,7 @@ class IntervalStoreManager extends ChangeNotifier {
     notifyListeners();
   }
 
-  // Copy all values from another instance.
+  /// Copy all values from another instance.
   void copyFrom(IntervalStoreManager other) {
     mainPage = other.mainPage;
     exportPage = other.exportPage;
@@ -244,7 +260,70 @@ class IntervalStoreManager extends ChangeNotifier {
 
 /// Locations supported by [IntervalStoreManager].
 enum IntervalStoreManagerLocation {
+  /// List on home screen.
   mainPage,
+  /// All exported data.
   exportPage,
+  /// Data for all statistics.
   statsPage,
-}
\ No newline at end of file
+}
+
+/// Represents an inclusive time span, defined by a [start] and an [end]
+/// [TimeOfDay].
+///
+/// **Serialization:**
+/// The class serializes the [TimeOfDay] objects into simple string representations
+/// of their hour and minute values (e.g., '14:30' for 2:30 PM).
+class TimeRange {
+  /// Creates a new [TimeRange] with a specified [start] and [end] time.
+  const TimeRange({
+    required this.start,
+    required this.end,
+  });
+
+  /// The starting time of the range (inclusive).
+  final TimeOfDay start;
+
+  /// The ending time of the range (inclusive).
+  final TimeOfDay end;
+
+  /// Serialization to JSON-compatible map
+  Map<String, dynamic> toJson() => {
+      'start': _timeOfDayToString(start),
+      'end': _timeOfDayToString(end),
+    };
+
+  /// Creates a [TimeRange] instance from a JSON map.
+  ///
+  /// Returns `null` if the input map is null or if the required keys ('start', 'end')
+  /// are missing or contain invalid time strings.
+  static TimeRange? fromJson(Map<String, dynamic>? json) {
+    if (json == null || json['start'] is! String || json['end'] is! String) {
+      return null;
+    }
+
+    try {
+      final start = _timeOfDayFromString(json['start'] as String);
+      final end = _timeOfDayFromString(json['end'] as String);
+      return TimeRange(start: start, end: end);
+    } catch (_) {
+      // Return null on parsing errors (e.g., non-numeric parts)
+      return null;
+    }
+  }
+
+  /// Converts a TimeOfDay to 'HH:MM' string.
+  static String _timeOfDayToString(TimeOfDay time) {
+    final hour = time.hour.toString().padLeft(2, '0');
+    final minute = time.minute.toString().padLeft(2, '0');
+    return '$hour:$minute';
+  }
+
+  /// Converts an 'HH:MM' string back to a TimeOfDay.
+  static TimeOfDay _timeOfDayFromString(String timeString) {
+    final parts = timeString.split(':');
+    final hour = int.parse(parts[0]);
+    final minute = int.parse(parts[1]);
+    return TimeOfDay(hour: hour, minute: minute);
+  }
+}
app/lib/screens/home_screen.dart
@@ -1,7 +1,7 @@
 import 'package:blood_pressure_app/config.dart';
 import 'package:blood_pressure_app/data_util/entry_context.dart';
 import 'package:blood_pressure_app/data_util/full_entry_builder.dart';
-import 'package:blood_pressure_app/data_util/interval_picker.dart';
+import 'package:blood_pressure_app/features/data_picker/interval_picker.dart';
 import 'package:blood_pressure_app/features/home/navigation_action_buttons.dart';
 import 'package:blood_pressure_app/features/measurement_list/compact_measurement_list.dart';
 import 'package:blood_pressure_app/features/measurement_list/measurement_list.dart';
app/lib/screens/statistics_screen.dart
@@ -1,12 +1,13 @@
-import 'package:blood_pressure_app/data_util/interval_picker.dart';
 import 'package:blood_pressure_app/data_util/repository_builder.dart';
+import 'package:blood_pressure_app/features/data_picker/interval_picker.dart';
 import 'package:blood_pressure_app/features/statistics/blood_pressure_distribution.dart';
 import 'package:blood_pressure_app/features/statistics/clock_bp_graph.dart';
+import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:blood_pressure_app/model/blood_pressure_analyzer.dart';
 import 'package:blood_pressure_app/model/storage/interval_store.dart';
 import 'package:flutter/material.dart';
-import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:health_data_store/health_data_store.dart';
+import 'package:provider/provider.dart';
 
 /// A page that shows statistics about stored blood pressure values.
 class StatisticsScreen extends StatefulWidget {
@@ -27,15 +28,24 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
       ),
       body: RepositoryBuilder<BloodPressureRecord, BloodPressureRepository>(
         rangeType: IntervalStoreManagerLocation.statsPage,
-        onData: (context, data) {
-          final analyzer = BloodPressureAnalyser(data.toList());
+        onData: (context, records) {
+          final manager = context.watch<IntervalStoreManager>();
+          final timeLimitRange = manager.get(IntervalStoreManagerLocation.statsPage)
+              .timeLimitRange;
+          if (timeLimitRange != null) {
+            records = records.where((r) {
+              final time = TimeOfDay.fromDateTime(r.time);
+              return time.isAfter(timeLimitRange.start) && time.isBefore(timeLimitRange.end);
+            }).toList();
+          }
+          final analyzer = BloodPressureAnalyser(records.toList());
           return ListView(
             children: [
               _buildSubTitle(localizations.statistics,),
               ListTile(
                 title: Text(localizations.measurementCount),
                 trailing: Text(
-                  data.length.toString(),
+                  records.length.toString(),
                   style: Theme.of(context).textTheme.headlineSmall,
                 ),
               ),
@@ -51,11 +61,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
                 height: 260,
                 padding: const EdgeInsets.symmetric(horizontal: 16.0),
                 child: BloodPressureDistribution(
-                  records: data,
+                  records: records,
                 ),
               ),
               _buildSubTitle(localizations.timeResolvedMetrics),
-              ClockBpGraph(measurements: data),
+              ClockBpGraph(measurements: records),
             ],
           );
         },
app/test/data_util/interval_picker_test.dart
@@ -1,4 +1,4 @@
-import 'package:blood_pressure_app/data_util/interval_picker.dart';
+import 'package:blood_pressure_app/features/data_picker/interval_picker.dart';
 import 'package:blood_pressure_app/model/storage/interval_store.dart';
 import 'package:flutter/material.dart';
 import 'package:blood_pressure_app/l10n/app_localizations.dart';
app/test/screens/home_screen_test.dart
@@ -1,4 +1,4 @@
-import 'package:blood_pressure_app/data_util/interval_picker.dart';
+import 'package:blood_pressure_app/features/data_picker/interval_picker.dart';
 import 'package:blood_pressure_app/features/home/navigation_action_buttons.dart';
 import 'package:blood_pressure_app/features/measurement_list/compact_measurement_list.dart';
 import 'package:blood_pressure_app/features/measurement_list/measurement_list.dart';