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/map/map_page.dartDart
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.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/partner.dart';
import '../../services/analytics_service.dart';
import '../../widgets/sponsored_banner.dart';
import '../../widgets/user_location_marker.dart';
import '../poi/poi_detail_page.dart';
import '../partners/partner_detail_page.dart';
class MapPage extends StatefulWidget {
const MapPage({
super.key,
this.initialCategory = 'all',
});
final String initialCategory;
@override
State<MapPage> createState() => _MapPageState();
}
class _MapPageState extends State<MapPage> {
final MapController _mapController = MapController();
final TextEditingController _searchController = TextEditingController();
Poi? _selected;
Partner? _selectedPartner;
bool _showNearby = false;
late String _filter;
String _query = '';
bool _locating = false;
static const _weimarCenter = LatLng(50.9796, 11.3290);
@override
void initState() {
super.initState();
_filter = _normalizeFilter(widget.initialCategory);
AnalyticsService.instance.screenView('map');
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
String _normalizeFilter(String raw) {
final value = raw.trim().toLowerCase();
if (value == 'all') return 'all';
if (value == 'klassik' || value == 'classic') return 'classic';
if (value == 'museen' || value == 'museum' || value == 'museums') {
return 'museums_art';
}
if (AppTaxonomy.byId(value) != null) return value;
return 'all';
}
List<Poi> _visiblePois(BuildContext context) {
final lang = Localizations.localeOf(context).languageCode;
final q = _query.trim().toLowerCase();
return AppDataScope.of(context).pois.where((poi) {
final title = poi.title.get(lang).toLowerCase();
final shortText = poi.shortDescription.get(lang).toLowerCase();
final topic = _filter == 'all' ? null : AppTaxonomy.byId(_filter);
final categoryOk = topic == null || AppTaxonomy.matchesPoi(topic, poi);
final searchOk = q.isEmpty ||
title.contains(q) ||
shortText.contains(q) ||
(poi.address ?? '').toLowerCase().contains(q);
return categoryOk && searchOk;
}).toList();
}
List<Partner> _visiblePartners(BuildContext context) {
final q = _query.trim().toLowerCase();
return AppDataScope.of(context).partners.where((partner) {
return q.isEmpty ||
partner.title.toLowerCase().contains(q) ||
(partner.shortDescription ?? '').toLowerCase().contains(q) ||
(partner.address ?? '').toLowerCase().contains(q) ||
(partner.partnerType ?? '').toLowerCase().contains(q);
}).toList();
}
Future<void> _goToMyLocation() async {
setState(() => _locating = true);
final location = LocationScope.read(context);
await location.refresh();
if (!mounted) return;
setState(() => _locating = false);
final position = location.position;
if (position == null) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.t('locationFailed'))),
);
return;
}
_mapController.move(position, 16.5);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final lang = Localizations.localeOf(context).languageCode;
final categoryForAd = switch (_filter) {
'classic' => 'Klassik',
'bauhaus' => 'Bauhaus',
'museums_art' => 'Museen',
_ => null,
};
final store = AppDataScope.of(context);
final mapAd =
store.campaignFor(AdPlacement.mapBanner, category: categoryForAd);
final genericAd = store.campaignFor(AdPlacement.appBanner);
final ad =
mapAd ?? (genericAd?.supportsScreen('map') == true ? genericAd : null);
final filters = <({String id, String label})>[
(id: 'all', label: l10n.t('all')),
for (final topic in AppTaxonomy.topics.take(9))
(id: topic.id, label: topic.label(l10n)),
];
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.t('map'),
style: const TextStyle(fontWeight: FontWeight.w800),
),
Text(
l10n.t('discoverWeimar'),
style: const TextStyle(
fontSize: 12,
color: AppColors.mutedInk,
),
),
],
),
),
body: Stack(
children: [
FlutterMap(
mapController: _mapController,
options: const MapOptions(
initialCenter: _weimarCenter,
initialZoom: 15.2,
minZoom: 12,
maxZoom: 19,
),
children: [
TileLayer(
urlTemplate:
'https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',
userAgentPackageName: 'de.meinweimar.cityguide',
),
MarkerLayer(
markers: [
if (!_showNearby)
..._visiblePois(context).map(
(poi) => Marker(
point: LatLng(poi.latitude, poi.longitude),
width: 52,
height: 52,
child: GestureDetector(
onTap: () {
AnalyticsService.instance.track('map_marker_open',
properties: {'poi_id': poi.id});
setState(() {
_selected = poi;
_selectedPartner = null;
});
},
child: Container(
decoration: BoxDecoration(
color: _selected?.id == poi.id
? AppColors.ink
: AppColors.heartRed,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
),
child: const Icon(
Icons.location_on_outlined,
color: Colors.white,
),
),
),
),
),
if (_showNearby)
..._visiblePartners(context).map(
(partner) => Marker(
point: LatLng(partner.latitude, partner.longitude),
width: 52,
height: 52,
child: GestureDetector(
onTap: () {
AnalyticsService.instance.track('map_partner_open',
properties: {'partner_id': partner.id});
setState(() {
_selectedPartner = partner;
_selected = null;
});
},
child: Container(
decoration: BoxDecoration(
color: _selectedPartner?.id == partner.id
? AppColors.ink
: const Color(0xFF5B4770),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
),
child: Icon(
partner.sponsored
? Icons.storefront_rounded
: Icons.local_cafe_outlined,
color: Colors.white),
),
),
),
),
if (LocationScope.of(context).position case final user?)
Marker(
point: user,
width: 64,
height: 64,
child: const UserLocationMarker(size: 62),
),
],
),
RichAttributionWidget(
attributions: const [
TextSourceAttribution(
'© OpenStreetMap contributors · © CARTO',
),
],
),
],
),
Positioned(
left: 12,
right: 12,
top: 10,
child: Column(
children: [
Material(
color: Colors.white,
elevation: 2,
borderRadius: BorderRadius.circular(18),
child: TextField(
controller: _searchController,
onChanged: (value) => setState(() => _query = value),
decoration: InputDecoration(
prefixIcon: const Icon(
Icons.search_rounded,
color: AppColors.heartRed,
),
hintText: l10n.t('searchPlace'),
suffixIcon: _query.isEmpty
? null
: IconButton(
onPressed: () {
_searchController.clear();
setState(() => _query = '');
},
icon: const Icon(Icons.close),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
const SizedBox(height: 9),
SizedBox(
height: 38,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
Padding(
padding: const EdgeInsets.only(right: 7),
child: ChoiceChip(
label: Text(l10n.t('places')),
selected: !_showNearby,
onSelected: (_) => setState(() {
_showNearby = false;
_selectedPartner = null;
}),
),
),
Padding(
padding: const EdgeInsets.only(right: 7),
child: ChoiceChip(
label: const Text('Nearby'),
selected: _showNearby,
onSelected: (_) => setState(() {
_showNearby = true;
_selected = null;
}),
),
),
if (!_showNearby)
...filters.map(
(filter) => Padding(
padding: const EdgeInsets.only(right: 7),
child: ChoiceChip(
label: Text(filter.label),
selected: filter.id == _filter,
onSelected: (_) => setState(() {
_filter = filter.id;
_selected = null;
}),
),
),
),
],
),
),
],
),
),
if (ad != null && _selected == null && _selectedPartner == null)
Positioned(
left: 12,
right: 76,
bottom: 12,
child: SponsoredBanner(
campaign: ad,
compact: true,
placement: 'map_banner',
),
),
Positioned(
right: 14,
bottom: _selected == null && _selectedPartner == null ? 84 : 150,
child: Column(
children: [
FloatingActionButton.small(
heroTag: 'weimar-center',
backgroundColor: Colors.white,
foregroundColor: AppColors.ink,
onPressed: () => _mapController.move(_weimarCenter, 15.2),
child: const Icon(Icons.location_city_rounded),
),
const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'my-location',
backgroundColor: AppColors.heartRed,
foregroundColor: Colors.white,
onPressed: _locating ? null : _goToMyLocation,
child: _locating
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.my_location_rounded),
),
],
),
),
if (_selectedPartner != null)
Positioned(
left: 12,
right: 12,
bottom: 12,
child: Material(
color: Colors.white,
elevation: 8,
borderRadius: BorderRadius.circular(22),
child: ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
leading: CircleAvatar(
backgroundColor: const Color(0xFFF0EAF6),
child: Icon(
_selectedPartner!.sponsored
? Icons.storefront_rounded
: Icons.local_cafe_outlined,
color: const Color(0xFF5B4770)),
),
title: Text(_selectedPartner!.title,
style: const TextStyle(fontWeight: FontWeight.w900)),
subtitle: Text([
if (_selectedPartner!.partnerType?.trim().isNotEmpty ==
true)
_selectedPartner!.partnerType!,
if (_selectedPartner!.sponsored) 'Anzeige',
].join(' · ')),
trailing: const Icon(Icons.arrow_forward_rounded),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) =>
PartnerDetailPage(partner: _selectedPartner!))),
),
),
),
if (_selected != null)
Positioned(
left: 12,
right: 12,
bottom: 12,
child: Material(
color: Colors.white,
elevation: 8,
borderRadius: BorderRadius.circular(22),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppColors.softRed,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
Icons.location_city_rounded,
color: AppColors.heartRed,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_selected!.title.get(lang),
style:
const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 3),
Text(
_selected!.shortDescription.get(lang),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.mutedInk,
fontSize: 13,
),
),
],
),
),
IconButton(
tooltip: l10n.t('details'),
onPressed: () {
AnalyticsService.instance.track('map_poi_detail_open',
properties: {'poi_id': _selected!.id});
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PoiDetailPage(poi: _selected!),
),
);
},
icon: const Icon(Icons.arrow_forward_rounded),
),
],
),
),
),
),
],
),
);
}
}

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%