import 'package:flutter/material.dart';
import '../../core/content/listing_labels.dart';
import '../../core/formatting/localized_formatters.dart';
import '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
import '../../models/content_models.dart';
import '../media/public_media_image.dart';
/// Shared result-card primitives for the service modules.
///
/// The cards deliberately receive already resolved API values. They never
/// create category names or infer a second content schema in the client.
class ClassifiedDiscoveryCard extends StatelessWidget {
const ClassifiedDiscoveryCard({
super.key,
required this.item,
required this.categoryLabel,
required this.onTap,
required this.isFavorite,
required this.onFavorite,
});
final DiscoveryItem item;
final String categoryLabel;
final VoidCallback onTap;
final bool isFavorite;
final VoidCallback onFavorite;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final listing = item.listing;
final locale = Localizations.localeOf(context).toLanguageTag();
final price = _listingPrice(listing, locale);
final condition = listing?.classifiedCondition.trim() ?? '';
final meta = <String>[
if (condition.isNotEmpty) listingValueLabel(l10n, condition),
if (categoryLabel.isNotEmpty) categoryLabel,
];
return _ServiceCardFrame(
item: item,
onTap: onTap,
favorite: _FavoriteButton(selected: isFavorite, onPressed: onFavorite),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: _cardTitleStyle,
),
if (price.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
price,
style: TextStyle(
color: AppTheme.palette(context).brand,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
],
if (item.address.formatted.isNotEmpty)
_ServiceMetaLine(
icon: Icons.location_on_outlined,
text: item.address.formatted,
),
if (meta.isNotEmpty)
_ServiceMetaLine(icon: Icons.sell_outlined, text: meta.join(' · ')),
],
),
);
}
}
class RealEstateDiscoveryCard extends StatelessWidget {
const RealEstateDiscoveryCard({
super.key,
required this.item,
required this.categoryLabel,
required this.onTap,
required this.isFavorite,
required this.onFavorite,
});
final DiscoveryItem item;
final String categoryLabel;
final VoidCallback onTap;
final bool isFavorite;
final VoidCallback onFavorite;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final listing = item.listing;
final locale = Localizations.localeOf(context).toLanguageTag();
final transaction = listing?.propertyTransactionType.trim() ?? '';
final price = _listingPrice(listing, locale);
final facts = <String>[
if (listing?.propertyRooms != null)
'${formatLocalizedNumber(listing!.propertyRooms!, locale)} ${l10n.detailRooms}',
if (listing?.propertyArea != null)
'${formatLocalizedNumber(listing!.propertyArea!, locale)} m²',
if (categoryLabel.isNotEmpty) categoryLabel,
];
return _ServiceCardFrame(
item: item,
onTap: onTap,
favorite: _FavoriteButton(selected: isFavorite, onPressed: onFavorite),
badge: transaction.isEmpty
? null
: ServiceCardBadge(label: listingValueLabel(l10n, transaction)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: _cardTitleStyle,
),
if (price.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
price,
style: TextStyle(
color: AppTheme.palette(context).brand,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
],
if (item.address.formatted.isNotEmpty)
_ServiceMetaLine(
icon: Icons.location_on_outlined,
text: item.address.formatted,
),
if (facts.isNotEmpty)
_ServiceMetaLine(
icon: Icons.home_work_outlined,
text: facts.join(' · '),
),
],
),
);
}
}
class NewsDiscoveryCard extends StatelessWidget {
const NewsDiscoveryCard({
super.key,
required this.item,
required this.categoryLabel,
required this.onTap,
required this.isFavorite,
required this.onFavorite,
});
final DiscoveryItem item;
final String categoryLabel;
final VoidCallback onTap;
final bool isFavorite;
final VoidCallback onFavorite;
@override
Widget build(BuildContext context) {
return _ServiceCardFrame(
item: item,
onTap: onTap,
favorite: _FavoriteButton(
selected: isFavorite,
onPressed: onFavorite,
icon: Icons.bookmark_border,
selectedIcon: Icons.bookmark,
),
badge: categoryLabel.isEmpty
? null
: ServiceCardBadge(label: categoryLabel),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: _cardTitleStyle,
),
if (item.excerpt.isNotEmpty) ...[
const SizedBox(height: 5),
Text(
item.excerpt,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).muted,
fontSize: 12,
height: 1.25,
),
),
],
],
),
);
}
}
/// Wide, media-first cards used by the Home horizontal sections.
///
/// Home must use the same API-backed facts as the dedicated discovery cards:
/// resource labels identify mixed content, taxonomy labels come from the
/// response meta, and dates/distances are rendered only when the REST payload
/// contains them.
class HomeDiscoveryCard extends StatelessWidget {
const HomeDiscoveryCard({
super.key,
required this.item,
required this.resourceLabel,
required this.categoryLabel,
required this.onTap,
});
final DiscoveryItem item;
final String resourceLabel;
final String categoryLabel;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context).toLanguageTag();
final eventDate = item.event == null
? null
: DateTime.tryParse(item.event!.start);
final showPublishedDate =
(item.resource == 'news' || item.resource == 'guides') &&
item.publishedAt.trim().isNotEmpty;
final dateRaw = eventDate == null
? (showPublishedDate ? item.publishedAt : '')
: item.event!.start;
final dateLabel = dateRaw.isEmpty ? '' : formatDate(dateRaw, locale);
final distanceLabel = formatDistanceLabel(item.distanceKm, locale);
final location = item.address.formatted;
final listingPrice = _listingPrice(item.listing, locale);
final image = item.imageUrl.trim().isNotEmpty
? item.imageUrl.trim()
: (item.media.isNotEmpty ? item.media.first.url : '');
return SizedBox(
width: 248,
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 128,
width: double.infinity,
child: PublicMediaImage(
url: image,
alt: item.title,
fit: BoxFit.cover,
backgroundColor: AppTheme.palette(context).brandSurface,
iconColor: AppTheme.palette(context).brand,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 4),
child: Text(
resourceLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).brand,
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: .35,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
if (categoryLabel.isNotEmpty)
_HomeMetaLine(icon: Icons.sell_outlined, text: categoryLabel),
if (listingPrice.isNotEmpty)
_HomeMetaLine(icon: Icons.euro_outlined, text: listingPrice),
if (dateLabel.isNotEmpty)
_HomeMetaLine(
icon: eventDate == null
? Icons.calendar_today_outlined
: Icons.schedule_outlined,
text: dateLabel,
),
if (location.isNotEmpty)
_HomeMetaLine(icon: Icons.location_on_outlined, text: location),
if (distanceLabel.isNotEmpty)
_HomeMetaLine(
icon: Icons.near_me_outlined,
text: distanceLabel,
bottom: 12,
)
else
const SizedBox(height: 12),
],
),
),
),
);
}
}
class _HomeMetaLine extends StatelessWidget {
const _HomeMetaLine({
required this.icon,
required this.text,
this.bottom = 0,
});
final IconData icon;
final String text;
final double bottom;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(14, 6, 14, bottom),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 14, color: AppTheme.palette(context).muted),
const SizedBox(width: 5),
Expanded(
child: Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).muted,
fontSize: 12,
height: 1.2,
),
),
),
],
),
);
}
}
class _ServiceCardFrame extends StatelessWidget {
const _ServiceCardFrame({
required this.item,
required this.onTap,
required this.favorite,
required this.child,
this.badge,
});
final DiscoveryItem item;
final VoidCallback onTap;
final Widget favorite;
final Widget child;
final Widget? badge;
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 118,
height: 104,
child: Stack(
fit: StackFit.expand,
children: [
PublicMediaImage(
url: item.imageUrl,
alt: item.title,
fit: BoxFit.cover,
emptyIcon: Icons.photo_outlined,
includeSemantics: false,
),
if (badge != null)
Positioned(left: 6, top: 6, child: badge!),
],
),
),
const SizedBox(width: 10),
Expanded(child: child),
const SizedBox(width: 2),
favorite,
],
),
),
),
);
}
}
class _FavoriteButton extends StatelessWidget {
const _FavoriteButton({
required this.selected,
required this.onPressed,
this.icon = Icons.favorite_border,
this.selectedIcon = Icons.favorite,
});
final bool selected;
final VoidCallback onPressed;
final IconData icon;
final IconData selectedIcon;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onPressed,
tooltip: AppLocalizations.of(context).save,
icon: Icon(selected ? selectedIcon : icon),
color: selected
? AppTheme.palette(context).brand
: AppTheme.palette(context).muted,
iconSize: 21,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 32, height: 32),
visualDensity: VisualDensity.compact,
);
}
}
class ServiceCardBadge extends StatelessWidget {
const ServiceCardBadge({super.key, required this.label});
final String label;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .94),
borderRadius: BorderRadius.circular(7),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).brand,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
);
}
}
class _ServiceMetaLine extends StatelessWidget {
const _ServiceMetaLine({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 15, color: AppTheme.palette(context).muted),
const SizedBox(width: 5),
Expanded(
child: Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).muted,
fontSize: 12,
height: 1.2,
),
),
),
],
),
);
}
}
TextStyle get _cardTitleStyle =>
const TextStyle(fontSize: 15.5, height: 1.18, fontWeight: FontWeight.w800);
String _listingPrice(ListingData? listing, String locale) {
if (listing == null) return '';
if (listing.priceLabel.trim().isNotEmpty) return listing.priceLabel.trim();
if (listing.priceAmount != null) {
return formatCurrencyAmount(
listing.priceAmount,
listing.priceCurrency,
locale,
);
}
return '';
}
