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/features/planner/route_planner_page.dartDart
import 'package:flutter/material.dart';
import '../../core/localization/app_localizations.dart';
import '../../core/theme/app_colors.dart';
import '../../core/taxonomy/app_taxonomy.dart';
import '../../data/app_data_scope.dart';
import '../../data/location_scope.dart';
import '../../models/ad_campaign.dart';
import '../../models/poi.dart';
import '../../models/tour.dart';
import '../../models/transport_mode.dart';
import '../../services/tour_planner_service.dart';
import '../../services/analytics_service.dart';
import '../../widgets/sponsored_banner.dart';
import '../../widgets/tour_map.dart';
import '../tours/tour_detail_page.dart';
class RoutePlannerPage extends StatefulWidget {
const RoutePlannerPage({
super.key,
this.initialMinutes = 120,
this.initialInterests = const {'highlights'},
this.initialFamily = false,
this.initialRequiredPoiIds = const <String>{},
});
final int initialMinutes;
final Set<String> initialInterests;
final bool initialFamily;
final Set<String> initialRequiredPoiIds;
@override
State<RoutePlannerPage> createState() => _RoutePlannerPageState();
}
class _RoutePlannerPageState extends State<RoutePlannerPage> {
final TourPlannerService _planner = const TourPlannerService();
final GlobalKey _resultKey = GlobalKey();
late double _minutes;
late Set<String> _interests;
late bool _family;
late final Set<String> _requiredPoiIds;
TransportMode _transportMode = TransportMode.walking;
Tour? _result;
bool _building = false;
String? _error;
@override
void initState() {
super.initState();
_minutes = widget.initialMinutes.clamp(30, 480).toDouble();
_interests = {...widget.initialInterests};
_family = widget.initialFamily;
_requiredPoiIds = {...widget.initialRequiredPoiIds};
AnalyticsService.instance.screenView('route_planner');
}
Future<void> _buildRoute() async {
final store = AppDataScope.read(context);
final position = LocationScope.read(context).position;
final lang = Localizations.localeOf(context).languageCode;
setState(() {
_building = true;
_error = null;
_result = null;
});
await AnalyticsService.instance.track('planner_build_start', properties: {
'transport': _transportMode.apiValue,
'duration_minutes': _minutes.round()
});
try {
final result = await _planner.buildTourWithRouting(
availableMinutes: _minutes.round(),
interests: _interests,
familyFriendly: _family,
pois: store.pois,
transportMode: _transportMode,
requiredPoiIds: _requiredPoiIds,
startPosition: position,
languageCode: lang,
);
if (!mounted) return;
setState(() => _result = result);
WidgetsBinding.instance.addPostFrameCallback((_) {
final target = _resultKey.currentContext;
if (target != null) {
Scrollable.ensureVisible(
target,
duration: const Duration(milliseconds: 450),
curve: Curves.easeOutCubic,
alignment: .08,
);
}
});
await AnalyticsService.instance
.track('planner_build_success', properties: {
'tour_id': result.id,
'transport': _transportMode.apiValue,
'stop_count': result.stops.length
});
} catch (_) {
await AnalyticsService.instance.track('planner_build_error',
properties: {'transport': _transportMode.apiValue, 'success': false});
if (!mounted) return;
setState(
() => _error = AppLocalizations.of(context).t('plannerError'),
);
} finally {
if (mounted) setState(() => _building = false);
}
}
Future<void> _chooseRequiredPois(List<Poi> pois, String lang) async {
final selected = {..._requiredPoiIds};
final l10n = AppLocalizations.of(context);
final result = await showModalBottomSheet<Set<String>>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) {
var query = '';
return StatefulBuilder(
builder: (context, setModalState) {
final q = query.trim().toLowerCase();
final filtered = pois
.where((poi) {
if (q.isEmpty) return true;
return poi.title.get(lang).toLowerCase().contains(q) ||
poi.category.toLowerCase().contains(q) ||
poi.topicNames.any((e) => e.toLowerCase().contains(q)) ||
poi.eraNames.any((e) => e.toLowerCase().contains(q));
})
.take(80)
.toList();
return FractionallySizedBox(
heightFactor: .88,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 10),
child: Row(
children: [
Expanded(
child: Text(
l10n.t('mustInclude'),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w900,
),
),
),
TextButton(
onPressed: () => Navigator.of(context).pop(selected),
child: Text(l10n.t('done')),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: TextField(
autofocus: true,
onChanged: (value) => setModalState(() => query = value),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: l10n.t('searchPoi'),
border: const OutlineInputBorder(),
),
),
),
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, index) {
final poi = filtered[index];
final checked = selected.contains(poi.id);
return CheckboxListTile(
value: checked,
title: Text(poi.title.get(lang)),
subtitle: Text(
[
poi.category,
if (poi.topicNames.isNotEmpty)
poi.topicNames.take(2).join(' · '),
].where((e) => e.trim().isNotEmpty).join(' · '),
),
onChanged: (value) => setModalState(() {
if (value == true) {
selected.add(poi.id);
} else {
selected.remove(poi.id);
}
}),
);
},
),
),
],
),
);
},
);
},
);
if (result != null && mounted) {
setState(() {
_requiredPoiIds
..clear()
..addAll(result);
_result = null;
});
}
}
@override
Widget build(BuildContext context) {
final store = AppDataScope.of(context);
final l10n = AppLocalizations.of(context);
final lang = Localizations.localeOf(context).languageCode;
final plannerAd = store.campaignFor(AdPlacement.plannerBanner);
final genericAd = store.campaignFor(AdPlacement.appBanner);
final ad = plannerAd ??
(genericAd?.supportsScreen('planner') == true ? genericAd : null);
final location = LocationScope.of(context);
final interestTopics = AppTaxonomy.topics.where((topic) {
if (topic.highlightOnly) return true;
return store.pois.any((poi) => AppTaxonomy.matchesPoi(topic, poi)) ||
store.tours.any((tour) => AppTaxonomy.matchesTour(topic, tour));
}).toList();
final requiredPois =
store.pois.where((poi) => _requiredPoiIds.contains(poi.id)).toList();
return Scaffold(
appBar: AppBar(
title: Text(
l10n.t('individualTour'),
style: TextStyle(fontWeight: FontWeight.w800),
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 30),
children: [
Text(
l10n.t('plannerHeadline'),
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 6),
Text(
l10n.t('plannerIntro'),
style: TextStyle(color: AppColors.mutedInk),
),
const SizedBox(height: 24),
_StepTitle(number: 1, title: l10n.t('howMuchTime')),
const SizedBox(height: 8),
Text(
_timeLabel(_minutes.round(), l10n),
style: const TextStyle(
color: AppColors.heartRed,
fontWeight: FontWeight.w900,
fontSize: 28,
),
),
Slider(
min: 30,
max: 480,
divisions: 15,
value: _minutes,
label: _timeLabel(_minutes.round(), l10n),
onChanged: (value) => setState(() {
_minutes = value;
_result = null;
}),
),
const SizedBox(height: 18),
_StepTitle(number: 2, title: l10n.t('whatInterests')),
const SizedBox(height: 5),
Text(
l10n.t('multipleTopics'),
style: TextStyle(color: AppColors.mutedInk, fontSize: 12),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: interestTopics
.map(
(topic) => FilterChip(
label: Text(topic.label(l10n)),
selected: _interests.contains(topic.id),
onSelected: (selected) => setState(() {
selected
? _interests.add(topic.id)
: _interests.remove(topic.id);
_result = null;
}),
),
)
.toList(),
),
const SizedBox(height: 22),
_StepTitle(number: 3, title: l10n.t('howTravel')),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: TransportMode.values.map((mode) {
final icon = switch (mode) {
TransportMode.walking => Icons.directions_walk_rounded,
TransportMode.bicycle => Icons.directions_bike_rounded,
TransportMode.carMotorcycle => Icons.directions_car_rounded,
TransportMode.publicTransit => Icons.directions_bus_rounded,
};
return ChoiceChip(
avatar: Icon(icon, size: 18),
label: Text(l10n.transport(mode.apiValue)),
selected: _transportMode == mode,
onSelected: (_) => setState(() {
_transportMode = mode;
_result = null;
}),
);
}).toList(),
),
if (_transportMode == TransportMode.publicTransit) ...[
const SizedBox(height: 8),
Text(
l10n.t('transitPlannerNote'),
style: TextStyle(color: AppColors.mutedInk, fontSize: 12),
),
],
const SizedBox(height: 22),
_StepTitle(number: 4, title: l10n.t('whereStart')),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.line),
),
child: Row(
children: [
Icon(
location.position != null
? Icons.my_location_rounded
: Icons.location_off_outlined,
color: location.position != null
? AppColors.heartRed
: AppColors.mutedInk,
),
const SizedBox(width: 10),
Expanded(
child: Text(
location.position != null
? l10n.t('currentLocationStart')
: l10n.t('noLocationStart'),
),
),
if (location.position == null)
TextButton(
onPressed: location.refresh,
child: Text(l10n.t('locate')),
),
],
),
),
const SizedBox(height: 22),
_StepTitle(number: 5, title: l10n.t('mustIncludeOptional')),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => _chooseRequiredPois(store.pois, lang),
icon: const Icon(Icons.add_location_alt_outlined),
label: Text(
_requiredPoiIds.isEmpty
? l10n.t('selectPoi')
: '${_requiredPoiIds.length} ${l10n.t(_requiredPoiIds.length == 1 ? 'poiSelected' : 'poisSelected')}',
),
),
if (requiredPois.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 7,
runSpacing: 7,
children: requiredPois
.map(
(poi) => InputChip(
label: Text(poi.title.get(lang)),
onDeleted: () => setState(() {
_requiredPoiIds.remove(poi.id);
_result = null;
}),
),
)
.toList(),
),
],
const SizedBox(height: 6),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(l10n.t('familyFriendly')),
subtitle: Text(l10n.t('preferFamilyPois')),
value: _family,
onChanged: (value) => setState(() {
_family = value;
_result = null;
}),
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _building ? null : _buildRoute,
icon: _building
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_awesome),
label: Text(
_building
? l10n.t('planningTour')
: l10n.t('createIndividualTour'),
),
),
if (_error != null) ...[
const SizedBox(height: 10),
Text(
_error!,
style: const TextStyle(color: AppColors.heartRed),
),
],
if (ad != null) ...[
const SizedBox(height: 18),
SponsoredBanner(
campaign: ad,
compact: true,
placement: 'planner_banner',
),
],
if (_result != null) ...[
SizedBox(key: _resultKey, height: 28),
const Divider(),
const SizedBox(height: 20),
Text(
l10n.t('yourIndividualTour'),
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 8),
Text(
'${_result!.stops.length} ${l10n.t(_result!.stops.length == 1 ? 'stopSingular' : 'stopPlural')} · '
'${_result!.distanceKm.toStringAsFixed(1)} km · '
'${l10n.transport(_result!.recommendedTransport.apiValue)} · '
'${l10n.t('timeBudget')} ${_timeLabel(_result!.durationMinutes, l10n)}',
),
const SizedBox(height: 6),
Text(
l10n.tf('plannerTimeBreakdown', {
'travel': (_result!.distanceKm *
1000 /
_result!.recommendedTransport.metersPerMinute)
.round(),
'visit': (_result!.stops.length * 18),
}),
style: const TextStyle(
color: AppColors.mutedInk, fontWeight: FontWeight.w700),
),
if (_result!.stops.length < 3) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.softRed,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.t('plannerThinResultTitle'),
style: const TextStyle(fontWeight: FontWeight.w900)),
const SizedBox(height: 5),
Text(l10n.t('plannerThinResultText')),
if (_minutes < 90) ...[
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => setState(() {
_minutes = 90;
_result = null;
}),
icon: const Icon(Icons.schedule_rounded),
label: Text(l10n.t('plannerIncreaseTime')),
),
],
],
),
),
],
const SizedBox(height: 14),
TourMap(tour: _result!, height: 300),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TourDetailPage(tour: _result!),
),
),
icon: const Icon(Icons.route_rounded),
label: Text(l10n.t('viewStartTour')),
),
],
],
),
);
}
String _timeLabel(int minutes, AppLocalizations l10n) {
if (minutes < 60) return '$minutes ${l10n.t('minutes')}';
final hours = minutes ~/ 60;
final rest = minutes % 60;
return rest == 0
? '$hours ${l10n.t('hoursShort')}'
: '$hours ${l10n.t('hoursShort')} $rest ${l10n.t('minutesShort')}';
}
}
class _StepTitle extends StatelessWidget {
const _StepTitle({required this.number, required this.title});
final int number;
final String title;
@override
Widget build(BuildContext context) {
return Row(
children: [
CircleAvatar(
radius: 14,
backgroundColor: AppColors.heartRed,
foregroundColor: Colors.white,
child: Text(
'$number',
style: const TextStyle(
fontWeight: FontWeight.w900,
fontSize: 12,
),
),
),
const SizedBox(width: 9),
Expanded(
child: Text(
title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w900),
),
),
],
);
}
}

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%