main
  1import 'dart:convert';
  2
  3import 'package:blood_pressure_app/l10n/app_localizations.dart';
  4import 'package:blood_pressure_app/model/storage/convert_util.dart';
  5import 'package:blood_pressure_app/model/storage/db/settings_loader.dart';
  6import 'package:flutter/material.dart';
  7import 'package:health_data_store/health_data_store.dart';
  8
  9/// Class for storing the current interval, as it is needed in start page, statistics and export.
 10class IntervalStorage extends ChangeNotifier {
 11  /// Create a instance from a map created by [toMap].
 12  factory IntervalStorage.fromMap(Map<String, dynamic> map) => IntervalStorage(
 13    stepSize: TimeStep.deserialize(map['stepSize']),
 14    range: ConvertUtil.parseRange(map['start'], map['end']),
 15    timeRange: TimeRange.fromJson(map['timeRange'])
 16  );
 17
 18  /// Create a instance from a [String] created by [toJson].
 19  factory IntervalStorage.fromJson(String json) {
 20    try {
 21      return IntervalStorage.fromMap(jsonDecode(json));
 22    } catch (exception) {
 23      return IntervalStorage();
 24    }
 25  }
 26
 27  /// Create a storage to interact with a display intervall.
 28  IntervalStorage({TimeStep? stepSize, DateRange? range, TimeRange? timeRange}) :
 29    _stepSize = stepSize ?? TimeStep.last7Days {
 30    _currentRange = range ?? _getMostRecentDisplayIntervall();
 31    _timeRange = timeRange;
 32  }
 33
 34  TimeStep _stepSize;
 35
 36  late DateRange _currentRange;
 37
 38  TimeRange? _timeRange;
 39
 40  /// Serialize the object to a restoreable map.
 41  Map<String, dynamic> toMap() => <String, dynamic>{
 42    'stepSize': stepSize.serialize(),
 43    'start': currentRange.start.millisecondsSinceEpoch,
 44    'end': currentRange.end.millisecondsSinceEpoch,
 45    'timeRange': _timeRange?.toJson(),
 46  };
 47
 48  /// Serialize the object to a restoreable string.
 49  String toJson() => jsonEncode(toMap());
 50
 51  /// The stepSize gets set through the changeStepSize method.
 52  TimeStep get stepSize => _stepSize;
 53
 54  // TODO: programmatically ensure this is respected:
 55  /// The [TimeRange] used to limit data selection if non-null.
 56  ///
 57  /// Data points must fall on or between the start and end times to be selected.
 58  TimeRange? get timeLimitRange => _timeRange;
 59
 60  set timeLimitRange(TimeRange? value) {
 61    _timeRange = value;
 62    notifyListeners();
 63  }
 64
 65  /// sets the stepSize to the new value and resets the currentRange to the most recent one. 
 66  void changeStepSize(TimeStep value) {
 67    _stepSize = value;
 68    setToMostRecentInterval();
 69  }
 70
 71  DateRange get currentRange => _currentRange;
 72
 73  set currentRange(DateRange value) {
 74    _currentRange = value;
 75    notifyListeners();
 76  }
 77
 78  /// Sets internal _currentRange to the most recent intervall and notifies listeners.
 79  void setToMostRecentInterval() {
 80    _currentRange = _getMostRecentDisplayIntervall();
 81    notifyListeners();
 82  }
 83
 84  void moveDataRangeByStep(int directionalStep) {
 85    final oldStart = currentRange.start;
 86    final oldEnd = currentRange.end;
 87    currentRange = switch (stepSize) {
 88      TimeStep.day => DateRange(
 89        start: oldStart.copyWith(day: oldStart.day + directionalStep),
 90        end: oldEnd.copyWith(day: oldEnd.day + directionalStep),
 91      ),
 92      TimeStep.week || TimeStep.last7Days => DateRange(
 93        start: oldStart.copyWith(day: oldStart.day + 7 * directionalStep),
 94        end: oldEnd.copyWith(day: oldEnd.day + 7 * directionalStep),
 95      ),
 96      TimeStep.month => DateRange(
 97        // No fitting Duration: wraps correctly according to doc
 98        start: oldStart.copyWith(month: oldStart.month + directionalStep),
 99        end: oldEnd.copyWith(month: oldEnd.month + directionalStep),
100      ),
101      TimeStep.year => DateRange(
102        // No fitting Duration: wraps correctly according to doc
103        start: oldStart.copyWith(year: oldStart.year + directionalStep),
104        end: oldEnd.copyWith(year: oldEnd.year + directionalStep),
105      ),
106      TimeStep.lifetime => DateRange(
107        start: DateTime.fromMillisecondsSinceEpoch(1),
108        end: DateTime.now().copyWith(hour: 23, minute: 59, second: 59),
109      ),
110      TimeStep.last30Days => DateRange(
111        start: oldStart.copyWith(day: oldStart.day + 30 * directionalStep),
112        end: oldEnd.copyWith(day: oldEnd.day + 30 * directionalStep),
113      ),
114      TimeStep.custom => DateRange(
115        start: oldStart.add(oldEnd.difference(oldStart) * directionalStep),
116        end: oldEnd.add(oldEnd.difference(oldStart) * directionalStep),
117      ),
118    };
119  }
120
121  DateRange _getMostRecentDisplayIntervall() {
122    final now = DateTime.now();
123    switch (stepSize) {
124      case TimeStep.day:
125        final start = DateTime(now.year, now.month, now.day);
126        return DateRange(start: start, end: start.copyWith(day: now.day + 1));
127      case TimeStep.week:
128        final start = DateTime(now.year, now.month, now.day - (now.weekday - 1)); // monday
129        return DateRange(start: start, end: start.copyWith(day: start.day + DateTime.sunday)); // end of sunday
130      case TimeStep.month:
131        final start = DateTime(now.year, now.month);
132        return DateRange(start: start, end: start.copyWith(month: now.month + 1));
133      case TimeStep.year:
134        final start = DateTime(now.year);
135        return DateRange(start: start, end: start.copyWith(year: now.year + 1));
136      case TimeStep.lifetime:
137        final start = DateTime.fromMillisecondsSinceEpoch(1);
138        final endOfToday = now.copyWith(hour: 23, minute: 59, second: 59);
139        return DateRange(start: start, end: endOfToday);
140      case TimeStep.last7Days:
141        final start = now.subtract(const Duration(days: 7));
142        final endOfToday = now.copyWith(hour: 23, minute: 59, second: 59);
143        return DateRange(start: start, end: endOfToday);
144      case TimeStep.last30Days:
145        final start = now.subtract(const Duration(days: 30));
146        final endOfToday = now.copyWith(hour: 23, minute: 59, second: 59);
147        return DateRange(start: start, end: endOfToday);
148      case TimeStep.custom:
149        return DateRange(
150          start: now.subtract(currentRange.duration),
151          end: now,
152        );
153    }
154  }
155}
156
157/// Different range types supported by the interval switcher.
158enum TimeStep {
159  day,
160  month,
161  year,
162  lifetime,
163  week,
164  last7Days,
165  last30Days,
166  custom;
167
168  /// Recreate a TimeStep from a number created with [TimeStep.serialize].
169  factory TimeStep.deserialize(Object? value) {
170    final int? intValue = ConvertUtil.parseInt(value);
171    assert(intValue == null || intValue >= 0 && intValue <= 7);
172    return switch (intValue) {
173      null => TimeStep.last7Days,
174      0 => TimeStep.day,
175      1 => TimeStep.month,
176      2 => TimeStep.year,
177      3 => TimeStep.lifetime,
178      4 => TimeStep.week,
179      5 => TimeStep.last7Days,
180      6 => TimeStep.last30Days,
181      7 => TimeStep.custom,
182      _ => TimeStep.last7Days,
183    };
184  }
185
186  /// Select a displayable string from [localizations].
187  String localize(AppLocalizations localizations) => switch (this) {
188    TimeStep.day => localizations.day,
189    TimeStep.month => localizations.month,
190    TimeStep.year => localizations.year,
191    TimeStep.lifetime => localizations.lifetime,
192    TimeStep.week => localizations.week,
193    TimeStep.last7Days => localizations.last7Days,
194    TimeStep.last30Days => localizations.last30Days,
195    TimeStep.custom =>  localizations.custom,
196  };
197
198  int serialize() =>switch (this) {
199    TimeStep.day => 0,
200    TimeStep.month => 1,
201    TimeStep.year => 2,
202    TimeStep.lifetime => 3,
203    TimeStep.week => 4,
204    TimeStep.last7Days => 5,
205    TimeStep.last30Days => 6,
206    TimeStep.custom => 7,
207  };
208}
209
210/// Class that stores the interval objects that are needed in the app and provides named access to them. 
211class IntervalStoreManager extends ChangeNotifier {
212  /// Constructor for creating [IntervalStoreManager] from items.
213  ///
214  /// You should use [SettingsLoader.loadExportColumnsManager] for most cases.
215  IntervalStoreManager(this.mainPage, this.exportPage, this.statsPage) {
216    mainPage.addListener(notifyListeners);
217    exportPage.addListener(notifyListeners);
218    statsPage.addListener(notifyListeners);
219  }
220
221  IntervalStorage get(IntervalStoreManagerLocation type) => switch (type) {
222    IntervalStoreManagerLocation.mainPage => mainPage,
223    IntervalStoreManagerLocation.exportPage => exportPage,
224    IntervalStoreManagerLocation.statsPage => statsPage,
225  };
226
227  /// Reset all fields to their default values.
228  void reset() {
229    mainPage = IntervalStorage();
230    exportPage = IntervalStorage();
231    statsPage = IntervalStorage();
232    notifyListeners();
233  }
234
235  /// Copy all values from another instance.
236  void copyFrom(IntervalStoreManager other) {
237    mainPage = other.mainPage;
238    exportPage = other.exportPage;
239    statsPage = other.statsPage;
240    notifyListeners();
241  }
242
243  /// Intervall for the page with graph and list.
244  IntervalStorage mainPage;
245
246  /// Intervall for all exports.
247  IntervalStorage exportPage;
248
249  /// Intervall to display statistics in.
250  IntervalStorage statsPage;
251
252  @override
253  void dispose() {
254    super.dispose();
255    mainPage.dispose();
256    exportPage.dispose();
257    statsPage.dispose();
258  }
259}
260
261/// Locations supported by [IntervalStoreManager].
262enum IntervalStoreManagerLocation {
263  /// List on home screen.
264  mainPage,
265  /// All exported data.
266  exportPage,
267  /// Data for all statistics.
268  statsPage,
269}
270
271/// Represents an inclusive time span, defined by a [start] and an [end]
272/// [TimeOfDay].
273///
274/// **Serialization:**
275/// The class serializes the [TimeOfDay] objects into simple string representations
276/// of their hour and minute values (e.g., '14:30' for 2:30 PM).
277class TimeRange {
278  /// Creates a new [TimeRange] with a specified [start] and [end] time.
279  const TimeRange({
280    required this.start,
281    required this.end,
282  });
283
284  /// The starting time of the range (inclusive).
285  final TimeOfDay start;
286
287  /// The ending time of the range (inclusive).
288  final TimeOfDay end;
289
290  /// Serialization to JSON-compatible map
291  Map<String, dynamic> toJson() => {
292      'start': _timeOfDayToString(start),
293      'end': _timeOfDayToString(end),
294    };
295
296  /// Creates a [TimeRange] instance from a JSON map.
297  ///
298  /// Returns `null` if the input map is null or if the required keys ('start', 'end')
299  /// are missing or contain invalid time strings.
300  static TimeRange? fromJson(Map<String, dynamic>? json) {
301    if (json == null || json['start'] is! String || json['end'] is! String) {
302      return null;
303    }
304
305    try {
306      final start = _timeOfDayFromString(json['start'] as String);
307      final end = _timeOfDayFromString(json['end'] as String);
308      return TimeRange(start: start, end: end);
309    } catch (_) {
310      // Return null on parsing errors (e.g., non-numeric parts)
311      return null;
312    }
313  }
314
315  /// Converts a TimeOfDay to 'HH:MM' string.
316  static String _timeOfDayToString(TimeOfDay time) {
317    final hour = time.hour.toString().padLeft(2, '0');
318    final minute = time.minute.toString().padLeft(2, '0');
319    return '$hour:$minute';
320  }
321
322  /// Converts an 'HH:MM' string back to a TimeOfDay.
323  static TimeOfDay _timeOfDayFromString(String timeString) {
324    final parts = timeString.split(':');
325    final hour = int.parse(parts[0]);
326    final minute = int.parse(parts[1]);
327    return TimeOfDay(hour: hour, minute: minute);
328  }
329}