class OpeningInterval {
const OpeningInterval(this.startMinutes, this.endMinutes);
final int startMinutes;
final int endMinutes;
bool get crossesMidnight => endMinutes <= startMinutes;
String get label => '${_time(startMinutes)}–${_time(endMinutes)}';
static String _time(int minutes) {
final normalized = minutes % 1440;
final h = normalized ~/ 60;
final m = normalized % 60;
return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
}
}
enum OpeningState {
open,
closed,
closingSoon,
openingSoon,
alwaysAccessible,
unknown,
}
class OpeningHoursInfo {
const OpeningHoursInfo({
required this.originalText,
required this.schedule,
required this.knownDays,
required this.state,
required this.placeholder,
required this.alwaysAccessible,
required this.notes,
this.minutesToChange,
});
final String originalText;
final Map<int, List<OpeningInterval>> schedule;
final Set<int> knownDays;
final OpeningState state;
final bool placeholder;
final bool alwaysAccessible;
final List<String> notes;
final int? minutesToChange;
bool get hasStructuredSchedule => schedule.values.any((e) => e.isNotEmpty);
bool get canShow => !placeholder && originalText.trim().isNotEmpty;
List<OpeningInterval> intervalsForWeekday(int weekday) =>
schedule[weekday] ?? const <OpeningInterval>[];
}
abstract final class OpeningHoursService {
static const int soonThresholdMinutes = 60;
static OpeningHoursInfo parse(String? raw, {DateTime? utcNow}) {
final original = (raw ?? '').trim();
if (original.isEmpty) {
return const OpeningHoursInfo(
originalText: '',
schedule: <int, List<OpeningInterval>>{},
knownDays: <int>{},
state: OpeningState.unknown,
placeholder: false,
alwaysAccessible: false,
notes: [],
);
}
final normalized = _normalize(original);
final lower = normalized.toLowerCase();
final placeholder = lower.contains('testdaten') ||
lower.contains('bitte später') ||
lower.contains('bitte spaeter') ||
lower.contains('platzhalter') ||
lower.contains('mit echten daten ersetzen');
if (placeholder) {
return OpeningHoursInfo(
originalText: original,
schedule: const <int, List<OpeningInterval>>{},
knownDays: const <int>{},
state: OpeningState.unknown,
placeholder: true,
alwaysAccessible: false,
notes: const [],
);
}
final hasAnyExactInterval = _parseIntervals(lower).isNotEmpty;
final alwaysAccessible = !hasAnyExactInterval &&
(lower.contains('jederzeit') ||
lower.contains('24/7') ||
lower.contains('rund um die uhr') ||
lower.contains('rund-um-die-uhr'));
final schedule = <int, List<OpeningInterval>>{
for (var day = DateTime.monday; day <= DateTime.sunday; day++) day: [],
};
final notes = <String>[];
final knownDays = <int>{};
final clauses = normalized
.split(RegExp(r'[;\n]+'))
.map((e) => e.trim())
.where((e) => e.isNotEmpty);
for (final clause in clauses) {
final clauseLower = clause.toLowerCase();
if (_isSecondaryService(clauseLower)) {
notes.add(clause);
continue;
}
final days = _parseDays(clauseLower);
final intervals = _parseIntervals(clauseLower);
final closed = clauseLower.contains('geschlossen') ||
clauseLower.contains('closed') ||
clauseLower.contains('fermé') ||
clauseLower.contains('ferme');
if (days.isEmpty) {
if (!alwaysAccessible) notes.add(clause);
continue;
}
if (closed) {
for (final day in days) {
schedule[day] = <OpeningInterval>[];
knownDays.add(day);
}
continue;
}
if (intervals.isEmpty) {
notes.add(clause);
continue;
}
for (final day in days) {
schedule[day]!.addAll(intervals);
knownDays.add(day);
}
}
for (final intervals in schedule.values) {
intervals.sort((a, b) => a.startMinutes.compareTo(b.startMinutes));
}
final berlinNow = _berlinNow(utcNow ?? DateTime.now().toUtc());
final status = alwaysAccessible
? const _StatusResult(OpeningState.alwaysAccessible)
: _statusFor(schedule, knownDays, berlinNow);
return OpeningHoursInfo(
originalText: original,
schedule: schedule,
knownDays: knownDays,
state: status.state,
placeholder: false,
alwaysAccessible: alwaysAccessible,
notes: notes.toSet().toList(),
minutesToChange: status.minutesToChange,
);
}
static String _normalize(String value) => value
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n')
.replaceAll('\t', ' ')
.replaceAll('–', '-')
.replaceAll('—', '-')
.replaceAll('−', '-')
.replaceAll(RegExp(r'[ ]+'), ' ')
.trim();
static bool _isSecondaryService(String clause) {
const words = <String>[
'küche',
'kueche',
'frühstück',
'fruehstueck',
'mittag',
'lunch',
'breakfast',
'kitchen',
'warme küche',
'warme kueche',
];
return words.any(clause.contains);
}
static Set<int> _parseDays(String value) {
if (value.contains('täglich') ||
value.contains('taeglich') ||
value.contains('jeden tag') ||
value.contains('daily')) {
return {
DateTime.monday,
DateTime.tuesday,
DateTime.wednesday,
DateTime.thursday,
DateTime.friday,
DateTime.saturday,
DateTime.sunday,
};
}
const token =
r'(mo(?:ntag)?|di(?:enstag)?|mi(?:ttwoch)?|do(?:nnerstag)?|fr(?:eitag)?|sa(?:mstag)?|so(?:nntag)?|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?|sun(?:day)?)';
final regex = RegExp('$token\\s*(?:-\\s*$token)?', caseSensitive: false);
final days = <int>{};
for (final match in regex.allMatches(value)) {
final start = _dayNumber(match.group(1));
final end = _dayNumber(match.group(2));
if (start == null) continue;
if (end == null) {
days.add(start);
continue;
}
var day = start;
for (var guard = 0; guard < 7; guard++) {
days.add(day);
if (day == end) break;
day = day == DateTime.sunday ? DateTime.monday : day + 1;
}
}
return days;
}
static int? _dayNumber(String? token) {
if (token == null) return null;
final value = token.toLowerCase();
if (value.startsWith('mo')) return DateTime.monday;
if (value.startsWith('di') || value.startsWith('tu')) {
return DateTime.tuesday;
}
if (value.startsWith('mi') || value.startsWith('we')) {
return DateTime.wednesday;
}
if (value.startsWith('do') || value.startsWith('th')) {
return DateTime.thursday;
}
if (value.startsWith('fr')) return DateTime.friday;
if (value.startsWith('sa')) return DateTime.saturday;
if (value.startsWith('so') || value.startsWith('su')) {
return DateTime.sunday;
}
return null;
}
static List<OpeningInterval> _parseIntervals(String value) {
final result = <OpeningInterval>[];
final regex = RegExp(
r'(\d{1,2})(?::(\d{2}))?\s*-\s*(\d{1,2})(?::(\d{2}))?',
);
for (final match in regex.allMatches(value)) {
final sh = int.tryParse(match.group(1) ?? '');
final sm = int.tryParse(match.group(2) ?? '0') ?? 0;
final eh = int.tryParse(match.group(3) ?? '');
final em = int.tryParse(match.group(4) ?? '0') ?? 0;
if (sh == null ||
eh == null ||
sh > 23 ||
eh > 24 ||
sm > 59 ||
em > 59) {
continue;
}
final start = sh * 60 + sm;
final end = eh == 24 ? 1440 : eh * 60 + em;
if (start == end) continue;
result.add(OpeningInterval(start, end));
}
return result;
}
static _StatusResult _statusFor(
Map<int, List<OpeningInterval>> schedule,
Set<int> knownDays,
DateTime now,
) {
final minute = now.hour * 60 + now.minute;
final today = now.weekday;
final yesterday = today == DateTime.monday ? DateTime.sunday : today - 1;
for (final interval in schedule[yesterday] ?? const <OpeningInterval>[]) {
if (interval.crossesMidnight && minute < interval.endMinutes) {
final remaining = interval.endMinutes - minute;
return _StatusResult(
remaining <= soonThresholdMinutes
? OpeningState.closingSoon
: OpeningState.open,
remaining,
);
}
}
if (!knownDays.contains(today)) {
return const _StatusResult(OpeningState.unknown);
}
for (final interval in schedule[today] ?? const <OpeningInterval>[]) {
if (!interval.crossesMidnight &&
minute >= interval.startMinutes &&
minute < interval.endMinutes) {
final remaining = interval.endMinutes - minute;
return _StatusResult(
remaining <= soonThresholdMinutes
? OpeningState.closingSoon
: OpeningState.open,
remaining,
);
}
if (interval.crossesMidnight && minute >= interval.startMinutes) {
final remaining = 1440 - minute + interval.endMinutes;
return _StatusResult(
remaining <= soonThresholdMinutes
? OpeningState.closingSoon
: OpeningState.open,
remaining,
);
}
}
var nextOpening = 8 * 1440;
for (var offset = 0; offset <= 7; offset++) {
final day = ((today - 1 + offset) % 7) + 1;
for (final interval in schedule[day] ?? const <OpeningInterval>[]) {
final delta = offset * 1440 + interval.startMinutes - minute;
if (delta > 0 && delta < nextOpening) nextOpening = delta;
}
}
if (nextOpening <= soonThresholdMinutes) {
return _StatusResult(OpeningState.openingSoon, nextOpening);
}
return const _StatusResult(OpeningState.closed);
}
static DateTime _berlinNow(DateTime utc) {
final normalized = utc.toUtc();
final year = normalized.year;
final dstStart = DateTime.utc(year, 3, _lastSunday(year, 3), 1);
final dstEnd = DateTime.utc(year, 10, _lastSunday(year, 10), 1);
final isDst = !normalized.isBefore(dstStart) && normalized.isBefore(dstEnd);
return normalized.add(Duration(hours: isDst ? 2 : 1));
}
static int _lastSunday(int year, int month) {
final lastDay = DateTime.utc(year, month + 1, 0);
return lastDay.day - (lastDay.weekday % 7);
}
}
class _StatusResult {
const _StatusResult(this.state, [this.minutesToChange]);
final OpeningState state;
final int? minutesToChange;
}

