final class DomainException implements Exception { const DomainException(this.message); final String message; @override String toString() => message; } final class ValidationException extends DomainException { const ValidationException(super.message); } final class EmailAlreadyTakenException extends DomainException { const EmailAlreadyTakenException() : super('Email already taken.'); } final class InvalidCredentialsException extends DomainException { const InvalidCredentialsException() : super('Invalid credentials.'); } final class UnauthorizedException extends DomainException { const UnauthorizedException() : super('Unauthorized.'); } final class UserAccount { UserAccount({ required String id, required String email, required String passwordHash, this.displayName, required DateTime createdAt, required DateTime updatedAt, DateTime? disabledAt, }) : id = _nonBlank(id, 'User id'), email = _nonBlank(email, 'Email').toLowerCase(), passwordHash = _nonBlank(passwordHash, 'Password hash'), createdAt = createdAt.toUtc(), updatedAt = updatedAt.toUtc(), disabledAt = disabledAt?.toUtc(); final String id; final String email; final String passwordHash; final String? displayName; final DateTime createdAt; final DateTime updatedAt; final DateTime? disabledAt; bool get isDisabled => disabledAt != null; } final class AuthSession { AuthSession({ required String id, required String userId, required String tokenHash, required DateTime issuedAt, required DateTime expiresAt, DateTime? revokedAt, this.userAgent, this.deviceLabel, }) : id = _nonBlank(id, 'Auth session id'), userId = _nonBlank(userId, 'User id'), tokenHash = _nonBlank(tokenHash, 'Token hash'), issuedAt = issuedAt.toUtc(), expiresAt = expiresAt.toUtc(), revokedAt = revokedAt?.toUtc() { if (!this.expiresAt.isAfter(this.issuedAt)) { throw const ValidationException( 'Auth session expiration must be after issue time.', ); } } final String id; final String userId; final String tokenHash; final DateTime issuedAt; final DateTime expiresAt; final DateTime? revokedAt; final String? userAgent; final String? deviceLabel; bool isActiveAt(DateTime now) { final instant = now.toUtc(); return revokedAt == null && expiresAt.isAfter(instant); } } enum SyncedResourceType { exercise('exercise'), program('program'), workoutTemplate('workoutTemplate'), workoutHistory('workoutHistory'), mediaAsset('mediaAsset'); const SyncedResourceType(this.wireName); final String wireName; static SyncedResourceType parse(String value) { for (final type in values) { if (type.wireName == value) { return type; } } throw ValidationException('Unsupported resource type: $value.'); } } final class SyncedResource { SyncedResource({ required String serverId, required String ownerUserId, required this.resourceType, required String clientId, required Map payloadJson, required this.schemaVersion, required DateTime clientUpdatedAt, required DateTime serverUpdatedAt, DateTime? deletedAt, this.originDeviceId, }) : serverId = _nonBlank(serverId, 'Server resource id'), ownerUserId = _nonBlank(ownerUserId, 'Owner user id'), clientId = _nonBlank(clientId, 'Client resource id'), payloadJson = Map.unmodifiable(payloadJson), clientUpdatedAt = clientUpdatedAt.toUtc(), serverUpdatedAt = serverUpdatedAt.toUtc(), deletedAt = deletedAt?.toUtc() { if (schemaVersion <= 0) { throw const ValidationException('Schema version must be positive.'); } } final String serverId; final String ownerUserId; final SyncedResourceType resourceType; final String clientId; final Map payloadJson; final int schemaVersion; final DateTime clientUpdatedAt; final DateTime serverUpdatedAt; final DateTime? deletedAt; final String? originDeviceId; } String _nonBlank(String value, String label) { final trimmed = value.trim(); if (trimmed.isEmpty) { throw ValidationException('$label must not be blank.'); } return trimmed; }