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}