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/categories/categories_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/event.dart';
import '../../models/partner.dart';
import '../../models/poi.dart';
import '../../models/tour.dart';
import '../../services/analytics_service.dart';
import '../../widgets/remote_image.dart';
import '../../widgets/sponsored_banner.dart';
import '../../widgets/tour_featured_header.dart';
import '../events/event_detail_page.dart';
import '../events/events_page.dart';
import '../partners/partner_detail_page.dart';
import '../poi/poi_detail_page.dart';
import '../tours/tour_detail_page.dart';
enum _DiscoverType { all, places, tours, events, partners, gastro, topics }
enum _DiscoverSort { relevance, az, distance }
enum _TopicType { all, places, tours, nearby }
enum _TopicSort { recommended, az, distance }
class CategoriesPage extends StatefulWidget {
const CategoriesPage({super.key});
@override
State<CategoriesPage> createState() => _CategoriesPageState();
}
class _CategoriesPageState extends State<CategoriesPage> {
final TextEditingController _searchController = TextEditingController();
String _query = '';
_DiscoverType _type = _DiscoverType.all;
_DiscoverSort _sort = _DiscoverSort.relevance;
@override
void initState() {
super.initState();
AnalyticsService.instance.screenView('discover');
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final lang = Localizations.localeOf(context).languageCode;
final l10n = AppLocalizations.of(context);
final store = AppDataScope.of(context);
final location = LocationScope.of(context);
final appAd = store.campaignFor(AdPlacement.appBanner);
final discoverAd = appAd?.supportsScreen('discover') == true ? appAd : null;
final q = _norm(_query);
final topics = AppTaxonomy.topics.where((topic) {
final pois =
store.pois.where((poi) => AppTaxonomy.matchesPoi(topic, poi));
final tours =
store.tours.where((tour) => AppTaxonomy.matchesTour(topic, tour));
return pois.isNotEmpty || tours.isNotEmpty;
}).toList();
if (q.isEmpty) {
return Scaffold(
appBar: AppBar(
title: Text(l10n.t('discover'),
style: const TextStyle(fontWeight: FontWeight.w900))),
body: LayoutBuilder(
builder: (context, constraints) {
final columns = _columnCount(constraints.maxWidth);
return CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 10),
sliver: SliverToBoxAdapter(child: _searchField(l10n)),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
sliver: SliverToBoxAdapter(
child: SizedBox(
height: 42,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_browseChip(
_DiscoverType.all, l10n.t('all'), store, lang),
_browseChip(_DiscoverType.places, l10n.t('places'),
store, lang),
_browseChip(_DiscoverType.tours, l10n.t('tours'),
store, lang),
_browseChip(_DiscoverType.events, l10n.t('events'),
store, lang),
_browseChip(_DiscoverType.partners,
l10n.t('partners'), store, lang),
_browseChip(_DiscoverType.gastro, l10n.t('food'),
store, lang),
_browseChip(_DiscoverType.topics, l10n.t('topics'),
store, lang),
],
),
),
),
),
if (discoverAd != null)
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
sliver: SliverToBoxAdapter(
child: SponsoredBanner(
campaign: discoverAd,
compact: true,
placement: 'app_banner_discover')),
),
if (store.events.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 14),
sliver: SliverToBoxAdapter(
child: Material(
color: AppColors.softRed,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const EventsPage())),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
const CircleAvatar(
backgroundColor: AppColors.heartRed,
foregroundColor: Colors.white,
child: Icon(Icons.event_rounded)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(l10n.t('eventsInWeimar'),
style: const TextStyle(
fontWeight: FontWeight.w900,
fontSize: 17)),
const SizedBox(height: 2),
Text(
l10n.tf('upcomingEventsCount',
{'n': store.events.length}),
style: const TextStyle(
color: AppColors.mutedInk)),
])),
const Icon(Icons.chevron_right_rounded),
]),
),
),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
sliver: SliverToBoxAdapter(
child: Text('${topics.length} ${l10n.t('discoverCount')}',
style: const TextStyle(
fontWeight: FontWeight.w800,
color: AppColors.mutedInk)),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 28),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
childAspectRatio: columns == 2 ? .94 : 1.04,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final topic = topics[index];
final pois = store.pois
.where((poi) => AppTaxonomy.matchesPoi(topic, poi))
.toList();
final tours = store.tours
.where(
(tour) => AppTaxonomy.matchesTour(topic, tour))
.toList();
return _TopicTile(
name: topic.label(l10n),
subtitle:
'${tours.length} ${l10n.t(tours.length == 1 ? 'tourSingular' : 'tourPlural')} · ${pois.length} ${l10n.t('places')}',
icon: _topicIcon(topic.id),
compact: columns >= 2,
onTap: () =>
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => CategoryDetailPage(
categorySlug: topic.id,
categoryName: topic.label(l10n),
pois: pois,
tours: tours,
partners:
_partnersForTopic(topic, store.partners),
languageCode: lang,
),
)),
);
},
childCount: topics.length,
),
),
),
],
);
},
),
);
}
final results = <_SearchResult>[];
if (_type == _DiscoverType.all || _type == _DiscoverType.topics) {
for (final topic in topics) {
final score = _textScore(q, [topic.label(l10n), ...topic.aliases]);
if (score > 0) {
results.add(_SearchResult.topic(
topic: topic, title: topic.label(l10n), score: score));
}
}
}
if (_type == _DiscoverType.all || _type == _DiscoverType.places) {
for (final poi in store.pois) {
final score = _textScore(q, [
poi.title.get(lang),
poi.shortDescription.get(lang),
poi.category,
...poi.topicNames,
...poi.eraNames
]);
if (score > 0) {
results.add(_SearchResult.poi(
poi: poi,
title: poi.title.get(lang),
score: score,
distanceMeters:
location.distanceMetersTo(poi.latitude, poi.longitude)));
}
}
}
if (_type == _DiscoverType.all || _type == _DiscoverType.tours) {
for (final tour in store.tours) {
final score = _textScore(q, [
tour.title.get(lang),
tour.subtitle.get(lang),
tour.category,
...tour.topicNames,
...tour.eraNames
]);
if (score > 0) {
results.add(_SearchResult.tour(
tour: tour, title: tour.title.get(lang), score: score));
}
}
}
if (_type == _DiscoverType.all || _type == _DiscoverType.events) {
for (final event in store.events) {
final score = _textScore(q, [
event.title.get(lang),
event.shortDescription.get(lang),
event.venueName ?? '',
...event.categories.map((c) => c.name)
]);
if (score > 0) {
results.add(_SearchResult.event(
event: event, title: event.title.get(lang), score: score));
}
}
}
if (_type == _DiscoverType.all ||
_type == _DiscoverType.partners ||
_type == _DiscoverType.gastro) {
for (final partner in store.partners) {
if (_type == _DiscoverType.gastro && !_isGastroPartner(partner)) {
continue;
}
final score = _textScore(q, [
partner.title,
partner.shortDescription ?? '',
partner.partnerType ?? '',
partner.cuisine ?? '',
partner.address ?? ''
]);
if (score > 0) {
results.add(_SearchResult.partner(
partner: partner,
title: partner.title,
score: score,
distanceMeters: location.distanceMetersTo(
partner.latitude, partner.longitude)));
}
}
}
_sortSearchResults(results);
return Scaffold(
appBar: AppBar(
title: Text(l10n.t('discover'),
style: const TextStyle(fontWeight: FontWeight.w900))),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
children: [
_searchField(l10n),
if (discoverAd != null) ...[
const SizedBox(height: 12),
SponsoredBanner(
campaign: discoverAd,
compact: true,
placement: 'app_banner_discover'),
],
const SizedBox(height: 12),
SizedBox(
height: 42,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_typeChip(_DiscoverType.all, l10n.t('all')),
_typeChip(_DiscoverType.places, l10n.t('places')),
_typeChip(_DiscoverType.tours, l10n.t('tours')),
_typeChip(_DiscoverType.events, l10n.t('events')),
_typeChip(_DiscoverType.partners, l10n.t('partners')),
_typeChip(_DiscoverType.gastro, l10n.t('food')),
_typeChip(_DiscoverType.topics, l10n.t('topics')),
],
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
'${results.length} ${l10n.t('resultsFor')} „${_query.trim()}“',
style: const TextStyle(
fontWeight: FontWeight.w800,
color: AppColors.mutedInk))),
PopupMenuButton<_DiscoverSort>(
tooltip: l10n.t('sort'),
initialValue: _sort,
onSelected: (v) => setState(() => _sort = v),
itemBuilder: (_) => [
PopupMenuItem(
value: _DiscoverSort.relevance,
child: Text(l10n.t('relevance'))),
PopupMenuItem(
value: _DiscoverSort.az, child: const Text('A–Z')),
PopupMenuItem(
value: _DiscoverSort.distance,
child: Text(l10n.t('distance'))),
],
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.sort_rounded)),
),
],
),
const SizedBox(height: 8),
if (results.isEmpty)
Padding(
padding: const EdgeInsets.all(30),
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.search_off_rounded,
size: 48, color: AppColors.mutedInk),
const SizedBox(height: 10),
Text(l10n.t('noDiscoverResults'), textAlign: TextAlign.center),
]),
)
else
for (final result in results) ...[
_SearchResultTile(
result: result,
languageCode: lang,
onTap: () =>
_openSearchResult(context, result, store, lang, l10n)),
const SizedBox(height: 10),
],
],
),
);
}
Widget _searchField(AppLocalizations l10n) => TextField(
controller: _searchController,
onChanged: (value) => setState(() => _query = value),
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: l10n.t('discoverSearch'),
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: _query.isEmpty
? null
: IconButton(
tooltip: l10n.t('clearSearch'),
onPressed: () {
_searchController.clear();
setState(() {
_query = '';
_type = _DiscoverType.all;
_sort = _DiscoverSort.relevance;
});
},
icon: const Icon(Icons.close_rounded),
),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: const BorderSide(color: AppColors.line)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
borderSide: const BorderSide(color: AppColors.line)),
),
);
Widget _browseChip(
_DiscoverType type, String label, dynamic store, String lang) =>
Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
label: Text(label),
onPressed: () {
if (type == _DiscoverType.all || type == _DiscoverType.topics) {
return;
}
if (type == _DiscoverType.events) {
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const EventsPage()));
return;
}
final results = <_SearchResult>[];
if (type == _DiscoverType.places) {
for (final poi in store.pois) {
results.add(_SearchResult.poi(
poi: poi, title: poi.title.get(lang), score: 1));
}
} else if (type == _DiscoverType.tours) {
for (final tour in store.tours) {
results.add(_SearchResult.tour(
tour: tour, title: tour.title.get(lang), score: 1));
}
} else if (type == _DiscoverType.partners ||
type == _DiscoverType.gastro) {
for (final partner in store.partners) {
if (type == _DiscoverType.gastro &&
!_isGastroPartner(partner)) {
continue;
}
results.add(_SearchResult.partner(
partner: partner, title: partner.title, score: 1));
}
}
Navigator.of(context).push(MaterialPageRoute(
builder: (_) =>
_DiscoverBrowsePage(title: label, results: results),
));
},
),
);
Widget _typeChip(_DiscoverType type, String label) => Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: _type == type,
onSelected: (_) => setState(() => _type = type)),
);
void _sortSearchResults(List<_SearchResult> results) {
switch (_sort) {
case _DiscoverSort.relevance:
results.sort((a, b) => b.score.compareTo(a.score));
break;
case _DiscoverSort.az:
results.sort(
(a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
break;
case _DiscoverSort.distance:
results.sort((a, b) {
final ad = a.distanceMeters ?? 1 << 30;
final bd = b.distanceMeters ?? 1 << 30;
final cmp = ad.compareTo(bd);
return cmp != 0
? cmp
: a.title.toLowerCase().compareTo(b.title.toLowerCase());
});
break;
}
}
void _openSearchResult(BuildContext context, _SearchResult result,
dynamic store, String lang, AppLocalizations l10n) {
if (result.poi != null) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => PoiDetailPage(poi: result.poi!)));
} else if (result.tour != null) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => TourDetailPage(tour: result.tour!)));
} else if (result.event != null) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => EventDetailPage(event: result.event!)));
} else if (result.partner != null) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PartnerDetailPage(partner: result.partner!)));
} else if (result.topic != null) {
final topic = result.topic!;
final pois = store.pois
.where((Poi p) => AppTaxonomy.matchesPoi(topic, p))
.toList();
final tours = store.tours
.where((Tour t) => AppTaxonomy.matchesTour(topic, t))
.toList();
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => CategoryDetailPage(
categorySlug: topic.id,
categoryName: topic.label(l10n),
pois: pois,
tours: tours,
partners: _partnersForTopic(topic, store.partners),
languageCode: lang,
)));
}
}
static int _columnCount(double width) => width >= 1100
? 4
: width >= 700
? 3
: 2;
}
class CategoryDetailPage extends StatefulWidget {
const CategoryDetailPage({
super.key,
required this.categorySlug,
required this.categoryName,
required this.pois,
required this.tours,
required this.partners,
required this.languageCode,
});
final String categorySlug;
final String categoryName;
final List<Poi> pois;
final List<Tour> tours;
final List<Partner> partners;
final String languageCode;
@override
State<CategoryDetailPage> createState() => _CategoryDetailPageState();
}
class _CategoryDetailPageState extends State<CategoryDetailPage> {
_TopicType _type = _TopicType.all;
_TopicSort _sort = _TopicSort.recommended;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final lang = widget.languageCode;
final location = LocationScope.of(context);
final items = <_TopicResult>[];
if (_type == _TopicType.all || _type == _TopicType.places) {
items.addAll(widget.pois.map((p) => _TopicResult.poi(p, p.title.get(lang),
location.distanceMetersTo(p.latitude, p.longitude))));
}
if (_type == _TopicType.all || _type == _TopicType.tours) {
items.addAll(
widget.tours.map((t) => _TopicResult.tour(t, t.title.get(lang))));
}
if (_type == _TopicType.all || _type == _TopicType.nearby) {
items.addAll(widget.partners.map((p) => _TopicResult.partner(
p, p.title, location.distanceMetersTo(p.latitude, p.longitude))));
}
_sortTopicItems(items);
return Scaffold(
appBar: AppBar(title: Text(widget.categoryName)),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 28),
children: [
Row(children: [
Expanded(
child: Text('${items.length} ${l10n.t('entries')}',
style: const TextStyle(
fontWeight: FontWeight.w800,
color: AppColors.mutedInk))),
PopupMenuButton<_TopicSort>(
initialValue: _sort,
tooltip: l10n.t('sort'),
onSelected: (v) => setState(() => _sort = v),
itemBuilder: (_) => [
PopupMenuItem(
value: _TopicSort.recommended,
child: Text(l10n.t('recommended'))),
const PopupMenuItem(value: _TopicSort.az, child: Text('A–Z')),
PopupMenuItem(
value: _TopicSort.distance,
child: Text(l10n.t('distance'))),
],
child: const Padding(
padding: EdgeInsets.all(8), child: Icon(Icons.sort_rounded)),
),
]),
const SizedBox(height: 8),
SizedBox(
height: 42,
child: ListView(scrollDirection: Axis.horizontal, children: [
_topicChip(_TopicType.all, l10n.t('all')),
_topicChip(_TopicType.places, l10n.t('places')),
_topicChip(_TopicType.tours, l10n.t('tours')),
if (widget.partners.isNotEmpty)
_topicChip(_TopicType.nearby, 'Nearby'),
]),
),
const SizedBox(height: 12),
if (items.isEmpty)
Padding(
padding: const EdgeInsets.all(24),
child: Center(child: Text(l10n.t('noMatchingContent'))))
else
for (final item in items) ...[
_TopicResultTile(item: item, languageCode: lang),
const SizedBox(height: 10),
],
],
),
);
}
Widget _topicChip(_TopicType type, String label) => Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: _type == type,
onSelected: (_) => setState(() => _type = type)),
);
void _sortTopicItems(List<_TopicResult> items) {
switch (_sort) {
case _TopicSort.recommended:
items.sort(
(a, b) => b.recommendationScore.compareTo(a.recommendationScore));
break;
case _TopicSort.az:
items.sort(
(a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
break;
case _TopicSort.distance:
items.sort((a, b) => (a.distanceMeters ?? 1 << 30)
.compareTo(b.distanceMeters ?? 1 << 30));
break;
}
}
}
class _TopicTile extends StatelessWidget {
const _TopicTile(
{required this.name,
required this.subtitle,
required this.icon,
required this.onTap,
this.compact = false});
final String name;
final String subtitle;
final IconData icon;
final VoidCallback onTap;
final bool compact;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.circular(24),
child: InkWell(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: AppColors.line),
borderRadius: BorderRadius.circular(24)),
padding: EdgeInsets.all(compact ? 14 : 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Center(
child: Container(
width: compact ? 84 : 104,
height: compact ? 84 : 104,
decoration: BoxDecoration(
color: AppColors.softRed,
borderRadius: BorderRadius.circular(compact ? 28 : 34),
),
child: Icon(
icon,
color: AppColors.heartRed,
size: compact ? 46 : 56,
),
),
),
),
const SizedBox(height: 8),
Text(
name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: compact ? 16 : 20,
height: 1.08,
),
),
const SizedBox(height: 5),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.mutedInk,
fontSize: compact ? 11 : 13,
),
),
],
),
),
),
);
}
}
class _SearchResultTile extends StatelessWidget {
const _SearchResultTile(
{required this.result, required this.languageCode, required this.onTap});
final _SearchResult result;
final String languageCode;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final subtitle = result.poi?.shortDescription.get(languageCode) ??
result.tour?.subtitle.get(languageCode) ??
result.event?.shortDescription.get(languageCode) ??
result.partner?.shortDescription ??
l10n.t('topic');
final image = result.poi?.imageUrl ??
result.tour?.imageUrl ??
result.event?.imageUrl ??
result.partner?.imageUrl;
final icon = result.topic != null
? _topicIcon(result.topic!.id)
: result.poi != null
? Icons.place_rounded
: result.tour != null
? Icons.route_rounded
: result.event != null
? Icons.event_rounded
: Icons.storefront_rounded;
final type = result.topic != null
? l10n.t('topic').toUpperCase()
: result.poi != null
? l10n.t('place').toUpperCase()
: result.tour != null
? l10n.t('tourSingular').toUpperCase()
: result.event != null
? l10n.t('events').toUpperCase()
: l10n.t('partners').toUpperCase();
return _ResultCard(
title: result.title,
subtitle: subtitle,
imageUrl: image,
icon: icon,
badge: type,
isTour: result.tour != null,
distanceMeters: result.distanceMeters,
onTap: onTap,
);
}
}
class _TopicResultTile extends StatelessWidget {
const _TopicResultTile({required this.item, required this.languageCode});
final _TopicResult item;
final String languageCode;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final subtitle = item.poi?.shortDescription.get(languageCode) ??
item.tour?.subtitle.get(languageCode) ??
item.partner?.shortDescription ??
'';
final image =
item.poi?.imageUrl ?? item.tour?.imageUrl ?? item.partner?.imageUrl;
final icon = item.poi != null
? Icons.place_rounded
: item.tour != null
? Icons.route_rounded
: Icons.storefront_rounded;
final badge = item.poi != null
? l10n.t('place').toUpperCase()
: item.tour != null
? l10n.t('tourSingular').toUpperCase()
: l10n.t('partners').toUpperCase();
return _ResultCard(
title: item.title,
subtitle: subtitle,
imageUrl: image,
icon: icon,
badge: badge,
isTour: item.tour != null,
distanceMeters: item.distanceMeters,
onTap: () {
if (item.poi != null) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => PoiDetailPage(poi: item.poi!)));
}
if (item.tour != null) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => TourDetailPage(tour: item.tour!)));
}
if (item.partner != null) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PartnerDetailPage(partner: item.partner!)));
}
},
);
}
}
class _ResultCard extends StatelessWidget {
const _ResultCard({
required this.title,
required this.subtitle,
required this.imageUrl,
required this.icon,
required this.badge,
required this.onTap,
this.isTour = false,
this.distanceMeters,
});
final String title;
final String subtitle;
final String? imageUrl;
final IconData icon;
final String badge;
final bool isTour;
final int? distanceMeters;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
border: Border.all(color: AppColors.line),
borderRadius: BorderRadius.circular(20)),
child: Row(children: [
SizedBox(
width: 92,
height: 82,
child: isTour
? TourFeaturedHeader(
imageUrl: imageUrl,
title: title,
height: 82,
compact: true,
thumbnail: true,
borderRadius: BorderRadius.circular(15),
)
: RemoteImage(
url: imageUrl,
width: 92,
height: 82,
fit: BoxFit.cover,
borderRadius: BorderRadius.circular(15),
fallbackIcon: icon,
backgroundColor: AppColors.softRed,
semanticLabel: title,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Text(badge,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w900,
color: AppColors.heartRed,
letterSpacing: .7)),
if (distanceMeters != null) ...[
const SizedBox(width: 8),
Text(_distanceLabel(distanceMeters!),
style: const TextStyle(
fontSize: 11, color: AppColors.mutedInk))
],
]),
const SizedBox(height: 3),
Text(title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w900, fontSize: 16)),
if (subtitle.trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.mutedInk, fontSize: 13)),
],
])),
const Icon(Icons.chevron_right_rounded),
]),
),
),
);
}
}
class _SearchResult {
_SearchResult(
{required this.title,
required this.score,
this.poi,
this.tour,
this.event,
this.partner,
this.topic,
this.distanceMeters});
factory _SearchResult.poi(
{required Poi poi,
required String title,
required int score,
int? distanceMeters}) =>
_SearchResult(
title: title, score: score, poi: poi, distanceMeters: distanceMeters);
factory _SearchResult.tour(
{required Tour tour, required String title, required int score}) =>
_SearchResult(title: title, score: score, tour: tour);
factory _SearchResult.event(
{required Event event, required String title, required int score}) =>
_SearchResult(title: title, score: score, event: event);
factory _SearchResult.partner(
{required Partner partner,
required String title,
required int score,
int? distanceMeters}) =>
_SearchResult(
title: title,
score: score,
partner: partner,
distanceMeters: distanceMeters);
factory _SearchResult.topic(
{required AppTopic topic,
required String title,
required int score}) =>
_SearchResult(title: title, score: score, topic: topic);
final String title;
final int score;
final Poi? poi;
final Tour? tour;
final Event? event;
final Partner? partner;
final AppTopic? topic;
final int? distanceMeters;
}
class _TopicResult {
_TopicResult(
{required this.title,
this.poi,
this.tour,
this.partner,
this.distanceMeters});
factory _TopicResult.poi(Poi poi, String title, int? distance) =>
_TopicResult(title: title, poi: poi, distanceMeters: distance);
factory _TopicResult.tour(Tour tour, String title) =>
_TopicResult(title: title, tour: tour);
factory _TopicResult.partner(Partner partner, String title, int? distance) =>
_TopicResult(title: title, partner: partner, distanceMeters: distance);
final String title;
final Poi? poi;
final Tour? tour;
final Partner? partner;
final int? distanceMeters;
int get recommendationScore => poi?.highlight == true
? 100
: tour?.highlight == true
? 90
: partner?.sponsored == true
? 70 + (partner?.priority ?? 0)
: 10;
}
int _textScore(String q, Iterable<String> fields) {
if (q.isEmpty) return 0;
var best = 0;
for (final raw in fields) {
final field = _norm(raw);
if (field.isEmpty) continue;
if (field == q) {
best = best < 120 ? 120 : best;
} else if (field.startsWith(q)) {
best = best < 95 ? 95 : best;
} else if (field.contains(q)) {
best = best < 70 ? 70 : best;
} else {
final words = q.split(' ').where((e) => e.isNotEmpty).toList();
final fieldWords = field.split(' ').where((e) => e.isNotEmpty).toList();
final hits = words.where(field.contains).length;
var score = hits * 18;
for (final queryWord in words) {
if (queryWord.length < 4) continue;
for (final fieldWord in fieldWords) {
final distance = _editDistance(queryWord, fieldWord);
if (distance <= 1) {
score += 35;
} else if (distance == 2 && queryWord.length >= 6) {
score += 20;
}
}
}
if (score > best) best = score;
}
}
return best;
}
int _editDistance(String a, String b) {
if (a == b) return 0;
if (a.isEmpty) return b.length;
if (b.isEmpty) return a.length;
var previous = List<int>.generate(b.length + 1, (i) => i);
for (var i = 0; i < a.length; i++) {
final current = List<int>.filled(b.length + 1, 0);
current[0] = i + 1;
for (var j = 0; j < b.length; j++) {
final cost = a[i] == b[j] ? 0 : 1;
current[j + 1] = [
current[j] + 1,
previous[j + 1] + 1,
previous[j] + cost,
].reduce((x, y) => x < y ? x : y);
}
previous = current;
}
return previous.last;
}
String _norm(String value) => value
.toLowerCase()
.trim()
.replaceAll('ä', 'ae')
.replaceAll('ö', 'oe')
.replaceAll('ü', 'ue')
.replaceAll('ß', 'ss')
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
List<Partner> _partnersForTopic(AppTopic topic, List<Partner> partners) {
final aliases = topic.aliases.map(_norm).toList();
return partners.where((p) {
final hay = _norm([
p.title,
p.partnerType ?? '',
p.shortDescription ?? '',
p.description ?? '',
p.cuisine ?? ''
].join(' '));
return aliases.any((a) => a.isNotEmpty && hay.contains(a));
}).toList();
}
IconData _topicIcon(String id) {
switch (id) {
case 'highlights':
return Icons.auto_awesome_rounded;
case 'goethe_schiller':
return Icons.history_edu_rounded;
case 'classic':
return Icons.menu_book_rounded;
case 'bauhaus':
return Icons.category_rounded;
case 'weimar_republic':
return Icons.account_balance_rounded;
case 'ns_memory':
return Icons.volunteer_activism_rounded;
case 'ddr_contemporary':
return Icons.schedule_rounded;
case 'architecture':
return Icons.architecture_rounded;
case 'museums_art':
return Icons.museum_rounded;
case 'parks_nature':
return Icons.park_rounded;
case 'castles':
return Icons.location_city_rounded;
case 'family':
return Icons.family_restroom_rounded;
case 'jewish_life':
return Icons.history_rounded;
case 'music':
return Icons.music_note_rounded;
case 'hidden_gems':
return Icons.explore_rounded;
default:
return Icons.interests_rounded;
}
}
String _distanceLabel(int meters) => meters < 1000
? '$meters m'
: '${(meters / 1000).toStringAsFixed(meters < 10000 ? 1 : 0)} km';
bool _isGastroPartner(Partner partner) {
final hay = _norm([
partner.partnerType ?? '',
partner.cuisine ?? '',
partner.title,
].join(' '));
const gastroTerms = [
'gastro',
'gastronomy',
'restaurant',
'cafe',
'café',
'bar',
'bistro',
'bakery',
'baeckerei',
'bäckerei',
'eis',
'ice cream',
'takeaway',
'food',
'imbiss',
'konditorei',
'kneipe',
];
return partner.cuisine?.trim().isNotEmpty == true ||
gastroTerms.any((term) => hay.contains(_norm(term)));
}
class _DiscoverBrowsePage extends StatelessWidget {
const _DiscoverBrowsePage({required this.title, required this.results});
final String title;
final List<_SearchResult> results;
@override
Widget build(BuildContext context) {
final lang = Localizations.localeOf(context).languageCode;
return Scaffold(
appBar: AppBar(
title:
Text(title, style: const TextStyle(fontWeight: FontWeight.w900))),
body: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
itemCount: results.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final result = results[index];
final imageUrl = result.poi?.imageUrl ??
result.tour?.imageUrl ??
result.partner?.imageUrl;
final subtitle = result.poi?.shortDescription.get(lang) ??
result.tour?.subtitle.get(lang) ??
result.partner?.shortDescription ??
'';
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () {
if (result.poi != null) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PoiDetailPage(poi: result.poi!)));
}
if (result.tour != null) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => TourDetailPage(tour: result.tour!)));
}
if (result.partner != null) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) =>
PartnerDetailPage(partner: result.partner!)));
}
},
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
SizedBox(
width: 82,
height: 76,
child: result.tour != null
? TourFeaturedHeader(
imageUrl: imageUrl,
title: result.title,
height: 76,
compact: true,
thumbnail: true,
borderRadius: BorderRadius.circular(14),
)
: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: RemoteImage(
url: imageUrl,
width: 82,
height: 76,
fit: BoxFit.cover,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(result.title,
style: const TextStyle(
fontWeight: FontWeight.w900, fontSize: 16)),
if (subtitle.trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: AppColors.mutedInk)),
],
])),
const Icon(Icons.chevron_right_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%