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/services/opening_hours_service.dartDart
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;
}

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%