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(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({ 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, ); } }