import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/config/app_config.dart';
import '../../core/location/location_controller.dart';
import '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
import '../../models/content_models.dart';
import '../../repositories/discovery_repository.dart';
import '../../shared/discovery/service_cards.dart';
import '../../shared/media/public_media_image.dart';
import '../../shared/states/app_state_widgets.dart';
class HomePage extends StatefulWidget {
const HomePage({
super.key,
required this.repository,
required this.brand,
required this.hero,
required this.resources,
this.locationController,
this.homeSections = HomeSectionConfig.defaults,
required this.onOpenDiscovery,
required this.onOpenResource,
this.onOpenQuickItem,
required this.onOpenItem,
required this.onOpenCommunity,
});
final DiscoveryRepository repository;
final BrandConfig brand;
final HomeHeroConfig hero;
final List<ResourceDefinition> resources;
final LocationController? locationController;
final List<HomeSectionConfig> homeSections;
final VoidCallback onOpenDiscovery;
final ValueChanged<String> onOpenResource;
final ValueChanged<HomeQuickAccessItem>? onOpenQuickItem;
final ValueChanged<DiscoveryItem> onOpenItem;
final VoidCallback onOpenCommunity;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
late Future<_HomeData> _future;
double? _lastLoadedLatitude;
double? _lastLoadedLongitude;
@override
void initState() {
super.initState();
widget.locationController?.addListener(_handleLocationChanged);
_future = _load();
}
@override
void dispose() {
widget.locationController?.removeListener(_handleLocationChanged);
super.dispose();
}
void _handleLocationChanged() {
if (!mounted) return;
final coordinate = widget.locationController?.coordinate;
if (_lastLoadedLatitude == coordinate?.latitude &&
_lastLoadedLongitude == coordinate?.longitude) {
return;
}
unawaited(_refresh());
}
Future<void> _refresh() async {
final future = _load();
setState(() => _future = future);
await future;
}
Future<_HomeData> _load() async {
final enabled = widget.homeSections
.where((section) => section.enabled)
.map((section) => section.id)
.toSet();
final sections = await Future.wait([
enabled.contains('events')
? _discover(const ['events'], sort: 'auto', perPage: 5)
: Future.value(const _HomeSection(items: [])),
enabled.contains('news')
? _discover(
const ['news'],
sort: 'newest',
perPage: 5,
// News are editorial content. They must never inherit the
// discovery shell's location/radius query, even on Home.
radiusKm: null,
)
: Future.value(const _HomeSection(items: [])),
(enabled.contains('nearby') || enabled.contains('places'))
? _discover(
const ['places'],
sort: 'auto',
perPage: 6,
useLocation: true,
)
: Future.value(const _HomeSection(items: [])),
enabled.contains('listings')
? _discover(
const ['classifieds', 'jobs', 'real-estate'],
sort: 'newest',
perPage: 6,
)
: Future.value(const _HomeSection(items: [])),
]);
return _HomeData(
events: sections[0],
news: sections[1],
places: sections[2],
listings: sections[3],
);
}
Future<_HomeSection> _discover(
List<String> types, {
required String sort,
required int perPage,
double? radiusKm = 10,
bool useLocation = false,
}) async {
try {
final coordinate = useLocation
? widget.locationController?.coordinate
: null;
_lastLoadedLatitude = coordinate?.latitude;
_lastLoadedLongitude = coordinate?.longitude;
final response = await widget.repository.discover(
types: types,
perPage: perPage,
sort: sort,
radiusKm: radiusKm,
latitude: coordinate?.latitude,
longitude: coordinate?.longitude,
);
return _HomeSection(
items: response.items,
categoryNames: {
for (final option in response.meta.categories)
option.slug: option.name,
},
);
} catch (_) {
return const _HomeSection(items: [], failed: true);
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<_HomeData>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting &&
!snapshot.hasData) {
return _scrollShell(
SliverToBoxAdapter(
child: Column(
children: [
const LinearProgressIndicator(minHeight: 2),
..._content(context, const _HomeData.empty()),
],
),
),
);
}
if (snapshot.hasError || !snapshot.hasData) {
return _scrollShell(
SliverFillRemaining(
hasScrollBody: false,
child: ErrorState(onRetry: _refresh),
),
);
}
final data = snapshot.data!;
if (data.allFailed) {
return _scrollShell(
SliverFillRemaining(
hasScrollBody: false,
child: ErrorState(onRetry: _refresh),
),
);
}
return _scrollShell(
SliverToBoxAdapter(child: Column(children: _content(context, data))),
);
},
);
}
Widget _scrollShell(Widget sliver) {
return ColoredBox(
color: AppTheme.palette(context).surface,
child: RefreshIndicator(
onRefresh: _refresh,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 28),
sliver: sliver,
),
],
),
),
);
}
List<Widget> _content(BuildContext context, _HomeData data) {
final l10n = AppLocalizations.of(context);
final content = <Widget>[];
for (final section in widget.homeSections) {
if (!section.enabled) continue;
final block = _sectionBlock(context, section, data, l10n);
if (block == null) continue;
if (content.isNotEmpty) {
content.add(const SizedBox(height: 26));
}
content.add(block);
}
return content;
}
Widget? _sectionBlock(
BuildContext context,
HomeSectionConfig section,
_HomeData data,
AppLocalizations l10n,
) {
final locale = Localizations.localeOf(context).toLanguageTag();
final title = section.text(locale, _sectionFallbackTitle(section.id, l10n));
final sectionTitle = _SectionTitle(
title: title,
icon: _sectionIcon(section.icon),
iconUrl: section.iconUrl,
);
switch (section.id) {
case 'hero':
return _HeroCard(
brand: widget.brand,
hero: widget.hero,
onOpenDiscovery: widget.onOpenDiscovery,
);
case 'quick_access':
final quickItems = _quickItems(section, l10n);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionTitle,
const SizedBox(height: 10),
_QuickAccessSlider(
items: quickItems,
labelBuilder: (item) =>
item.text(locale, _resourceLabel(l10n, item.route)),
onOpen: (item) {
final callback = widget.onOpenQuickItem;
if (callback != null) {
callback(item);
} else {
widget.onOpenResource(item.route);
}
},
),
],
);
case 'events':
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionTitle,
const SizedBox(height: 10),
_HorizontalItems(
items: data.events.items,
categoryNames: data.events.categoryNames,
resourceLabelBuilder: (item) =>
_resourceLabel(l10n, item.resource),
emptyLabel: l10n.homeEmptyBody,
onOpenItem: widget.onOpenItem,
),
],
);
case 'news':
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionTitle,
const SizedBox(height: 10),
_HorizontalItems(
items: data.news.items,
categoryNames: data.news.categoryNames,
resourceLabelBuilder: (item) =>
_resourceLabel(l10n, item.resource),
emptyLabel: l10n.homeEmptyBody,
onOpenItem: widget.onOpenItem,
),
],
);
case 'nearby':
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
title: title,
icon: _sectionIcon(section.icon),
iconUrl: section.iconUrl,
trailing: l10n.homeNearbyAction,
),
const SizedBox(height: 10),
_HorizontalItems(
items: data.places.items,
categoryNames: data.places.categoryNames,
resourceLabelBuilder: (item) =>
_resourceLabel(l10n, item.resource),
emptyLabel: l10n.homeNearbyBody,
onOpenItem: widget.onOpenItem,
),
],
);
case 'listings':
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionTitle,
const SizedBox(height: 10),
_ListingGrid(
items: data.listings.items,
resourceLabelBuilder: (item) =>
_resourceLabel(l10n, item.resource),
emptyLabel: l10n.homeEmptyBody,
onOpenItem: widget.onOpenItem,
),
],
);
case 'community':
return _CommunityCard(
title: title,
icon: _sectionIcon(section.icon),
iconUrl: section.iconUrl,
onOpen: widget.onOpenCommunity,
);
default:
return null;
}
}
String _sectionFallbackTitle(String id, AppLocalizations l10n) {
return switch (id) {
'quick_access' => l10n.homeQuickAccessTitle,
'events' => l10n.homeEventsTitle,
'news' => l10n.homeNewsTitle,
'nearby' => l10n.homeNearbyTitle,
'listings' => l10n.homeListingsTitle,
'community' => l10n.homeCommunityTitle,
_ => '',
};
}
IconData _sectionIcon(String key) {
return switch (key) {
'bolt' => Icons.bolt_outlined,
'event' => Icons.event_outlined,
'auto_stories' => Icons.auto_stories_outlined,
'near_me' => Icons.near_me_outlined,
'new_releases' => Icons.new_releases_outlined,
'people' => Icons.people_alt_outlined,
'place' => Icons.place_outlined,
'map' => Icons.map_outlined,
'sell' => Icons.sell_outlined,
'work' => Icons.work_outline,
'home_work' => Icons.home_work_outlined,
'article' => Icons.article_outlined,
'menu_book' => Icons.menu_book_outlined,
_ => Icons.grid_view_outlined,
};
}
List<ResourceDefinition> _quickResources() {
final byId = <String, ResourceDefinition>{
for (final resource in widget.resources) resource.id: resource,
};
const ids = ['places', 'events', 'classifieds', 'jobs', 'real-estate'];
final resources = <ResourceDefinition>[];
for (final id in ids) {
final resource = byId[id];
if (resource != null) resources.add(resource);
}
if (resources.isNotEmpty) return resources;
return const [
ResourceDefinition(id: 'places', label: '', endpoint: '', geo: true),
ResourceDefinition(id: 'events', label: '', endpoint: '', geo: true),
ResourceDefinition(id: 'classifieds', label: '', endpoint: '', geo: true),
ResourceDefinition(id: 'jobs', label: '', endpoint: '', geo: true),
ResourceDefinition(id: 'real-estate', label: '', endpoint: '', geo: true),
];
}
List<HomeQuickAccessItem> _quickItems(
HomeSectionConfig section,
AppLocalizations l10n,
) {
if (section.quickItems.isNotEmpty) return section.quickItems;
// Compatibility for an older backend response. These are resource
// definitions, not invented category terms; visible category choices
// still come exclusively from WordPress.
return _quickResources()
.map(
(resource) => HomeQuickAccessItem(
route: resource.id,
category: '',
label: {'*': _resourceLabel(l10n, resource.id)},
imageUrl: resource.iconUrl,
imageAlt: resource.iconAlt,
),
)
.toList(growable: false);
}
String _resourceLabel(AppLocalizations l10n, String id) {
return switch (id) {
'places' => l10n.navPlaces,
'events' => l10n.navEvents,
'classifieds' => l10n.moduleClassifieds,
'jobs' => l10n.moduleJobs,
'real-estate' => l10n.moduleRealEstate,
_ => l10n.contentTypeOther,
};
}
}
class _HomeData {
const _HomeData.empty()
: events = const _HomeSection(items: []),
news = const _HomeSection(items: []),
places = const _HomeSection(items: []),
listings = const _HomeSection(items: []);
const _HomeData({
required this.events,
required this.news,
required this.places,
required this.listings,
});
final _HomeSection events;
final _HomeSection news;
final _HomeSection places;
final _HomeSection listings;
bool get allFailed =>
events.failed && news.failed && places.failed && listings.failed;
}
class _HomeSection {
const _HomeSection({
required this.items,
this.categoryNames = const {},
this.failed = false,
});
final List<DiscoveryItem> items;
final Map<String, String> categoryNames;
final bool failed;
}
class _HeroCard extends StatelessWidget {
const _HeroCard({
required this.brand,
required this.hero,
required this.onOpenDiscovery,
});
final BrandConfig brand;
final HomeHeroConfig hero;
final VoidCallback onOpenDiscovery;
@override
Widget build(BuildContext context) {
final palette = AppTheme.palette(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final title = hero.text(hero.title, locale, '');
final body = hero.text(hero.body, locale, '');
final primaryLabel = hero.text(hero.primaryLabel, locale, '');
final secondaryLabel = hero.text(hero.secondaryLabel, locale, '');
final imageUrl = hero.imageUrl.isNotEmpty
? hero.imageUrl
: brand.heroImageUrl;
final imageAlt = hero.imageAlt.isNotEmpty
? hero.imageAlt
: brand.heroImageAlt;
final logoUrl = hero.logoUrl.isNotEmpty ? hero.logoUrl : brand.logoLightUrl;
final logoAlt = hero.logoAlt.isNotEmpty ? hero.logoAlt : brand.logoLightAlt;
final eyebrow = hero.text(hero.eyebrow, locale, '');
final hasImage = imageUrl.trim().isNotEmpty;
final imageFit = hero.imageFit == 'contain' ? BoxFit.contain : BoxFit.cover;
final imageAspectRatio = hero.imageWidth > 0 && hero.imageHeight > 0
? hero.imageWidth / hero.imageHeight
: null;
final imageHeightMode =
hero.heightMode == 'image' && imageAspectRatio != null;
final content = _heroContent(
context,
title: title,
body: body,
eyebrow: eyebrow,
primaryLabel: primaryLabel,
secondaryLabel: secondaryLabel,
primaryUrl: hero.text(hero.primaryUrl, locale, ''),
secondaryUrl: hero.text(hero.secondaryUrl, locale, ''),
logoUrl: logoUrl,
logoAlt: logoAlt,
palette: palette,
);
final stack = _heroStack(
context,
content: content,
hasImage: hasImage,
imageUrl: imageUrl,
imageAlt: imageAlt,
imageFit: imageFit,
imageHeightMode: imageHeightMode,
);
return ClipRRect(
borderRadius: BorderRadius.circular(26),
child: imageHeightMode
? AspectRatio(aspectRatio: imageAspectRatio, child: stack)
: IntrinsicHeight(child: stack),
);
}
Widget _heroStack(
BuildContext context, {
required Widget content,
required bool hasImage,
required String imageUrl,
required String imageAlt,
required BoxFit imageFit,
required bool imageHeightMode,
}) {
final palette = AppTheme.palette(context);
final background = Positioned.fill(
child: hasImage
? ColoredBox(
color: palette.secondary,
child: PublicMediaImage(
url: imageUrl,
alt: imageAlt,
fit: imageFit,
),
)
: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
palette.secondary,
palette.brand.withValues(alpha: .86),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
);
final overlay = Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
palette.ink.withValues(alpha: .88),
palette.ink.withValues(alpha: .20),
palette.ink.withValues(alpha: .72),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
);
final contentLayer = imageHeightMode
? SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: content,
)
: Padding(padding: const EdgeInsets.all(24), child: content);
return Stack(children: [background, overlay, contentLayer]);
}
Widget _heroContent(
BuildContext context, {
required String title,
required String body,
required String eyebrow,
required String primaryLabel,
required String secondaryLabel,
required String primaryUrl,
required String secondaryUrl,
required String logoUrl,
required String logoAlt,
required MeinWeimarPalette palette,
}) {
final titleColor = _parseHeroColor(hero.colors.title, Colors.white);
final bodyColor = _parseHeroColor(
hero.colors.body,
Colors.white.withValues(alpha: .78),
);
final eyebrowColor = _parseHeroColor(hero.colors.eyebrow, palette.accent);
final buttonColor = _parseHeroColor(hero.colors.button, palette.brand);
final buttonTextColor = _parseHeroColor(
hero.colors.buttonText,
Colors.white,
);
final secondaryButtonColor = _parseHeroColor(
hero.colors.secondaryButton,
palette.secondary,
);
final secondaryButtonTextColor = _parseHeroColor(
hero.colors.secondaryButtonText,
Colors.white,
);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (logoUrl.isNotEmpty)
SizedBox(
width: 132,
height: 42,
child: Image.network(
logoUrl,
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
semanticLabel: logoAlt,
),
),
if (eyebrow.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
eyebrow,
style: TextStyle(
color: eyebrowColor,
fontWeight: FontWeight.w800,
letterSpacing: 1.1,
),
),
],
if (title.isNotEmpty || body.isNotEmpty || primaryLabel.isNotEmpty)
const SizedBox(height: 16),
if (title.isNotEmpty)
Text(
title,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: titleColor,
fontWeight: FontWeight.w800,
),
),
if (title.isNotEmpty && body.isNotEmpty) const SizedBox(height: 6),
if (body.isNotEmpty)
Text(body, style: TextStyle(color: bodyColor, height: 1.35)),
if (primaryLabel.isNotEmpty) ...[
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _openLink(context, primaryUrl),
style: FilledButton.styleFrom(
backgroundColor: buttonColor,
foregroundColor: buttonTextColor,
),
icon: const Icon(Icons.search_outlined),
label: Text(primaryLabel),
),
],
if (secondaryLabel.isNotEmpty) ...[
const SizedBox(height: 8),
TextButton(
onPressed: () => _openLink(context, secondaryUrl),
style: TextButton.styleFrom(
backgroundColor: secondaryButtonColor,
foregroundColor: secondaryButtonTextColor,
),
child: Text(secondaryLabel),
),
],
],
);
}
Color _parseHeroColor(String value, Color fallback) {
final normalized = value.trim().replaceFirst('#', '');
if (normalized.length != 6 && normalized.length != 8) return fallback;
final parsed = int.tryParse(normalized, radix: 16);
if (parsed == null) return fallback;
return normalized.length == 8 ? Color(parsed) : Color(0xFF000000 | parsed);
}
Future<void> _openLink(BuildContext context, String url) async {
final normalized = url.trim();
if (normalized.isEmpty || normalized.startsWith('#')) {
onOpenDiscovery();
return;
}
final uri = Uri.tryParse(normalized);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.platformDefault);
return;
}
onOpenDiscovery();
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({
required this.title,
required this.icon,
this.iconUrl = '',
this.trailing,
});
final String title;
final IconData icon;
final String iconUrl;
final String? trailing;
@override
Widget build(BuildContext context) {
final titleText = Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800),
);
final titleRow = Row(
children: [
iconUrl.isEmpty
? Icon(icon, color: AppTheme.palette(context).brand)
: PublicMediaImage(
url: iconUrl,
width: 24,
height: 24,
fit: BoxFit.contain,
alt: '',
emptyIcon: icon,
errorIcon: icon,
iconSize: 22,
iconColor: AppTheme.palette(context).brand,
backgroundColor: Colors.transparent,
borderColor: Colors.transparent,
),
const SizedBox(width: 8),
Expanded(child: titleText),
],
);
if (trailing == null) return titleRow;
final trailingText = Text(
trailing!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).brand,
fontWeight: FontWeight.w700,
),
);
if (MediaQuery.sizeOf(context).width < 420) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
titleRow,
Padding(
padding: const EdgeInsets.only(left: 32, top: 3),
child: trailingText,
),
],
);
}
return Row(
children: [
Expanded(child: titleRow),
const SizedBox(width: 8),
Flexible(child: trailingText),
],
);
}
}
class _QuickAccessSlider extends StatelessWidget {
const _QuickAccessSlider({
required this.items,
required this.labelBuilder,
required this.onOpen,
});
final List<HomeQuickAccessItem> items;
final String Function(HomeQuickAccessItem item) labelBuilder;
final ValueChanged<HomeQuickAccessItem> onOpen;
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
final itemWidth = width >= 900 ? 120.0 : 104.0;
return SizedBox(
height: 145,
child: ListView.separated(
scrollDirection: Axis.horizontal,
dragStartBehavior: DragStartBehavior.down,
padding: const EdgeInsets.only(right: 4),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final item = items[index];
return SizedBox(
width: itemWidth,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onOpen(item),
child: Column(
children: [
SizedBox(
width: 88,
height: 88,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: AppTheme.palette(
context,
).muted.withValues(alpha: .24),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(1),
child: ClipOval(
child: PublicMediaImage(
url: item.imageUrl,
alt: item.imageAlt,
width: 86,
height: 86,
fit: BoxFit.cover,
backgroundColor: AppTheme.palette(
context,
).brandSurface,
borderColor: Colors.transparent,
iconColor: AppTheme.palette(context).brand,
emptyIcon: _resourceIcon(item.route),
errorIcon: _resourceIcon(item.route),
fallbackBuilder: (_) => ColoredBox(
color: AppTheme.palette(context).brandSurface,
child: Center(
child: Icon(
_resourceIcon(item.route),
size: 34,
color: AppTheme.palette(context).brand,
),
),
),
),
),
),
),
),
const SizedBox(height: 7),
Text(
labelBuilder(item),
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
height: 1.1,
fontWeight: FontWeight.w700,
),
),
],
),
),
);
},
),
);
}
IconData _resourceIcon(String id) {
return switch (id) {
'places' => Icons.place_outlined,
'events' => Icons.event_outlined,
'services' => Icons.account_balance_outlined,
'classifieds' => Icons.sell_outlined,
'jobs' => Icons.work_outline,
'real-estate' => Icons.home_work_outlined,
_ => Icons.grid_view_outlined,
};
}
}
class _HorizontalItems extends StatelessWidget {
const _HorizontalItems({
required this.items,
required this.categoryNames,
required this.resourceLabelBuilder,
required this.emptyLabel,
required this.onOpenItem,
});
final List<DiscoveryItem> items;
final Map<String, String> categoryNames;
final String Function(DiscoveryItem item) resourceLabelBuilder;
final String emptyLabel;
final ValueChanged<DiscoveryItem> onOpenItem;
@override
Widget build(BuildContext context) {
if (items.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Text(
emptyLabel,
style: TextStyle(color: AppTheme.palette(context).muted),
),
),
);
}
// Let the tallest card determine the viewport height. A fixed height
// clips localized two-line titles, addresses or the shared card shadow.
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SingleChildScrollView(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
dragStartBehavior: DragStartBehavior.down,
padding: const EdgeInsets.only(right: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var index = 0; index < items.length; index++)
Padding(
padding: EdgeInsets.only(
right: index == items.length - 1 ? 0 : 12,
),
child: HomeDiscoveryCard(
item: items[index],
resourceLabel: resourceLabelBuilder(items[index]),
categoryLabel: _categoryLabel(items[index]),
onTap: () => onOpenItem(items[index]),
),
),
],
),
),
);
}
String _categoryLabel(DiscoveryItem item) {
for (final slugs in item.terms.values) {
for (final slug in slugs) {
final label = categoryNames[slug];
if (label != null && label.trim().isNotEmpty) return label;
}
}
return '';
}
}
class _ListingGrid extends StatelessWidget {
const _ListingGrid({
required this.items,
required this.resourceLabelBuilder,
required this.emptyLabel,
required this.onOpenItem,
});
final List<DiscoveryItem> items;
final String Function(DiscoveryItem item) resourceLabelBuilder;
final String emptyLabel;
final ValueChanged<DiscoveryItem> onOpenItem;
@override
Widget build(BuildContext context) {
if (items.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Text(
emptyLabel,
style: TextStyle(color: AppTheme.palette(context).muted),
),
),
);
}
final columns = MediaQuery.sizeOf(context).width >= 900 ? 3 : 1;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: items.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: columns == 1 ? 3.4 : 1.45,
),
itemBuilder: (context, index) {
final item = items[index];
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => onOpenItem(item),
child: Row(
children: [
SizedBox(
width: columns == 1 ? 118 : 100,
height: double.infinity,
child: PublicMediaImage(
url: item.imageUrl,
alt: item.title,
fit: BoxFit.cover,
backgroundColor: AppTheme.palette(context).brandSurface,
iconColor: AppTheme.palette(context).brand,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
resourceLabelBuilder(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).brand,
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: .35,
),
),
const SizedBox(height: 4),
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w800),
),
if (item.address.formatted.isNotEmpty) ...[
const SizedBox(height: 5),
Text(
item.address.formatted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).muted,
fontSize: 12,
),
),
],
],
),
),
),
],
),
),
);
},
);
}
}
class _CommunityCard extends StatelessWidget {
const _CommunityCard({
required this.title,
required this.icon,
required this.iconUrl,
required this.onOpen,
});
final String title;
final IconData icon;
final String iconUrl;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Card(
color: AppTheme.palette(context).ink,
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
iconUrl.isEmpty
? Icon(icon, color: AppTheme.palette(context).brand, size: 34)
: PublicMediaImage(
url: iconUrl,
width: 34,
height: 34,
fit: BoxFit.contain,
alt: '',
emptyIcon: icon,
errorIcon: icon,
iconSize: 30,
iconColor: AppTheme.palette(context).brand,
backgroundColor: Colors.transparent,
borderColor: Colors.transparent,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 19,
),
),
const SizedBox(height: 6),
Text(
l10n.homeCommunityBody,
style: const TextStyle(color: Colors.white70, height: 1.35),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: onOpen,
child: Text(l10n.homeCommunityAction),
),
],
),
),
],
),
),
);
}
}
