-
Notifications
You must be signed in to change notification settings - Fork 31
/
date_builder.dart
329 lines (291 loc) · 12.6 KB
/
date_builder.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:clock/clock.dart';
import 'date_computation.dart' as date_computation;
/// A class for holding onto the data for a date so that it can be built
/// up incrementally.
class DateBuilder {
// Default the date values to the EPOCH so that there's a valid date
// in case the format doesn't set them.
int year = 1970,
month = 1,
day = 1,
dayOfYear = 0,
hour = 0,
minute = 0,
second = 0,
fractionalSecond = 0;
bool pm = false;
bool utc = false;
/// Whether the century portion of [year] is ambiguous.
///
/// Ignored if `year < 0` or `year >= 100`.
bool _hasAmbiguousCentury = false;
/// The locale, kept for logging purposes when there's an error.
final String _locale;
/// The date result produced from [asDate].
///
/// Kept as a field to cache the result and to reduce the possibility of error
/// after we've verified.
DateTime? _date;
/// The number of times we've retried, for error reporting.
int _retried = 0;
/// Is this constructing a pure date.
///
/// This is important because some locales change times at midnight,
/// e.g. Brazil. So if we try to create a DateTime representing a date at
/// midnight on the day of transition it will jump forward or back 1 hour. If
/// it jumps forward that's mostly harmless if we only care about the
/// date. But if it jumps backwards that will change the date, which is
/// bad. Compensate by adjusting the time portion forward. But only do that
/// when we're explicitly trying to construct a date, which we can tell from
/// the format.
// We do set it, the analyzer just can't tell.
bool dateOnly = false;
/// The function we will call to create a DateTime from its component pieces.
///
/// This is normally only modified in tests that want to introduce errors.
final _DateTimeConstructor _dateTimeConstructor;
DateBuilder(this._locale, this._dateTimeConstructor);
// Functions that exist just to be closurized so we can pass them to a general
// method.
void setYear(int x) {
year = x;
}
/// Sets whether [year] should be treated as ambiguous because it lacks a
/// century.
void setHasAmbiguousCentury(bool isAmbiguous) {
_hasAmbiguousCentury = isAmbiguous;
}
void setMonth(int x) {
month = x;
}
void setDay(int x) {
day = x;
}
void setDayOfYear(int x) {
dayOfYear = x;
}
/// If [dayOfYear] has been set, return it, otherwise return [day], indicating
/// the day of the month.
int get dayOrDayOfYear => dayOfYear == 0 ? day : dayOfYear;
void setHour(int x) {
hour = x;
}
void setMinute(int x) {
minute = x;
}
void setSecond(int x) {
second = x;
}
void setFractionalSecond(int x) {
fractionalSecond = x;
}
int get hour24 => pm ? hour + 12 : hour;
/// Verify that we correspond to a valid date. This will reject out of
/// range values, even if the DateTime constructor would accept them. An
/// invalid message will result in throwing a [FormatException].
void verify(String s) {
_verify(month, 1, 12, 'month', s);
_verify(hour24, 0, 23, 'hour', s);
_verify(minute, 0, 59, 'minute', s);
_verify(second, 0, 59, 'second', s);
_verify(fractionalSecond, 0, 999, 'fractional second', s);
// Verifying the day is tricky, because it depends on the month. Create
// our resulting date and then verify that our values agree with it
// as an additional verification. And since we're doing that, also
// check the year, which we otherwise can't verify, and the hours,
// which will catch cases like '14:00:00 PM'.
var date = asDate();
// On rare occasions, possibly related to DST boundaries, a parsed date will
// come out as 1:00am. We compensate for the case of going backwards in
// _correctForErrors, but we may not be able to compensate for a midnight
// that doesn't exist. So tolerate an hour value of zero or one in these
// cases.
var minimumDate = dateOnly && date.hour == 1 ? 0 : date.hour;
_verify(hour24, minimumDate, date.hour, 'hour', s, date);
if (dayOfYear > 0) {
// We have an ordinal date, compute the corresponding date for the result
// and compare to that.
var leapYear = date_computation.isLeapYear(date);
var correspondingDay =
date_computation.dayOfYear(date.month, date.day, leapYear);
_verify(
dayOfYear, correspondingDay, correspondingDay, 'dayOfYear', s, date);
} else {
// We have the day of the month, compare directly.
_verify(day, date.day, date.day, 'day', s, date);
}
_verify(year, date.year, date.year, 'year', s, date);
}
void _verify(int value, int min, int max, String desc, String originalInput,
[DateTime? parsed]) {
if (value < min || value > max) {
var parsedDescription = parsed == null ? '' : ' Date parsed as $parsed.';
var errorDescription =
'Error parsing $originalInput, invalid $desc value: $value'
' in $_locale'
' with time zone offset ${parsed?.timeZoneOffset ?? 'unknown'}.'
' Expected value between $min and $max.$parsedDescription.';
if (_retried > 0) {
errorDescription += ' Failed after $_retried retries.';
}
throw FormatException(errorDescription);
}
}
/// Offsets a [DateTime] by a specified number of years.
///
/// All other fields of the [DateTime] normally will remain unaffected. An
/// exception is if the resulting [DateTime] otherwise would represent an
/// invalid date (e.g. February 29 of a non-leap year).
DateTime _offsetYear(DateTime dateTime, int offsetYears) =>
_dateTimeConstructor(
dateTime.year + offsetYears,
dateTime.month,
dateTime.day,
dateTime.hour,
dateTime.minute,
dateTime.second,
dateTime.millisecond,
dateTime.isUtc);
/// Return a date built using our values. If no date portion is set,
/// use the 'Epoch' of January 1, 1970.
DateTime asDate({int retries = 3}) {
// TODO(alanknight): Validate the date, especially for things which
// can crash the VM, e.g. large month values.
if (_date != null) return _date!;
DateTime preliminaryResult;
final hasCentury = !_hasAmbiguousCentury || year < 0 || year >= 100;
if (hasCentury) {
preliminaryResult = _dateTimeConstructor(year, month, dayOrDayOfYear,
hour24, minute, second, fractionalSecond, utc);
} else {
var now = clock.now();
if (utc) {
now = now.toUtc();
}
const lookBehindYears = 80;
var lowerDate = _offsetYear(now, -lookBehindYears);
var upperDate = _offsetYear(now, 100 - lookBehindYears);
var lowerCentury = (lowerDate.year ~/ 100) * 100;
var upperCentury = (upperDate.year ~/ 100) * 100;
preliminaryResult = _dateTimeConstructor(upperCentury + year, month,
dayOrDayOfYear, hour24, minute, second, fractionalSecond, utc);
// Our interval must be half-open since there otherwise could be ambiguity
// for a date that is exactly 20 years in the future or exactly 80 years
// in the past (mod 100). We'll treat the lower-bound date as the
// exclusive bound because:
// * It's farther away from the present, and we're less likely to care
// about it.
// * By the time this function exits, time will have advanced to favor
// the upper-bound date.
//
// We don't actually need to check both bounds.
if (preliminaryResult.compareTo(upperDate) <= 0) {
// Within range.
assert(preliminaryResult.compareTo(lowerDate) > 0);
} else {
preliminaryResult = _dateTimeConstructor(lowerCentury + year, month,
dayOrDayOfYear, hour24, minute, second, fractionalSecond, utc);
}
}
if (utc && hasCentury) {
_date = preliminaryResult;
} else {
_date = _correctForErrors(preliminaryResult, retries);
}
return _date!;
}
/// Given a local DateTime, check for errors and try to compensate for them if
/// possible.
DateTime _correctForErrors(DateTime result, int retries) {
// There are 3 kinds of errors that we know of
//
// 1 - Issue 15560, sometimes we get UTC even when we asked for local, or
// they get constructed as if in UTC and then have the offset subtracted.
// Retry, possibly several times, until we get something that looks valid,
// or we give up.
//
// 1a) - It appears that sometimes we get incorrect timezone offsets that
// are not directly related to UTC. Also check for those and retry or
// compensate.
//
// 2 - Timezone transitions. If we ask for the time during a timezone
// transition then it will offset it by that transition. This is
// particularly a problem if the timezone transition happens at midnight,
// and we're looking for a date with no time component. This happens in
// Brazil, and we can end up with 11:00pm the previous day. Add time to
// compensate.
//
// 3 - Invalid input which the constructor nevertheless accepts. Just return
// what it created, and verify will catch it if we're in strict mode.
// If we've exhausted our retries, just return the input - it's not just a
// flaky result.
if (retries <= 0) {
return result;
}
var leapYear = date_computation.isLeapYear(result);
var resultDayOfYear =
date_computation.dayOfYear(result.month, result.day, leapYear);
// Check for the UTC failure. Are we expecting to produce a local time, but
// the result is UTC. However, the local time might happen to be the same as
// UTC. To be thorough, check if either the hour/day don't agree with what
// we expect, or is a new DateTime in a non-UTC timezone.
if (!utc &&
result.isUtc &&
(result.hour != hour24 ||
result.day != resultDayOfYear ||
!DateTime.now().isUtc)) {
// This may be a UTC failure. Retry and if the result doesn't look
// like it's in the UTC time zone, use that instead.
_retried++;
return asDate(retries: retries - 1);
}
if (dateOnly && result.hour != 0) {
// This could be a flake, try again.
var tryAgain = asDate(retries: retries - 1);
if (tryAgain != result) {
// Trying again gave a different answer, so presumably it worked.
return tryAgain;
}
// Trying again didn't work, try to force the offset.
var expectedDayOfYear = dayOfYear == 0
? date_computation.dayOfYear(month, day, leapYear)
: dayOfYear;
// If we're _dateOnly, then hours should be zero, but might have been
// offset to e.g. 11:00pm the previous day. Add that time back in. This
// might be because of an erratic error, but it might also be because of a
// time zone (Brazil) where there is no midnight at a daylight savings
// time transition. In that case we will retry, but eventually give up and
// return 1:00am on the correct date.
var daysPrevious = expectedDayOfYear - resultDayOfYear;
// For example, if it's the day before at 11:00pm, we offset by (24 - 23),
// so +1. If it's the same day at 1:00am, we offset by (0 - 1), so -1.
var offset = (daysPrevious * 24) - result.hour;
var adjusted = result.add(Duration(hours: offset));
// Check if the adjustment worked. This can fail on a time zone transition
// where midnight doesn't exist.
if (adjusted.hour == 0) {
return adjusted;
}
// Adjusting did not work. Just check if the adjusted date is right. And
// if it's not, just give up and return [result]. The scenario where this
// might correctly happen is if we're in a Brazil time zone, jump forward
// to 1:00 am because of a DST transition, and trying to go backwards 1
// hour takes us back to 11:00pm the day before. In that case the 1:00am
// answer on the correct date is preferable.
var adjustedDayOfYear =
date_computation.dayOfYear(adjusted.month, adjusted.day, leapYear);
if (adjustedDayOfYear != expectedDayOfYear) {
return result;
}
return adjusted;
}
// None of our corrections applied, just return the uncorrected date.
return result;
}
}
/// Defines a function type for creating DateTime instances.
typedef _DateTimeConstructor = DateTime Function(int year, int month, int day,
int hour24, int minute, int second, int fractionalSecond, bool utc);