focus-flow/lib/src/time_contracts.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,
);
}
}