glanzlicht / Meinweimar_Tourguide

Intelligenter Tourguide für Weimar

Verified secure showcase
167 Files 833 KB Public size 12 Redacted Dart · Markdown · YAML · HTML · JSON · GITIGNORE

Project overview

Meinweimar_Tourguide is a verified code showcase with 167 visible project entries across 5 top-level modules. The primary detected language is Dart; protected files remain visible in the structure while their contents stay private.

README

MeinWeimar Tourguide

MeinWeimar Tourguide

Eine produktionsnahe Flutter-App fuer Stadtentdeckung, Tourplanung und Sprachlernen in Weimar. Die App verbindet eine responsive mobile und webfaehige Oberflaeche mit einer WordPress-REST-API und macht aus redaktionellen Inhalten konkrete Wege, Orte und Empfehlungen.

Dieses Repository zeigt neben der UI auch die technische Produktarbeit dahinter: API-Integration, Offline-tolerante Datenhaltung, Lokalisierung, Routing, Audio/TTS, Analytics, Deep Links und automatisierte Qualitaetssicherung.

App-Eindruecke

Start · Entdecken · Touren · Karte · Mehr

Produktumfang

  • Entdecken: POIs, Touren, Veranstaltungen, Partner und Themen mit Suche,
  • Filtern und fehlertoleranter Trefferlogik.

  • Unterwegs: Kartenansicht, Standort, Fussgaenger-Routing, Tourfortschritt,
  • Favoriten und eine persoenliche Route aus der eigenen Weimar-Liste.

  • Mehrsprachigkeit: UI und CMS-Inhalte in Deutsch, Englisch,
  • Niederlaendisch, Franzoesisch, Polnisch, Italienisch, Spanisch und Tschechisch.

  • Audio und Lernen: Guide-Audio, Flutter TTS sowie SprachGuide-Kurse und
  • Lern-Touren.

  • Redaktion und Betrieb: WordPress REST API, Oeffnungszeiten mit
  • Zeitzonenlogik, Kampagnen, Partnerplatzierungen, Deep Links und First-Party- Analytics.

  • Responsive UX: kleine Displays, grosse Systemschrift, dynamische
  • Kartenhoehen und versionierte Web-Releases ohne veraltete Service-Worker- Assets.

Technischer Steckbrief

BereichUmsetzung
FrameworkFlutter / Dart 3.4+
PlattformeniOS, Android und Web
DatenWordPress REST API, typisierte Models und Repositories
Kartenflutter_map, latlong2, Standort und Fussgaenger-Routing
Audiojust_audio, flutter_tts
Lokaler ZustandShared Preferences, begrenztes Caching und Analytics-Puffer
QualitaetFlutter Analyzer, Widget- und Service-Tests, Lokalisierungs- und API-Modelltests

Die Codebasis ist nach Verantwortlichkeiten gegliedert:

lib/
	core/          gemeinsame API-, Lokalisierungs- und Navigationslogik
	data/          Datenzugriff und DTO-nahe Strukturen
	features/      produktbezogene Screens und Flows
	models/        fachliche Modelle
	repositories/  Datenzugriff hinter stabilen Schnittstellen
	services/      Routing, Audio, Analytics und Plattformdienste
	widgets/       wiederverwendbare UI-Bausteine
test/            fokussierte Tests fuer Services, Modelle und Widgets

Lokal starten

Voraussetzungen: aktuelles Flutter SDK und das darin enthaltene Dart SDK.

flutter pub get
flutter analyze
flutter test
flutter run -d chrome

Die Anwendung verwendet standardmaessig die oeffentliche API: https://tour.meinweimar.de/wp-json/meinweimar/v1

Web-Release

Fuer einen Web-Release das versionierte Build-Skript verwenden:

./tool/build_web_release.sh

Das Skript legt den Runtime-Build unter build/web/releases/<release-id>/ ab. Launcher und Versionsendpunkt bleiben bewusst nicht cachebar, damit Browser nach einem Reload die aktuelle Version auflosen. Startup-Diagnostik ist nur mit ?mwdebug=1 aktiv.

Dokumentation

  • [Deep Links](DEEP_LINKS.md) - Web-Routen fuer Bereiche, Touren und POIs
  • [Projektstatus](PROJECT_STATE.md) - aktueller Funktions- und Integrationsstand
  • [Changelog](CHANGELOG.md) - nachvollziehbare Produkt- und UX-Iterationen
  • [API-Integration](docs/API_INTEGRATION.md) - technische Integrationsnotizen
lib/repositories/event_repository.dartDart
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;
}

Architecture

High-level modules detected from the verified public snapshot. Protected implementation details are intentionally omitted.

libmodule
testmodule
docsmodule
webmodule
toolmodule

Technology stack

DartMarkdownLOCKHTMLShellHTACCESSFlutter / Dart
Dart89.9%
Markdown3.9%
LOCK3.1%
HTML1.7%
Shell0.8%
HTACCESS0.2%
YAML0.1%
METADATA0.1%