import '../core/api/meinweimar_api_client.dart';
import '../core/text/wp_text.dart';
import '../models/event.dart';
import '../models/localized_content.dart';
class EventRepository {
const EventRepository(this.api);
final MeinWeimarApiClient api;
Future<List<Event>> fetchAll({required String languageCode}) async {
// Some WordPress language plugins return an empty result when a CPT has
// not explicitly been assigned a language yet. Prefer the localized
// endpoint, but retry without `lang` before treating the event feed as
// empty. This keeps editorial events visible while translations are
// being completed.
var data = await api.getJson(
'/event-feed',
query: {'lang': languageCode, 'per_page': '200'},
cacheMaxAge: const Duration(minutes: 5),
);
var items = (data['items'] as List? ?? const []);
if (items.isEmpty) {
data = await api.getJson(
'/event-feed',
query: {'per_page': '200'},
cacheMaxAge: const Duration(minutes: 5),
);
items = (data['items'] as List? ?? const []);
}
return items
.whereType<Map<String, dynamic>>()
.map((j) => _fromJson(j, languageCode))
.whereType<Event>()
.where((event) => !event.cancelled)
.toList(growable: false)
..sort((a, b) => a.start.compareTo(b.start));
}
Future<EventActionUrls> fetchActions({required String id}) async {
final data = await api.getJson(
'/event-actions/$id',
allowOfflineCache: false,
cacheMaxAge: Duration.zero,
);
return EventActionUrls(
websiteUrl: WpText.url(data['website_url']),
organizerUrl: WpText.url(data['organizer_url']),
ticketUrl: WpText.url(data['ticket_url']),
onlineUrl: WpText.url(data['online_url']),
);
}
Future<Event> fetchOne(
{required String id, required String languageCode}) async {
final data = await api.getJson(
'/event-feed/$id',
query: {'lang': languageCode},
// Event details are editorially changed often enough that a stale
// multi-hour detail cache is undesirable.
cacheMaxAge: const Duration(seconds: 10),
);
final event = _fromJson(data, languageCode);
if (event == null) {
throw const ApiException('event_missing_start_date');
}
return event;
}
Event? _fromJson(Map<String, dynamic> j, String lang) {
final content = _map(j['content']);
final meta = _map(j['meta']);
final location = _map(j['location']);
final organizer = _map(j['organizer']);
final tickets = _map(j['tickets']);
final allDay = _bool(j['all_day'] ?? meta['_mw_event_all_day']);
final start = _eventDateTime(j, meta, start: true, allDay: allDay);
if (start == null) return null;
final end = _eventDateTime(j, meta, start: false, allDay: allDay) ?? start;
final rawCategories = j['categories'];
final categories = rawCategories is List
? rawCategories
.whereType<Map<String, dynamic>>()
.map((row) => EventCategory(
name: WpText.plain(row['name']),
slug: row['slug']?.toString() ?? '',
))
.where((c) => c.name.trim().isNotEmpty)
.toList(growable: false)
: const <EventCategory>[];
final rawTitle = content['title'] ?? _rendered(j['title']);
final rawShort = content['short_text'] ?? _rendered(j['excerpt']);
final rawDescription = content['description_text'] ??
content['description_html'] ??
_rendered(j['content']);
final imageMap = _map(j['image']);
final featuredMedia = j['_embedded'];
String? embeddedImage;
if (featuredMedia is Map<String, dynamic>) {
final media = featuredMedia['wp:featuredmedia'];
if (media is List &&
media.isNotEmpty &&
media.first is Map<String, dynamic>) {
embeddedImage =
WpText.url((media.first as Map<String, dynamic>)['source_url']);
}
}
return Event(
id: j['id'].toString(),
title: LocalizedContent({lang: WpText.plain(rawTitle)}),
shortDescription: LocalizedContent({lang: WpText.plain(rawShort)}),
description: LocalizedContent({lang: WpText.plain(rawDescription)}),
start: start,
end: end.isBefore(start) ? start : end,
allDay: allDay,
status:
(j['status'] ?? meta['_mw_event_status'])?.toString() ?? 'scheduled',
attendanceMode:
(j['attendance_mode'] ?? meta['_mw_event_attendance_mode'])
?.toString() ??
'offline',
imageUrl: WpText.url(imageMap['url'] ??
j['image_url'] ??
j['featured_image'] ??
embeddedImage),
venueName: WpText.nullablePlain(
location['name'] ?? meta['_mw_event_venue_name']),
street:
WpText.nullablePlain(location['street'] ?? meta['_mw_event_street']),
postalCode: WpText.nullablePlain(
location['postal_code'] ?? meta['_mw_event_postal_code']),
city: WpText.nullablePlain(location['city'] ?? meta['_mw_event_city']),
latitude: _double(location['lat'] ?? meta['_mw_event_lat']),
longitude: _double(location['lng'] ?? meta['_mw_event_lng']),
organizerName: WpText.nullablePlain(
organizer['name'] ?? meta['_mw_event_organizer_name']),
websiteUrl: WpText.url(
j['website_url'] ??
j['event_website_url'] ??
meta['_mw_event_website_url'],
),
organizerUrl: WpText.url(
organizer['url'] ??
j['organizer_url'] ??
j['organizer_website'] ??
j['website_url'] ??
meta['_mw_event_organizer_url'],
),
ticketUrl: WpText.url(
tickets['url'] ??
j['ticket_url'] ??
j['booking_url'] ??
meta['_mw_event_ticket_url'],
),
onlineUrl: WpText.url(
j['online_url'] ?? j['stream_url'] ?? meta['_mw_event_online_url'],
),
sourceUrl: WpText.url(j['source_url'] ?? meta['_mw_event_source_url']),
free: _bool(tickets['free'] ?? meta['_mw_event_free']),
bookingRequired: _bool(
tickets['booking_required'] ?? meta['_mw_event_booking_required']),
accessible: _bool(j['accessible'] ?? meta['_mw_event_accessible']),
priceMin: _double(tickets['price_min'] ?? meta['_mw_event_price_min']),
priceMax: _double(tickets['price_max'] ?? meta['_mw_event_price_max']),
currency:
(tickets['currency'] ?? meta['_mw_event_currency'])?.toString() ??
'EUR',
categories: categories,
);
}
Map<String, dynamic> _map(Object? value) =>
value is Map<String, dynamic> ? value : const <String, dynamic>{};
Object? _rendered(Object? value) {
if (value is Map<String, dynamic>) return value['rendered'] ?? value['raw'];
return value;
}
bool _bool(Object? value) {
if (value is bool) return value;
if (value is num) return value != 0;
final text = value?.toString().trim().toLowerCase();
return text == '1' || text == 'true' || text == 'yes' || text == 'on';
}
double? _double(Object? value) {
if (value is num) return value.toDouble();
if (value == null) return null;
return double.tryParse(value.toString().replaceAll(',', '.'));
}
DateTime? _eventDateTime(
Map<String, dynamic> j,
Map<String, dynamic> meta, {
required bool start,
required bool allDay,
}) {
final direct = start
? (j['start'] ?? j['start_datetime'] ?? j['date_start'])
: (j['end'] ?? j['end_datetime'] ?? j['date_end']);
final parsedDirect = _parseFlexibleDateTime(direct, allDay: allDay);
if (parsedDirect != null) return parsedDirect;
final tsValue = start
? (j['start_ts'] ?? meta['_mw_event_start_ts'])
: (j['end_ts'] ?? meta['_mw_event_end_ts']);
final ts = int.tryParse(tsValue?.toString() ?? '');
if (ts != null && ts > 0) {
final millis = ts > 100000000000 ? ts : ts * 1000;
return DateTime.fromMillisecondsSinceEpoch(millis).toLocal();
}
final date = start
? (j['start_date'] ?? j['date'] ?? meta['_mw_event_start_date'])
: (j['end_date'] ?? meta['_mw_event_end_date']);
final time = start
? (j['start_time'] ?? j['time'] ?? meta['_mw_event_start_time'])
: (j['end_time'] ?? meta['_mw_event_end_time']);
final dateText = date?.toString().trim() ?? '';
if (dateText.isEmpty) return null;
// WordPress stores HTML date values as YYYY-MM-DD. The admin browser
// may display DD.MM.YYYY; accept that representation as well so imported
// or legacy event metadata never vanishes silently.
final germanDate =
RegExp(r'^(\d{1,2})\.(\d{1,2})\.(\d{4})$').firstMatch(dateText);
if (germanDate != null) {
final d = int.parse(germanDate.group(1)!);
final m = int.parse(germanDate.group(2)!);
final y = int.parse(germanDate.group(3)!);
if (allDay) {
return start ? DateTime(y, m, d) : DateTime(y, m, d, 23, 59, 59);
}
final timeParts = (time?.toString().trim().isNotEmpty == true
? time.toString()
: '00:00')
.split(':');
return DateTime(y, m, d, int.tryParse(timeParts[0]) ?? 0,
timeParts.length > 1 ? int.tryParse(timeParts[1]) ?? 0 : 0);
}
// HTML date inputs save YYYY-MM-DD. Keep all-day values as local calendar
// dates so no timezone conversion can move them to the previous day.
final dateParts = dateText.split('-');
if (dateParts.length == 3) {
final y = int.tryParse(dateParts[0]);
final m = int.tryParse(dateParts[1]);
final d = int.tryParse(dateParts[2]);
if (y != null && m != null && d != null) {
if (allDay) {
return start ? DateTime(y, m, d) : DateTime(y, m, d, 23, 59, 59);
}
final timeParts = (time?.toString().trim().isNotEmpty == true
? time.toString()
: '00:00')
.split(':');
final hour = timeParts.isNotEmpty ? int.tryParse(timeParts[0]) ?? 0 : 0;
final minute =
timeParts.length > 1 ? int.tryParse(timeParts[1]) ?? 0 : 0;
return DateTime(y, m, d, hour, minute);
}
}
return _parseFlexibleDateTime(dateText, allDay: allDay);
}
DateTime? _parseFlexibleDateTime(Object? value, {required bool allDay}) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty || text == 'null') return null;
// Date-only values are deliberately constructed as local calendar dates.
final dateOnly = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(text);
if (dateOnly != null) {
return DateTime(
int.parse(dateOnly.group(1)!),
int.parse(dateOnly.group(2)!),
int.parse(dateOnly.group(3)!),
);
}
final parsed = DateTime.tryParse(text);
if (parsed == null) return null;
if (allDay) {
final local = parsed.toLocal();
return DateTime(local.year, local.month, local.day);
}
return parsed.toLocal();
}
}
class EventActionUrls {
const EventActionUrls(
{this.websiteUrl, this.organizerUrl, this.ticketUrl, this.onlineUrl});
final String? websiteUrl;
final String? organizerUrl;
final String? ticketUrl;
final String? onlineUrl;
}

