293 lines
7.5 KiB
Dart
293 lines
7.5 KiB
Dart
import 'models.dart';
|
|
|
|
/// Deterministic source of the current instant.
|
|
abstract interface class Clock {
|
|
DateTime now();
|
|
}
|
|
|
|
/// Production clock boundary. Domain services should receive this through
|
|
/// constructors rather than reading wall time directly.
|
|
class SystemClock implements Clock {
|
|
const SystemClock();
|
|
|
|
@override
|
|
DateTime now() => DateTime.now();
|
|
}
|
|
|
|
/// Test/application clock with a fixed instant.
|
|
class FixedClock implements Clock {
|
|
const FixedClock(this.instant);
|
|
|
|
final DateTime instant;
|
|
|
|
@override
|
|
DateTime now() => instant;
|
|
}
|
|
|
|
/// Deterministic ID source for application/domain convenience entry points.
|
|
abstract interface class IdGenerator {
|
|
String nextId();
|
|
}
|
|
|
|
/// Simple deterministic ID generator useful for tests and local wiring.
|
|
class SequentialIdGenerator implements IdGenerator {
|
|
SequentialIdGenerator({
|
|
this.prefix = 'id',
|
|
int start = 1,
|
|
}) : _next = start;
|
|
|
|
final String prefix;
|
|
int _next;
|
|
|
|
@override
|
|
String nextId() {
|
|
final id = '$prefix-$_next';
|
|
_next += 1;
|
|
return id;
|
|
}
|
|
}
|
|
|
|
/// Calendar date without a time or timezone.
|
|
class CivilDate implements Comparable<CivilDate> {
|
|
CivilDate(this.year, this.month, this.day) {
|
|
final normalized = DateTime.utc(year, month, day);
|
|
if (normalized.year != year ||
|
|
normalized.month != month ||
|
|
normalized.day != day) {
|
|
throw DomainValidationException(
|
|
code: DomainValidationCode.invalidCivilDate,
|
|
invalidValue: {'year': year, 'month': month, 'day': day},
|
|
name: 'CivilDate',
|
|
message: 'Civil date must be a valid calendar date.',
|
|
);
|
|
}
|
|
}
|
|
|
|
factory CivilDate.fromDateTime(DateTime value) {
|
|
return CivilDate(value.year, value.month, value.day);
|
|
}
|
|
|
|
factory CivilDate.fromIsoString(String value) {
|
|
if (!RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(value)) {
|
|
throw DomainValidationException(
|
|
code: DomainValidationCode.invalidCivilDate,
|
|
invalidValue: value,
|
|
name: 'CivilDate',
|
|
message: 'Civil date must use YYYY-MM-DD format.',
|
|
);
|
|
}
|
|
final parts = value.split('-');
|
|
return CivilDate(
|
|
int.parse(parts[0]),
|
|
int.parse(parts[1]),
|
|
int.parse(parts[2]),
|
|
);
|
|
}
|
|
|
|
final int year;
|
|
final int month;
|
|
final int day;
|
|
|
|
int get weekday => DateTime.utc(year, month, day).weekday;
|
|
|
|
CivilDate addDays(int days) {
|
|
final shifted = DateTime.utc(year, month, day).add(Duration(days: days));
|
|
return CivilDate(shifted.year, shifted.month, shifted.day);
|
|
}
|
|
|
|
String toIsoString() {
|
|
final monthText = month.toString().padLeft(2, '0');
|
|
final dayText = day.toString().padLeft(2, '0');
|
|
return '$year-$monthText-$dayText';
|
|
}
|
|
|
|
@override
|
|
int compareTo(CivilDate other) {
|
|
final yearComparison = year.compareTo(other.year);
|
|
if (yearComparison != 0) {
|
|
return yearComparison;
|
|
}
|
|
final monthComparison = month.compareTo(other.month);
|
|
if (monthComparison != 0) {
|
|
return monthComparison;
|
|
}
|
|
return day.compareTo(other.day);
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
return other is CivilDate &&
|
|
other.year == year &&
|
|
other.month == month &&
|
|
other.day == day;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(year, month, day);
|
|
|
|
@override
|
|
String toString() => toIsoString();
|
|
}
|
|
|
|
/// Wall-clock time without a date or timezone.
|
|
class WallTime implements Comparable<WallTime> {
|
|
WallTime({
|
|
required this.hour,
|
|
required this.minute,
|
|
}) {
|
|
if (hour < 0 || hour >= 24 || minute < 0 || minute >= 60) {
|
|
throw DomainValidationException(
|
|
code: DomainValidationCode.invalidClockTime,
|
|
invalidValue: {'hour': hour, 'minute': minute},
|
|
name: 'WallTime',
|
|
message: 'Wall time must use hour 0-23 and minute 0-59.',
|
|
);
|
|
}
|
|
}
|
|
|
|
factory WallTime.fromIsoString(String value) {
|
|
if (!RegExp(r'^\d{2}:\d{2}$').hasMatch(value)) {
|
|
throw DomainValidationException(
|
|
code: DomainValidationCode.invalidClockTime,
|
|
invalidValue: value,
|
|
name: 'WallTime',
|
|
message: 'Wall time must use HH:MM format.',
|
|
);
|
|
}
|
|
final parts = value.split(':');
|
|
return WallTime(hour: int.parse(parts[0]), minute: int.parse(parts[1]));
|
|
}
|
|
|
|
final int hour;
|
|
final int minute;
|
|
|
|
int get minutesSinceMidnight => hour * 60 + minute;
|
|
|
|
String toIsoString() {
|
|
return '${hour.toString().padLeft(2, '0')}:'
|
|
'${minute.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
@override
|
|
int compareTo(WallTime other) {
|
|
return minutesSinceMidnight.compareTo(other.minutesSinceMidnight);
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
return other is WallTime && other.hour == hour && other.minute == minute;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(hour, minute);
|
|
|
|
@override
|
|
String toString() => toIsoString();
|
|
}
|
|
|
|
enum LocalTimeResolution {
|
|
exact,
|
|
shiftedForward,
|
|
repeatedEarlier,
|
|
repeatedLater,
|
|
}
|
|
|
|
enum NonexistentLocalTimePolicy {
|
|
/// Move through a daylight-saving gap to the first valid local instant after it.
|
|
shiftForward,
|
|
|
|
/// Reject local times that do not exist in the requested timezone.
|
|
reject,
|
|
}
|
|
|
|
enum RepeatedLocalTimePolicy {
|
|
/// Use the first instant for a repeated local time during a fall-back transition.
|
|
earlier,
|
|
|
|
/// Use the second instant for a repeated local time during a fall-back transition.
|
|
later,
|
|
|
|
/// Reject local times that occur more than once in the requested timezone.
|
|
reject,
|
|
}
|
|
|
|
class TimeZoneResolutionOptions {
|
|
const TimeZoneResolutionOptions({
|
|
this.nonexistentLocalTimePolicy = NonexistentLocalTimePolicy.shiftForward,
|
|
this.repeatedLocalTimePolicy = RepeatedLocalTimePolicy.earlier,
|
|
});
|
|
|
|
final NonexistentLocalTimePolicy nonexistentLocalTimePolicy;
|
|
final RepeatedLocalTimePolicy repeatedLocalTimePolicy;
|
|
}
|
|
|
|
class ResolvedLocalDateTime {
|
|
const ResolvedLocalDateTime({
|
|
required this.date,
|
|
required this.wallTime,
|
|
required this.timeZoneId,
|
|
required this.instant,
|
|
required this.resolution,
|
|
required this.utcOffset,
|
|
});
|
|
|
|
final CivilDate date;
|
|
final WallTime wallTime;
|
|
final String timeZoneId;
|
|
final DateTime instant;
|
|
final LocalTimeResolution resolution;
|
|
final Duration utcOffset;
|
|
}
|
|
|
|
/// Boundary for converting local civil date/time values to instants.
|
|
abstract interface class TimeZoneResolver {
|
|
ResolvedLocalDateTime resolve({
|
|
required CivilDate date,
|
|
required WallTime wallTime,
|
|
required String timeZoneId,
|
|
TimeZoneResolutionOptions options,
|
|
});
|
|
}
|
|
|
|
/// Resolver for zones whose offset is already known by the application boundary.
|
|
class FixedOffsetTimeZoneResolver implements TimeZoneResolver {
|
|
const FixedOffsetTimeZoneResolver.utc() : offset = Duration.zero;
|
|
|
|
const FixedOffsetTimeZoneResolver({required this.offset});
|
|
|
|
final Duration offset;
|
|
|
|
@override
|
|
ResolvedLocalDateTime resolve({
|
|
required CivilDate date,
|
|
required WallTime wallTime,
|
|
required String timeZoneId,
|
|
TimeZoneResolutionOptions options = const TimeZoneResolutionOptions(),
|
|
}) {
|
|
final trimmedZone = timeZoneId.trim();
|
|
if (trimmedZone.isEmpty) {
|
|
throw DomainValidationException(
|
|
code: DomainValidationCode.blankTimeZoneId,
|
|
invalidValue: timeZoneId,
|
|
name: 'timeZoneId',
|
|
message: 'Time-zone id is required.',
|
|
);
|
|
}
|
|
final localAsUtc = DateTime.utc(
|
|
date.year,
|
|
date.month,
|
|
date.day,
|
|
wallTime.hour,
|
|
wallTime.minute,
|
|
);
|
|
|
|
return ResolvedLocalDateTime(
|
|
date: date,
|
|
wallTime: wallTime,
|
|
timeZoneId: trimmedZone,
|
|
instant: localAsUtc.subtract(offset),
|
|
resolution: LocalTimeResolution.exact,
|
|
utcOffset: offset,
|
|
);
|
|
}
|
|
}
|