glanzlicht / MeinWeimar_APP

Official MeinWeimar App

Verified secure showcase
204 Files 2 MB Public size 14 Redacted Dart · PHP · XML · GITIGNORE · YAML · PLIST

Project overview

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

README

MeinWeimar — Flutter App & WordPress Plugin

> Working in Progress · Private portfolio repository · Active development

MeinWeimar is a location-based local platform for residents, students and visitors. This repository intentionally contains only two product parts:

  1. the Flutter consumer app;
  2. the meinweimar-core WordPress plugin that provides the editorial/admin
  3. layer and the REST contract consumed by the app.

The complete public website, WordPress core, databases, private infrastructure and unrelated platform modules are not part of this repository.

MeinWeimar consumer app preview

Why this project is interesting

MeinWeimar combines a mobile/web Flutter client with WordPress used as a structured content and administration backend. The goal is a maintainable product boundary: content, branding, navigation, categories, media and configuration are managed in WordPress and delivered through a versioned REST contract instead of being hardcoded in the Flutter UI.

The project demonstrates work across:

  • Flutter/Dart application architecture for iOS, Android and Web;
  • WordPress plugin architecture with custom post types, taxonomies, admin
  • settings and REST endpoints;

  • localized German/English UI and backend-controlled labels;
  • discovery/list/detail flows for places, services, events, classifieds, jobs
  • and real estate;

  • map, GPS, geocoding, marker and navigation contracts;
  • caching, ETag/config-version handling and offline-safe fallbacks;
  • role-aware account, claim, favorites, moderation and messaging boundaries;
  • automated Flutter tests, PHP/REST contract checks and release builds.

Repository structure

flutter-app/       Flutter consumer application
wordpress-plugin/  meinweimar-core WordPress plugin
screenshots/       Curated portfolio preview only

Current status

This is an active Working in Progress project. The core architecture and many end-to-end flows are implemented and continuously tested. Native device polish, production content, final legal/privacy review and further product modules are still being developed. The repository does not claim to be a finished production release.

Local development

Flutter app

cd flutter-app
flutter pub get
flutter analyze
flutter test
flutter run --dart-define=MW_API_BASE_URL=https://example.test/wp-json/meinweimar/v1

The API base URL and other environment-specific values are supplied at build or run time. No credentials are stored in the repository.

WordPress plugin

Copy wordpress-plugin into a WordPress installation's wp-content/plugins/meinweimar-core directory and activate it in the admin area. The plugin owns the MeinWeimar content types, editorial settings, permissions, REST responses, media/configuration contracts and frontend integration points.

Privacy and repository boundary

This repository is private and is prepared for controlled technical review. Secrets, production database exports, user data, WordPress uploads, private keys and infrastructure configuration must remain outside version control. The public-facing portfolio page uses a separate, redacted presentation layer and does not expose the private repository directly.

For technical reviewers

The most relevant entry points are:

  • flutter-app/lib/core — shared configuration, networking, localization,
  • theme and location contracts;

  • flutter-app/lib/features — consumer-facing product surfaces;
  • flutter-app/lib/repositories — API boundary and data access;
  • wordpress-plugin/includes — plugin services, REST, admin and contracts;
  • wordpress-plugin/assets — admin/frontend integration assets.

MeinWeimar is deliberately presented as an evolving product rather than a finished demo: the repository shows the architectural decisions, the working implementation and the remaining engineering work transparently.

flutter-app/lib/features/detail/content_detail_page.dartDart
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/formatting/localized_formatters.dart';
import '../../core/config/app_config.dart';
import '../../core/content/event_labels.dart';
import '../../core/content/listing_labels.dart';
import '../../core/navigation/canonical_module_routes.dart';
import '../../core/errors/api_error_code.dart';
import '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
import '../../models/content_models.dart';
import '../../repositories/account_repository.dart';
import '../../repositories/discovery_repository.dart';
import '../../repositories/image_suggestion_repository.dart';
import '../../shared/states/app_state_widgets.dart';
import '../../shared/map/map_surface.dart';
import '../../shared/media/public_media_image.dart';
import '../../shared/navigation/external_navigation_adapter.dart';
import '../image_suggestion/image_suggestion_labels.dart';
import '../image_suggestion/image_suggestion_page.dart';
typedef ExternalUriLauncher = Future<bool> Function(Uri uri);
class ContentDetailPage extends StatefulWidget {
const ContentDetailPage({
super.key,
required this.item,
required this.repository,
this.accountRepository,
this.imageSuggestionRepository,
this.onOpenMap,
this.geoConfig,
this.userLatitude,
this.userLongitude,
this.uriLauncher,
this.fieldSchemas = const {},
this.fieldIcons = const {},
});
final DiscoveryItem item;
final DiscoveryRepository repository;
final AccountRepository? accountRepository;
final ImageSuggestionRepository? imageSuggestionRepository;
final VoidCallback? onOpenMap;
final GeoConfig? geoConfig;
final double? userLatitude;
final double? userLongitude;
final ExternalUriLauncher? uriLauncher;
final Map<String, ContentFieldSchema> fieldSchemas;
final Map<String, AppIconConfig> fieldIcons;
@override
State<ContentDetailPage> createState() => _ContentDetailPageState();
}
class _ContentDetailPageState extends State<ContentDetailPage> {
DiscoveryItem? _detail;
bool _loading = true;
ApiErrorCode? _errorCode;
bool? _saved;
bool _favoriteBusy = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final detail = await widget.repository.detail(
resource: widget.item.resource,
id: widget.item.id,
);
if (mounted) {
bool? saved;
final accountRepository = widget.accountRepository;
if (accountRepository != null) {
try {
final response = await accountRepository.favorites();
saved = response.items.any(
(entry) =>
entry.objectType == detail.resource &&
entry.objectId == detail.id,
);
} catch (_) {
// A private favorites read may legitimately be unauthorized. The
// save action still provides the typed auth boundary on demand.
}
}
if (!mounted) return;
setState(() {
_detail = detail;
_saved = saved;
_errorCode = null;
_loading = false;
});
}
} catch (error) {
if (mounted) {
setState(() {
_detail = widget.item;
_errorCode = error is ApiException
? error.code
: ApiErrorCode.unknown;
_loading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final item = _detail ?? widget.item;
final canOpenMap =
CanonicalModuleRoutes.isGeoResource(item.resource) &&
item.geo.available &&
item.geo.latitude != null &&
item.geo.longitude != null &&
widget.onOpenMap != null;
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => Navigator.of(context).maybePop(),
tooltip: l10n.back,
icon: const Icon(Icons.arrow_back),
),
title: Text(
item.resource == 'jobs' ? l10n.jobsScreenTitle : item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
actions: [
IconButton(
onPressed: _toggleFavorite,
tooltip: _saved == true ? l10n.favoriteRemove : l10n.save,
icon: _favoriteBusy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(_saved == true ? Icons.favorite : Icons.favorite_border),
),
],
),
body: _loading
? const LoadingState()
: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 860),
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 28),
children: [
if (_errorCode != null)
SizedBox(
height: 260,
child: ErrorState(onRetry: _load, code: _errorCode),
),
_HeroMedia(item: item),
if (isImageSuggestionTargetAllowed(item.resource) &&
(item.mediaPlaceholder ||
(item.media.isEmpty && item.imageUrl.isEmpty) ||
(item.media.isNotEmpty &&
item.media.first.isPlaceholder))) ...[
const SizedBox(height: 12),
_ImageSuggestionCta(
onPressed: () => _openImageSuggestion(context, item),
),
],
const SizedBox(height: 20),
_DetailIdentityCard(
item: item,
l10n: l10n,
canOpenMap: canOpenMap,
onOpenMap: widget.onOpenMap,
eventStatusLabel: _eventStatusLabel,
),
if (item.resource == 'places' ||
item.resource == 'services' ||
item.resource == 'events')
_PlaceActionBar(
l10n: l10n,
onRoute: canOpenMap && _hasNavigationTarget(item)
? () => _openNavigation(context, item)
: null,
onPhone: item.contact.phone.isEmpty
? null
: () => _launchPhone(context, item.contact.phone),
onWebsite: item.contact.websiteUrl.isEmpty
? null
: () =>
_launchWeb(context, item.contact.websiteUrl),
),
if ((item.resource == 'classifieds' ||
item.resource == 'real-estate') &&
item.listing != null)
_ListingOverview(item: item, l10n: l10n),
if ((item.resource == 'classifieds' ||
item.resource == 'real-estate') &&
(item.contact.phone.isNotEmpty ||
item.contact.email.isNotEmpty ||
item.contact.websiteUrl.isNotEmpty))
_ListingActionBar(
l10n: l10n,
onPhone: item.contact.phone.isEmpty
? null
: () => _launchPhone(context, item.contact.phone),
onEmail: item.contact.email.isEmpty
? null
: () => _launchUri(
context,
Uri(scheme: 'mailto', path: item.contact.email),
),
onWebsite: item.contact.websiteUrl.isEmpty
? null
: () =>
_launchWeb(context, item.contact.websiteUrl),
),
if (item.event != null)
_EventSection(
event: item.event!,
l10n: l10n,
uriLauncher: widget.uriLauncher,
),
if (item.resource == 'jobs' && item.listing != null)
_JobDetailSurface(
item: item,
l10n: l10n,
onApply: () => _openJobApplication(context, item),
),
if (item.listing != null)
_ListingSection(
listing: item.listing!,
l10n: l10n,
categorySlugs: item.terms.entries
.where((entry) => entry.key.endsWith('_category'))
.expand((entry) => entry.value)
.toSet(),
fieldSchemas: widget.fieldSchemas,
fieldIcons: widget.fieldIcons,
resource: item.resource,
presentationKeys: item.resource == 'jobs'
? _jobDetailPresentationKeys
: null,
sectionTitle: item.resource == 'jobs'
? l10n.jobsDetailsTitle
: null,
dense: item.resource == 'jobs',
),
const SizedBox(height: 24),
_DetailSection(
title: l10n.detailDescription,
html: item.content?.html.isNotEmpty == true
? item.content!.html
: '',
fallback: item.content?.text.isNotEmpty == true
? item.content!.text
: item.excerpt,
onOpenUrl: (uri) => _launchUri(context, uri),
),
if (item.contact.phone.isNotEmpty ||
item.contact.email.isNotEmpty ||
item.contact.websiteUrl.isNotEmpty ||
item.contact.facebookUrl.isNotEmpty ||
item.contact.twitterUrl.isNotEmpty ||
item.contact.videoUrl.isNotEmpty)
_IconDetailSection(
title: l10n.detailContact,
rows: [
if (item.contact.phone.isNotEmpty)
_IconDetailRow(
Icons.phone_outlined,
item.contact.phone,
onTap: () =>
_launchPhone(context, item.contact.phone),
),
if (item.contact.email.isNotEmpty)
_IconDetailRow(
Icons.email_outlined,
item.contact.email,
onTap: () => _launchUri(
context,
Uri(scheme: 'mailto', path: item.contact.email),
),
),
if (item.contact.websiteUrl.isNotEmpty)
_IconDetailRow(
Icons.language_outlined,
item.contact.websiteUrl,
onTap: () =>
_launchWeb(context, item.contact.websiteUrl),
),
if (item.contact.facebookUrl.isNotEmpty)
_IconDetailRow(
Icons.public_outlined,
item.contact.facebookUrl,
onTap: () =>
_launchWeb(context, item.contact.facebookUrl),
),
if (item.contact.twitterUrl.isNotEmpty)
_IconDetailRow(
Icons.public_outlined,
item.contact.twitterUrl,
onTap: () =>
_launchWeb(context, item.contact.twitterUrl),
),
if (item.contact.videoUrl.isNotEmpty)
_IconDetailRow(
Icons.play_circle_outline,
item.contact.videoUrl,
onTap: () =>
_launchWeb(context, item.contact.videoUrl),
),
],
),
if (item.placeDetails?.openingHours.hasData == true)
_IconDetailSection(
title: l10n.detailOpeningHours,
rows: [
_IconDetailRow(
Icons.schedule_outlined,
formatOpeningHours(
item.placeDetails!.openingHours,
Localizations.localeOf(context).toLanguageTag(),
l10n.detailClosed,
),
),
],
),
if (item.placeDetails?.specialOffers.isNotEmpty == true)
_DetailSection(
title: l10n.detailSpecialOffers,
fallback: item.placeDetails!.specialOffers,
),
if (item.placeDetails?.neighbourhood.isNotEmpty == true)
_DetailSection(
title: l10n.detailNeighbourhood,
fallback: item.placeDetails!.neighbourhood,
),
if ((item.placeDetails?.ratingCount ?? 0) > 0)
_DetailSection(
title: l10n.detailRating,
fallback:
'${formatRating(item.placeDetails!.ratingAverage, Localizations.localeOf(context).toLanguageTag())} / 5 (${formatCount(item.placeDetails!.ratingCount, Localizations.localeOf(context).toLanguageTag())})',
),
if (canOpenMap) ...[
const SizedBox(height: 16),
MapSurface(
items: [item],
meta: null,
geoConfig: widget.geoConfig,
initialSelectedItem: item,
userLatitude: widget.userLatitude,
userLongitude: widget.userLongitude,
radiusKm: widget.geoConfig?.defaultRadiusKm ?? 1,
compact: true,
showSearchCenterMarker: false,
onItemTap: (_) => widget.onOpenMap?.call(),
onRetry: _load,
),
if (_hasNavigationTarget(item)) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => _openNavigation(context, item),
icon: const Icon(Icons.directions_outlined),
label: Text(l10n.routeAction),
),
],
],
if (CanonicalModuleRoutes.isGeoResource(item.resource) &&
item.address.formatted.isNotEmpty)
_IconDetailSection(
title: l10n.detailAddress,
rows: [
_IconDetailRow(
Icons.location_on_outlined,
item.address.formatted,
onTap: canOpenMap ? widget.onOpenMap : null,
),
],
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _toggleFavorite,
icon: Icon(
_saved == true ? Icons.favorite : Icons.favorite_border,
),
label: Text(
_saved == true ? l10n.favoriteRemove : l10n.save,
),
),
],
),
),
),
);
}
String _eventStatusLabel(AppLocalizations l10n, String status) {
return eventStatusLabel(l10n, status);
}
void _showAuthRequired(BuildContext context) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.authRequiredBody)));
}
Future<void> _toggleFavorite() async {
final l10n = AppLocalizations.of(context);
final accountRepository = widget.accountRepository;
if (accountRepository == null || _favoriteBusy) {
_showAuthRequired(context);
return;
}
final item = _detail ?? widget.item;
final nextSaved = _saved != true;
setState(() => _favoriteBusy = true);
try {
final saved = await accountRepository.updateFavorite(
objectType: item.resource,
objectId: item.id,
saved: nextSaved,
);
if (!mounted) return;
setState(() {
_saved = saved;
_favoriteBusy = false;
});
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
saved ? l10n.favoriteSavedBody : l10n.favoriteRemovedBody,
),
),
);
} catch (error) {
if (!mounted) return;
setState(() => _favoriteBusy = false);
if (error is ApiException &&
error.code == ApiErrorCode.permissionDenied) {
_showAuthRequired(context);
return;
}
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.favoriteError)));
}
}
void _openImageSuggestion(BuildContext context, DiscoveryItem item) {
final repository = widget.imageSuggestionRepository;
if (repository == null) {
_showAuthRequired(context);
return;
}
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ImageSuggestionPage(item: item, repository: repository),
),
);
}
Future<void> _openJobApplication(
BuildContext context,
DiscoveryItem item,
) async {
final listing = item.listing;
if (listing == null) return;
final applicationUrl = listing.applicationUrl.trim();
if (applicationUrl.isNotEmpty) {
await _launchWeb(context, applicationUrl);
return;
}
final applicationEmail = '${listing.value('application_email') ?? ''}'
.trim();
if (applicationEmail.isNotEmpty) {
await _launchUri(context, Uri(scheme: 'mailto', path: applicationEmail));
return;
}
if (item.contact.websiteUrl.isNotEmpty) {
await _launchWeb(context, item.contact.websiteUrl);
}
}
Future<void> _launchPhone(BuildContext context, String value) async {
var normalized = value.replaceAll(RegExp(r'[^0-9+#*;,]'), '');
if (normalized.startsWith('00')) {
normalized = '+${normalized.substring(2)}';
} else if (normalized.startsWith('49')) {
normalized = '+$normalized';
}
if (normalized.isEmpty) return;
await _launchUri(context, Uri(scheme: 'tel', path: normalized));
}
Future<void> _launchWeb(BuildContext context, String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) return;
final uri = Uri.tryParse(
RegExp(r'^[a-z][a-z0-9+.-]*://', caseSensitive: false).hasMatch(trimmed)
? trimmed
: 'https://$trimmed',
);
if (uri != null) await _launchUri(context, uri);
}
bool _hasNavigationTarget(DiscoveryItem item) {
final latitude = item.geo.latitude;
final longitude = item.geo.longitude;
if (latitude == null || longitude == null) return false;
final adapter = ExternalNavigationAdapter.system();
return adapter
.candidateUris(
NavigationTarget(
latitude: latitude,
longitude: longitude,
address: item.address.formatted,
),
navigationProvider:
widget.geoConfig?.navigationProvider ?? 'google_maps',
webUrlTemplate: widget.geoConfig?.externalNavigationUrlTemplate ?? '',
)
.isNotEmpty;
}
Future<void> _openNavigation(BuildContext context, DiscoveryItem item) async {
final latitude = item.geo.latitude;
final longitude = item.geo.longitude;
if (latitude == null || longitude == null) return;
final opened = await ExternalNavigationAdapter.system().open(
NavigationTarget(
latitude: latitude,
longitude: longitude,
address: item.address.formatted,
),
navigationProvider: widget.geoConfig?.navigationProvider ?? 'google_maps',
webUrlTemplate: widget.geoConfig?.externalNavigationUrlTemplate ?? '',
);
if (!opened && context.mounted) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.linkOpenFailed)));
}
}
Future<void> _launchUri(BuildContext context, Uri uri) async {
final allowed = {'http', 'https', 'mailto', 'tel'};
if (!allowed.contains(uri.scheme.toLowerCase())) return;
var opened = false;
try {
opened = await (widget.uriLauncher ?? _launchExternally)(uri);
} catch (_) {
opened = false;
}
if (!opened && context.mounted) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.linkOpenFailed)));
}
}
static Future<bool> _launchExternally(Uri uri) =>
launchUrl(uri, mode: LaunchMode.externalApplication);
}
class _HeroMedia extends StatefulWidget {
const _HeroMedia({required this.item});
final DiscoveryItem item;
@override
State<_HeroMedia> createState() => _HeroMediaState();
}
class _HeroMediaState extends State<_HeroMedia> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
final seenUrls = <String>{};
final media = widget.item.media
.where((entry) {
final url = entry.url.trim();
return url.isNotEmpty && seenUrls.add(url);
})
.toList(growable: false);
if (media.isEmpty) {
return _MediaImage(url: widget.item.imageUrl);
}
final selected = media[_selectedIndex.clamp(0, media.length - 1).toInt()];
final gallery = media.length > 1 ? media.sublist(1) : const <MediaData>[];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_MediaImage(url: selected.url, alt: selected.alt),
if (gallery.isNotEmpty) ...[
const SizedBox(height: 8),
SizedBox(
height: 68,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: gallery.length,
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final thumbnail = gallery[index];
return InkWell(
onTap: () => setState(() => _selectedIndex = index + 1),
borderRadius: BorderRadius.circular(10),
child: Semantics(
button: true,
label: thumbnail.alt,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: PublicMediaImage(
url: thumbnail.url,
alt: thumbnail.alt,
width: 88,
height: 68,
iconSize: 24,
),
),
),
);
},
),
),
],
],
);
}
}
class _MediaImage extends StatelessWidget {
const _MediaImage({required this.url, this.alt = ''});
final String url;
final String alt;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final height = (constraints.maxWidth * .56).clamp(220.0, 360.0);
final image = PublicMediaImage(
url: url,
alt: alt,
width: double.infinity,
height: height,
fallbackHeight: height,
fit: BoxFit.cover,
emptyIcon: Icons.photo_outlined,
);
return ClipRRect(borderRadius: BorderRadius.circular(22), child: image);
},
);
}
}
class _DetailIdentityCard extends StatelessWidget {
const _DetailIdentityCard({
required this.item,
required this.l10n,
required this.canOpenMap,
required this.onOpenMap,
required this.eventStatusLabel,
});
final DiscoveryItem item;
final AppLocalizations l10n;
final bool canOpenMap;
final VoidCallback? onOpenMap;
final String Function(AppLocalizations, String) eventStatusLabel;
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context).toLanguageTag();
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.placeDetails?.featured == true) ...[
Chip(
avatar: const Icon(Icons.star, size: 18),
label: Text(l10n.detailFeatured),
),
const SizedBox(height: 10),
],
Text(
item.title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontSize: 23,
height: 1.14,
fontWeight: FontWeight.w800,
letterSpacing: -.35,
),
),
if (CanonicalModuleRoutes.isGeoResource(item.resource) &&
item.address.formatted.isNotEmpty) ...[
const SizedBox(height: 12),
InkWell(
onTap: canOpenMap ? onOpenMap : null,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.location_on_outlined,
size: 20,
color: AppTheme.palette(context).brand,
),
const SizedBox(width: 10),
Expanded(
child: Text(
item.address.formatted,
style: TextStyle(
color: AppTheme.palette(context).muted,
height: 1.35,
),
),
),
if (canOpenMap)
const Padding(
padding: EdgeInsets.only(left: 8, top: 1),
child: Icon(Icons.chevron_right, size: 20),
),
],
),
),
),
],
if (item.event?.start.isNotEmpty == true) ...[
const SizedBox(height: 12),
_IdentityMetaRow(
icon: Icons.event_outlined,
text:
'${l10n.eventDate}: ${formatDate(item.event!.start, locale)}',
),
],
if (item.event?.end.isNotEmpty == true)
_IdentityMetaRow(
icon: Icons.schedule_outlined,
text:
'${l10n.eventEnd}: ${formatDate(item.event!.end, locale)}',
),
if (item.event?.status.isNotEmpty == true)
_IdentityMetaRow(
icon: Icons.info_outline,
text:
'${l10n.eventStatus}: ${eventStatusLabel(l10n, item.event!.status)}',
),
if (item.resource == 'places' &&
(item.placeDetails?.ratingCount ?? 0) > 0) ...[
const SizedBox(height: 10),
_IdentityMetaRow(
icon: Icons.star_outline,
text:
'${formatRating(item.placeDetails!.ratingAverage, locale)} (${formatCount(item.placeDetails!.ratingCount, locale)})',
color: AppTheme.palette(context).brand,
),
],
],
),
),
);
}
}
class _IdentityMetaRow extends StatelessWidget {
const _IdentityMetaRow({required this.icon, required this.text, this.color});
final IconData icon;
final String text;
final Color? color;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: color ?? AppTheme.palette(context).muted),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: TextStyle(color: color ?? AppTheme.palette(context).muted),
),
),
],
),
);
}
}
class _PlaceActionBar extends StatelessWidget {
const _PlaceActionBar({
required this.l10n,
this.onRoute,
this.onPhone,
this.onWebsite,
});
final AppLocalizations l10n;
final VoidCallback? onRoute;
final VoidCallback? onPhone;
final VoidCallback? onWebsite;
@override
Widget build(BuildContext context) {
final actions = <Widget>[];
if (onRoute != null) {
actions.add(
FilledButton.icon(
onPressed: onRoute,
icon: const Icon(Icons.navigation_outlined, size: 18),
label: Text(l10n.routeAction),
),
);
}
if (onPhone != null) {
actions.add(
OutlinedButton.icon(
onPressed: onPhone,
icon: const Icon(Icons.phone_outlined, size: 18),
label: Text(l10n.callAction),
),
);
}
if (onWebsite != null) {
actions.add(
OutlinedButton.icon(
onPressed: onWebsite,
icon: const Icon(Icons.language_outlined, size: 18),
label: Text(l10n.websiteAction),
),
);
}
if (actions.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: Wrap(spacing: 8, runSpacing: 8, children: actions),
);
}
}
class _EventSection extends StatelessWidget {
const _EventSection({
required this.event,
required this.l10n,
this.uriLauncher,
});
final EventData event;
final AppLocalizations l10n;
final ExternalUriLauncher? uriLauncher;
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context).toLanguageTag();
final rows = <_IconDetailRow>[];
for (final occurrence in event.occurrences) {
if (occurrence.startsAt.isEmpty) continue;
final start = formatDate(occurrence.startsAt, locale);
final end = occurrence.endsAt.isEmpty
? ''
: '–${formatDate(occurrence.endsAt, locale)}';
final timezone = occurrence.timezone.isEmpty
? ''
: ' · ${occurrence.timezone}';
final status =
occurrence.status.isEmpty || occurrence.status == 'scheduled'
? ''
: ' · ${eventStatusLabel(l10n, occurrence.status)}';
rows.add(
_IconDetailRow(
Icons.schedule_outlined,
'${l10n.eventOccurrences}: $start$end$timezone$status',
),
);
}
final calendarUrl = event.value('calendar_url');
if (calendarUrl is String && calendarUrl.isNotEmpty) {
rows.add(
_IconDetailRow(
Icons.calendar_today_outlined,
eventFieldLabel(l10n, 'calendar_url'),
onTap: () => _openExternal(calendarUrl),
),
);
}
final ticketMode = event.value('ticket_mode');
if (ticketMode is String && ticketMode.isNotEmpty && ticketMode != 'free') {
rows.add(
_IconDetailRow(
Icons.confirmation_number_outlined,
'${eventFieldLabel(l10n, 'ticket_mode')}: ${eventValueLabel(l10n, ticketMode)}',
),
);
}
final ticketUrl = event.value('ticket_url');
if (ticketUrl is String && ticketUrl.isNotEmpty) {
rows.add(
_IconDetailRow(
Icons.open_in_new_outlined,
eventFieldLabel(l10n, 'ticket_url'),
onTap: () => _openExternal(ticketUrl),
),
);
}
final priceFrom = _number(event.value('ticket_price_from'));
final priceTo = _number(event.value('ticket_price_to'));
if (priceFrom != null || priceTo != null) {
final from = priceFrom == null
? ''
: formatCurrencyAmount(priceFrom, 'EUR', locale);
final to = priceTo == null
? ''
: formatCurrencyAmount(priceTo, 'EUR', locale);
final price = to.isEmpty || from == to ? from : '$from–$to';
if (price.isNotEmpty) {
rows.add(
_IconDetailRow(
Icons.euro_outlined,
'${eventFieldLabel(l10n, 'ticket_price_from')}: $price',
),
);
}
}
final organizer = event.value('organizer_name');
if (organizer is String && organizer.isNotEmpty) {
rows.add(
_IconDetailRow(
Icons.groups_outlined,
'${eventFieldLabel(l10n, 'organizer_name')}: $organizer',
),
);
}
final organizerUrl = event.value('organizer_url');
if (organizerUrl is String && organizerUrl.isNotEmpty) {
rows.add(
_IconDetailRow(
Icons.language_outlined,
eventFieldLabel(l10n, 'organizer_url'),
onTap: () => _openExternal(organizerUrl),
),
);
}
if (rows.isEmpty) return const SizedBox.shrink();
return _IconDetailSection(title: l10n.detailEvent, rows: rows);
}
Future<void> _openExternal(String value) async {
final uri = Uri.tryParse(value);
if (uri == null) return;
if (uriLauncher != null) {
await uriLauncher!(uri);
return;
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
double? _number(Object? value) {
return value is num ? value.toDouble() : double.tryParse('$value');
}
}
class _ImageSuggestionCta extends StatelessWidget {
const _ImageSuggestionCta({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.add_photo_alternate_outlined),
const SizedBox(width: 12),
Expanded(child: Text(l10n.imageSuggestCta)),
OutlinedButton(
onPressed: onPressed,
child: Text(l10n.imageSuggestCta),
),
],
),
),
);
}
}
class _DetailSection extends StatelessWidget {
const _DetailSection({
required this.title,
this.html = '',
this.fallback = '',
this.onOpenUrl,
});
final String title;
final String html;
final String fallback;
final ValueChanged<Uri>? onOpenUrl;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 6),
if (html.isNotEmpty)
HtmlWidget(
html,
renderMode: RenderMode.column,
textStyle: TextStyle(
color: AppTheme.palette(context).muted,
height: 1.45,
),
customStylesBuilder: (element) {
if (element.localName == 'a') {
return {
'color': AppTheme.cssColor(AppTheme.palette(context).brand),
};
}
return null;
},
onTapUrl: (url) {
final uri = Uri.tryParse(url);
if (uri == null || onOpenUrl == null) return false;
onOpenUrl!(uri);
return true;
},
)
else
Text(
fallback.isEmpty
? AppLocalizations.of(context).unknownValue
: fallback,
style: TextStyle(
color: AppTheme.palette(context).muted,
height: 1.45,
),
),
],
),
);
}
}
class _JobDetailSurface extends StatelessWidget {
const _JobDetailSurface({
required this.item,
required this.l10n,
required this.onApply,
});
final DiscoveryItem item;
final AppLocalizations l10n;
final VoidCallback onApply;
@override
Widget build(BuildContext context) {
final listing = item.listing!;
final locale = Localizations.localeOf(context).toLanguageTag();
final salary = jobSalaryLabel(listing, locale);
final employment = listing.employmentType.isEmpty
? ''
: listingValueLabel(
l10n,
listing.employmentType,
fieldKey: 'employment_type',
);
final workMode = listing.workMode.isEmpty
? ''
: listingValueLabel(l10n, listing.workMode, fieldKey: 'work_mode');
final hasApplication =
listing.applicationUrl.trim().isNotEmpty ||
'${listing.value('application_email') ?? ''}'.trim().isNotEmpty ||
item.contact.websiteUrl.trim().isNotEmpty;
final sections = <Widget>[];
_addCopySection(
sections,
context,
l10n.jobsTasks,
listing.value('responsibilities'),
);
_addCopySection(
sections,
context,
l10n.jobsRequirements,
listing.value('requirements'),
);
_addCopySection(
sections,
context,
l10n.detailBenefits,
listing.value('benefits'),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Wrap(
spacing: 10,
runSpacing: 8,
children: [
if (salary.isNotEmpty)
_JobFact(
icon: Icons.euro_outlined,
label: salary,
hint: jobSalaryIntervalLabel(l10n, listing),
),
if (employment.isNotEmpty)
_JobFact(
icon: Icons.schedule_outlined,
label: employment,
hint: l10n.detailEmploymentType,
),
if (workMode.isNotEmpty)
_JobFact(
icon: Icons.home_work_outlined,
label: workMode,
hint: l10n.detailWorkMode,
),
],
),
),
),
if (hasApplication) ...[
const SizedBox(height: 8),
FilledButton.icon(
onPressed: onApply,
icon: const Icon(Icons.open_in_new_outlined),
label: Text(l10n.jobsApplyNow),
),
],
...sections,
],
);
}
void _addCopySection(
List<Widget> sections,
BuildContext context,
String title,
Object? raw,
) {
final value = '$raw'.trim();
if (raw == null || value.isEmpty || value == 'null') return;
final lines = value
.split(RegExp(r'\r?\n|•'))
.map((line) => line.trim().replaceFirst(RegExp(r'^[-*]\s*'), ''))
.where((line) => line.isNotEmpty)
.toList(growable: false);
sections.add(
Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 7),
...lines.map(
(line) => Padding(
padding: const EdgeInsets.only(bottom: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.check_circle,
size: 18,
color: AppTheme.palette(context).brand,
),
const SizedBox(width: 8),
Expanded(
child: Text(
line,
style: TextStyle(
color: AppTheme.palette(context).muted,
),
),
),
],
),
),
),
],
),
),
);
}
}
class _JobFact extends StatelessWidget {
const _JobFact({required this.icon, required this.label, required this.hint});
final IconData icon;
final String label;
final String hint;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20, color: AppTheme.palette(context).brand),
const SizedBox(width: 6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
height: 1.15,
fontWeight: FontWeight.w700,
),
),
Text(
hint,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontSize: 11,
height: 1.15,
color: AppTheme.palette(context).muted,
),
),
],
),
],
);
}
}
class _ListingOverview extends StatelessWidget {
const _ListingOverview({required this.item, required this.l10n});
final DiscoveryItem item;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
final listing = item.listing!;
final locale = Localizations.localeOf(context).toLanguageTag();
final price = listing.priceLabel.trim().isNotEmpty
? listing.priceLabel.trim()
: formatCurrencyAmount(
listing.priceAmount,
listing.priceCurrency,
locale,
);
final facts = <_ListingFactData>[];
if (item.resource == 'real-estate') {
if (listing.propertyRooms != null) {
facts.add(
_ListingFactData(
Icons.bed_outlined,
formatLocalizedNumber(listing.propertyRooms!, locale),
l10n.detailRooms,
),
);
}
final customRooms = listing.value('property_rooms_custom');
if (listing.propertyRooms == null && customRooms is num) {
facts.add(
_ListingFactData(
Icons.bed_outlined,
formatLocalizedNumber(customRooms.toDouble(), locale),
l10n.detailRooms,
),
);
}
if (listing.propertyArea != null) {
facts.add(
_ListingFactData(
Icons.square_foot_outlined,
formatLocalizedNumber(listing.propertyArea!, locale),
'm²',
),
);
}
if (listing.propertyKind.trim().isNotEmpty) {
facts.add(
_ListingFactData(
Icons.home_work_outlined,
backendValueLabel(listing.propertyKind),
l10n.detailPropertyKind,
),
);
}
final bathroomValue = _propertyCountLabel(
listing.value('bathrooms'),
locale,
l10n,
);
if (bathroomValue.isNotEmpty) {
facts.add(
_ListingFactData(
Icons.bathtub_outlined,
bathroomValue,
l10n.detailBathrooms,
),
);
}
final bedrooms = listing.value('bedrooms');
final bedroomValue = _propertyCountLabel(bedrooms, locale, l10n);
if (bedroomValue.isNotEmpty) {
facts.add(
_ListingFactData(
Icons.king_bed_outlined,
bedroomValue,
l10n.detailBedrooms,
),
);
}
} else if (listing.classifiedCondition.trim().isNotEmpty) {
facts.add(
_ListingFactData(
Icons.sell_outlined,
listingValueLabel(l10n, listing.classifiedCondition),
l10n.detailCondition,
),
);
}
final transaction = listing.propertyTransactionType.trim();
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (price.isNotEmpty)
Expanded(
child: Text(
price,
style: TextStyle(
color: AppTheme.palette(context).brand,
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
)
else
const Spacer(),
if (transaction.isNotEmpty)
Chip(
label: Text(listingValueLabel(l10n, transaction)),
visualDensity: VisualDensity.compact,
),
],
),
if (facts.isNotEmpty) ...[
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [for (final fact in facts) _ListingFact(fact: fact)],
),
],
],
),
),
);
}
}
String _propertyCountLabel(
Object? value,
String locale,
AppLocalizations l10n,
) {
if (value is num) return formatLocalizedNumber(value.toDouble(), locale);
final raw = value is String ? value.trim() : '';
if (raw.isEmpty) return '';
if (raw == 'more_than_6') {
return l10n.listingValueMoreThanSix;
}
final parsed = double.tryParse(raw);
return parsed == null ? '' : formatLocalizedNumber(parsed, locale);
}
class _ListingFactData {
const _ListingFactData(this.icon, this.value, this.label);
final IconData icon;
final String value;
final String label;
}
class _ListingFact extends StatelessWidget {
const _ListingFact({required this.fact});
final _ListingFactData fact;
@override
Widget build(BuildContext context) {
final palette = AppTheme.palette(context);
return Container(
constraints: const BoxConstraints(minWidth: 92),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: palette.surface,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(fact.icon, size: 18, color: palette.muted),
const SizedBox(width: 7),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fact.value,
style: const TextStyle(
fontWeight: FontWeight.w700,
height: 1.1,
),
),
Text(
fact.label,
style: TextStyle(
color: palette.muted,
fontSize: 11,
height: 1.1,
),
),
],
),
],
),
);
}
}
class _ListingActionBar extends StatelessWidget {
const _ListingActionBar({
required this.l10n,
this.onPhone,
this.onEmail,
this.onWebsite,
});
final AppLocalizations l10n;
final VoidCallback? onPhone;
final VoidCallback? onEmail;
final VoidCallback? onWebsite;
@override
Widget build(BuildContext context) {
final actions = <Widget>[];
if (onEmail != null) {
actions.add(
OutlinedButton.icon(
onPressed: onEmail,
icon: const Icon(Icons.mail_outline),
label: Text(l10n.detailContact),
),
);
}
if (onPhone != null) {
actions.add(
FilledButton.icon(
onPressed: onPhone,
icon: const Icon(Icons.phone_outlined),
label: Text(l10n.callAction),
),
);
}
if (onWebsite != null) {
actions.add(
OutlinedButton.icon(
onPressed: onWebsite,
icon: const Icon(Icons.language_outlined),
label: Text(l10n.websiteAction),
),
);
}
if (actions.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: Wrap(spacing: 8, runSpacing: 8, children: actions),
);
}
}
// This is a presentation subset, not a second field registry. Values, labels,
// conditions and formatting still come from the WordPress FieldSchemas map.
const _jobDetailPresentationKeys = <String>[
'company_name',
'department',
'work_hours',
'start_date',
'application_deadline',
'languages',
'education',
'experience_years',
];
// Presentation order is deliberately separate from the canonical field
// registry. Values, conditions, labels and availability still come from the
// WordPress FieldSchemas contract; this only prevents schema metadata from
// becoming a raw user-facing field dump.
const _classifiedDetailPresentationKeys = <String>[
'provider_type',
'available_from',
'delivery_mode',
'brand',
'model',
'color',
'size',
'material',
'item_age',
'original_price',
'payment_mode',
];
const _realEstateDetailPresentationKeys = <String>[
'provider_type',
'company_name',
'available_from',
'bedrooms',
'floor',
'total_floors',
'condition',
'last_renovated',
'cold_rent',
'additional_costs',
'heating_costs_included',
'warm_rent',
'deposit',
'house_fee',
'commission_calculation',
'commission_total',
'commission_months',
'commission_buyer_share',
'commission_seller_share',
'commission_vat',
'commission_payer',
'energy_certificate_type',
'energy_value',
'energy_class',
'heating_type',
'energy_source',
'parking_spaces',
'parking_price',
'amenity_kitchen',
'amenity_balcony',
'amenity_terrace',
'amenity_garden',
'amenity_basement',
'amenity_attic',
'amenity_elevator',
'amenity_guest_toilet',
'amenity_air_conditioning',
'amenity_floor_heating',
'amenity_fireplace',
'amenity_furnished',
'amenity_barrier_free',
'amenity_pets_allowed',
'amenity_garage',
'amenity_carport',
'amenity_e_charging',
];
const _listingOverviewKeys = <String>{
'price_label',
'price_amount',
'price_currency',
'price_interval',
'price_type',
'status',
'contact_preference',
'offer_type',
'transaction_type',
'property_kind',
'property_rooms',
'area',
'bathrooms',
'bedrooms',
'commission_applicable',
};
class _ListingSection extends StatelessWidget {
const _ListingSection({
required this.listing,
required this.l10n,
required this.categorySlugs,
required this.fieldSchemas,
required this.fieldIcons,
required this.resource,
this.presentationKeys,
this.sectionTitle,
this.dense = false,
});
final ListingData listing;
final AppLocalizations l10n;
final Set<String> categorySlugs;
final Map<String, ContentFieldSchema> fieldSchemas;
final Map<String, AppIconConfig> fieldIcons;
final String resource;
final List<String>? presentationKeys;
final String? sectionTitle;
final bool dense;
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context).toLanguageTag();
bool show(String key) {
final schema = fieldSchemas['listing.$key'];
if (schema == null) return true;
if (schema.categoryConditions.isNotEmpty &&
categorySlugs.isNotEmpty &&
categorySlugs
.intersection(schema.categoryConditions.toSet())
.isEmpty) {
return false;
}
for (final condition in schema.conditions) {
final field = (condition['field'] as String? ?? '').split('.').last;
final value = _dependencyValue(field).trim();
switch (condition['operator']) {
case 'in':
final allowed = condition['values'] is List
? (condition['values'] as List)
.whereType<String>()
.map((entry) => entry.trim())
.toSet()
: <String>{};
if (field == 'category') {
if (categorySlugs.isNotEmpty &&
categorySlugs.intersection(allowed).isEmpty) {
return false;
}
} else if (allowed.isNotEmpty && !allowed.contains(value)) {
return false;
}
case 'equals':
if (value != (condition['value'] as String? ?? '').trim()) {
return false;
}
case 'not_empty':
if (value.isEmpty) return false;
}
}
return true;
}
// The WordPress FieldSchemas contract is the only field registry. It
// supplies availability, conditions and values; the compact presentation
// order below is only a UI concern, with a forward-compatible tail for
// new fields returned by the API.
final schemaKeys = fieldSchemas.keys
.where((key) => key.startsWith('listing.'))
.map((key) => key.substring('listing.'.length))
.toList(growable: false);
final availableKeys = schemaKeys.isNotEmpty
? schemaKeys
: listing.values.keys.toList(growable: false);
final effectivePresentationKeys =
presentationKeys ??
switch (resource) {
'classifieds' => _classifiedDetailPresentationKeys,
'real-estate' => _realEstateDetailPresentationKeys,
_ => null,
};
final preferredKeys = effectivePresentationKeys == null
? const <String>[]
: effectivePresentationKeys.where(availableKeys.contains).toList();
final knownKeys = {...preferredKeys, ..._listingOverviewKeys};
// Preserve forward compatibility with a new backend FieldSchemas field,
// but only append fields the current presentation does not already know.
// This keeps future data visible without reintroducing the old full-schema
// dump for canonical listing fields.
final orderedKeys = [
...preferredKeys,
...availableKeys.where((key) => !knownKeys.contains(key)),
];
final isJobListing = listing.values.keys.any(
(key) =>
key == 'employment_type' ||
key == 'work_mode' ||
key == 'application_url' ||
key == 'responsibilities' ||
key == 'requirements' ||
key == 'benefits',
);
final rows = <_IconDetailRow>[];
for (final key in orderedKeys) {
if (isJobListing &&
{
'employment_type',
'work_mode',
'salary_min',
'salary_max',
'application_url',
'application_email',
'responsibilities',
'requirements',
'benefits',
}.contains(key)) {
continue;
}
// The compact overview already renders price_label or the canonical
// amount. Showing the raw amount, currency, interval and type again is
// the source of the repeated "Preis / Vergütung" rows. Facts and
// commission status are represented by the overview/chip as well.
if (_listingOverviewKeys.contains(key)) {
continue;
}
if ('salary_max' == key) continue;
final isSalary = 'salary_min' == key;
final hasSalary = listing.salaryMin != null || listing.salaryMax != null;
final visible = isSalary
? hasSalary && (show('salary_min') || show('salary_max'))
: show(key);
if (!visible) continue;
final raw = isSalary
? listing.salaryMin ?? listing.salaryMax
: listing.value(key);
if (key == 'energy_source' &&
_normalizedListingValue(raw) ==
_normalizedListingValue(listing.value('heating_type'))) {
continue;
}
if (!_hasDisplayValue(raw, key: key, listing: listing)) continue;
final text = isSalary
? _salaryRange(listing, locale)
: _formatValue(key, raw, locale);
if (text.isEmpty) continue;
rows.add(
_IconDetailRow(
_fallbackIcon(key),
'${listingFieldLabel(l10n, key)}: $text',
image: fieldIcons[key],
),
);
}
final commissionFree = listing.value('commission_applicable') == 'no';
if (rows.isEmpty && !commissionFree) return const SizedBox.shrink();
final sections = <Widget>[];
if (commissionFree) {
sections.add(
Align(
alignment: Alignment.centerLeft,
child: Chip(
avatar: const Icon(Icons.verified_outlined, size: 18),
label: Text(l10n.listingValueCommissionFree),
),
),
);
}
if (rows.isNotEmpty) {
sections.add(
_IconDetailSection(
title: sectionTitle ?? l10n.detailOffer,
rows: rows,
dense: dense,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: sections,
);
}
String _dependencyValue(String key) {
return switch (key) {
'offer_type' => listing.offerType,
'provider_type' => listing.providerType,
'price_type' => listing.priceType,
'transaction_type' => listing.propertyTransactionType,
'commission_applicable' =>
listing.value('commission_applicable')?.toString() ?? '',
'commission_calculation' =>
listing.value('commission_calculation')?.toString() ?? '',
'property_rooms' => listing.value('property_rooms')?.toString() ?? '',
_ => '',
};
}
bool _hasDisplayValue(
Object? value, {
required String key,
required ListingData listing,
}) {
if (value == null) return false;
if (value is bool) return value;
if (value is String) {
final normalized = value.trim().toLowerCase();
if ((key == 'house_fee' || key == 'parking_price') &&
(double.tryParse(normalized) ?? 0) <= 0) {
return false;
}
if (key == 'parking_price' &&
(_numericListingValue(listing.value('parking_spaces')) ?? 0) <= 0) {
return false;
}
return normalized.isNotEmpty &&
normalized != 'unknown' &&
normalized != 'no';
}
if (value is num &&
(key == 'house_fee' || key == 'parking_price') &&
value <= 0) {
return false;
}
if (key == 'parking_price' &&
(_numericListingValue(listing.value('parking_spaces')) ?? 0) <= 0) {
return false;
}
return true;
}
String _formatValue(String key, Object? raw, String locale) {
if (raw == null) return '';
final schema = fieldSchemas['listing.$key'];
if (raw is String) {
final normalized = raw.trim().toLowerCase();
if (key == 'commission_applicable') {
return normalized == 'no'
? l10n.listingValueCommissionFree
: l10n.listingValueCommissionPaid;
}
if (normalized == 'unknown' || normalized == 'no') return '';
if (key == 'experience_years' && double.tryParse(raw.trim()) == 0) {
return l10n.detailNoExperienceRequired;
}
if (schema?.input == 'enum') {
return listingValueLabel(l10n, raw, fieldKey: key);
}
if (schema?.input == 'date') {
return formatDate(raw, locale, includeTime: false);
}
return raw;
}
if (raw is num) {
if (key == 'experience_years' && raw == 0) {
return l10n.detailNoExperienceRequired;
}
final unit = schema?.unit;
if (unit == 'currency') {
return formatCurrencyAmount(
raw.toDouble(),
listing.priceCurrency,
locale,
);
}
final number = formatLocalizedNumber(raw.toDouble(), locale);
return switch (unit) {
'm²' => '$number m²',
'count' => number,
'rooms' => number,
_ => number,
};
}
if (raw is bool) {
return raw ? l10n.listingValueYes : '';
}
return raw.toString();
}
IconData _fallbackIcon(String key) {
return switch (key) {
'provider_type' => Icons.person_outline,
'available_from' => Icons.event_available_outlined,
'delivery_mode' => Icons.local_shipping_outlined,
'brand' || 'model' => Icons.sell_outlined,
'color' => Icons.palette_outlined,
'size' => Icons.straighten_outlined,
'material' => Icons.layers_outlined,
'item_age' || 'year_built' || 'last_renovated' => Icons.history_outlined,
'original_price' => Icons.price_change_outlined,
'payment_mode' => Icons.payments_outlined,
'bedrooms' => Icons.king_bed_outlined,
'floor' || 'total_floors' => Icons.layers_outlined,
'condition' => Icons.home_repair_service_outlined,
'cold_rent' ||
'warm_rent' ||
'additional_costs' ||
'heating_costs_included' ||
'house_fee' => Icons.euro_outlined,
'deposit' => Icons.savings_outlined,
'commission_calculation' ||
'commission_total' ||
'commission_months' ||
'commission_buyer_share' ||
'commission_seller_share' ||
'commission_vat' ||
'commission_payer' => Icons.handshake_outlined,
'energy_certificate_type' || 'energy_class' => Icons.eco_outlined,
'energy_value' => Icons.speed_outlined,
'heating_type' || 'energy_source' => Icons.local_fire_department_outlined,
'parking_spaces' || 'parking_price' => Icons.local_parking_outlined,
'amenity_kitchen' => Icons.kitchen_outlined,
'amenity_balcony' ||
'amenity_terrace' ||
'amenity_garden' => Icons.deck_outlined,
'amenity_basement' || 'amenity_attic' => Icons.warehouse_outlined,
'amenity_elevator' => Icons.elevator_outlined,
'amenity_guest_toilet' => Icons.wc_outlined,
'amenity_air_conditioning' => Icons.ac_unit_outlined,
'amenity_floor_heating' => Icons.thermostat_outlined,
'amenity_fireplace' => Icons.fireplace_outlined,
'amenity_furnished' => Icons.chair_outlined,
'amenity_barrier_free' => Icons.accessible_outlined,
'amenity_pets_allowed' => Icons.pets_outlined,
'amenity_garage' || 'amenity_carport' => Icons.garage_outlined,
'amenity_e_charging' => Icons.ev_station_outlined,
'company_name' => Icons.business_outlined,
'department' => Icons.account_tree_outlined,
'work_hours' => Icons.schedule_outlined,
'start_date' => Icons.event_available_outlined,
'application_deadline' => Icons.event_busy_outlined,
'languages' => Icons.translate_outlined,
'education' => Icons.school_outlined,
'experience_years' => Icons.workspace_premium_outlined,
_ => Icons.sell_outlined,
};
}
}
String _normalizedListingValue(Object? value) =>
value?.toString().trim().toLowerCase() ?? '';
double? _numericListingValue(Object? value) {
if (value is num) return value.toDouble();
return double.tryParse(_normalizedListingValue(value));
}
String _salaryRange(ListingData listing, String locale) {
final min = formatCurrencyAmount(
listing.salaryMin,
listing.priceCurrency,
locale,
);
final max = formatCurrencyAmount(
listing.salaryMax,
listing.priceCurrency,
locale,
);
if (min.isEmpty) return max;
if (max.isEmpty) return min;
return '$min–$max';
}
class _IconDetailRow {
const _IconDetailRow(this.icon, this.text, {this.onTap, this.image});
final IconData icon;
final String text;
final VoidCallback? onTap;
final AppIconConfig? image;
}
class _IconDetailSection extends StatelessWidget {
const _IconDetailSection({
required this.title,
required this.rows,
this.dense = false,
});
final String title;
final List<_IconDetailRow> rows;
final bool dense;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: EdgeInsets.fromLTRB(16, dense ? 13 : 15, 16, dense ? 5 : 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: dense
? Theme.of(context).textTheme.titleSmall
: Theme.of(context).textTheme.titleMedium,
),
SizedBox(height: dense ? 6 : 8),
...rows.map(
(row) => Padding(
padding: EdgeInsets.only(bottom: dense ? 5 : 8),
child: Semantics(
button: row.onTap != null,
label: row.text,
child: InkWell(
onTap: row.onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 4,
vertical: dense ? 3 : 5,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: row.image?.url.isNotEmpty == true
? PublicMediaImage(
url: row.image!.url,
alt: row.image!.alt,
width: dense ? 19 : 22,
height: dense ? 19 : 22,
fit: BoxFit.contain,
isSvg: row.image!.isSvg,
emptyIcon: row.icon,
errorIcon: row.icon,
iconSize: dense ? 18 : 20,
includeSemantics: false,
)
: Icon(
row.icon,
size: dense ? 18 : 20,
color: AppTheme.palette(context).brand,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
row.text,
style: TextStyle(
color: AppTheme.palette(context).muted,
fontSize: dense ? 13.5 : null,
height: dense ? 1.28 : 1.45,
),
),
),
if (row.onTap != null)
Padding(
padding: EdgeInsets.only(left: 8, top: 1),
child: Icon(
Icons.chevron_right,
size: dense ? 18 : 20,
),
),
],
),
),
),
),
),
),
],
),
),
);
}
}

Architecture

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

flutter-appmodule
wordpress-pluginmodule

Technology stack

DartPHPJavaScriptARBLOCKPBXPROJFlutter / Dart
Dart56.1%
PHP29.4%
JavaScript4.0%
ARB2.9%
LOCK1.7%
PBXPROJ1.7%
CSS1.5%
Markdown0.4%