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,
),
),
],
),
),
),
),
),
),
],
),
),
);
}
}
