import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../core/formatting/localized_formatters.dart';
import '../../core/config/app_config.dart';
import '../../core/content/listing_labels.dart';
import '../../core/content/navigation_labels.dart';
import '../../core/errors/api_error_code.dart';
import '../../core/location/location_controller.dart';
import '../../core/location/location_service.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 '../../repositories/geo_repository.dart';
import '../../shared/map/map_surface.dart';
import '../../shared/map/map_policy.dart';
import '../../shared/discovery/category_navigation.dart';
import '../../shared/discovery/discovery_search_field.dart';
import '../../shared/discovery/radius_filter_control.dart';
import '../../shared/discovery/service_cards.dart';
import '../../core/navigation/canonical_module_routes.dart';
import '../../shared/media/public_media_image.dart';
import '../../shared/states/app_state_widgets.dart';
import '../detail/content_detail_page.dart';
class DiscoveryPage extends StatefulWidget {
const DiscoveryPage({
super.key,
required this.repository,
required this.titleBuilder,
this.types = const ['places', 'events'],
this.showMapInitially = false,
this.allowMap = true,
this.showTitle = true,
this.initialFocusItem,
this.initialSelectedAddress,
this.initialUserLatitude,
this.initialUserLongitude,
this.initialCategory = '',
this.locationService,
this.locationController,
this.geoRepository,
this.geoConfig,
this.imageFallbackUrl = '',
this.fallbackHero,
this.imageSuggestionRepository,
this.accountRepository,
this.categorySchema,
this.fieldSchemas = const {},
this.fieldIcons = const {},
this.surfaceHero,
});
final DiscoveryRepository repository;
final List<String> types;
final bool showMapInitially;
final bool allowMap;
final bool showTitle;
final DiscoveryItem? initialFocusItem;
final GeoSearchResult? initialSelectedAddress;
final double? initialUserLatitude;
final double? initialUserLongitude;
final String initialCategory;
final LocationService? locationService;
final LocationController? locationController;
final GeoRepository? geoRepository;
final GeoConfig? geoConfig;
final String imageFallbackUrl;
final SurfaceHeroConfig? fallbackHero;
final ImageSuggestionRepository? imageSuggestionRepository;
final AccountRepository? accountRepository;
final ContentCategorySchema? categorySchema;
final Map<String, ContentFieldSchema> fieldSchemas;
final Map<String, AppIconConfig> fieldIcons;
final SurfaceHeroConfig? surfaceHero;
final String Function(AppLocalizations) titleBuilder;
@override
State<DiscoveryPage> createState() => _DiscoveryPageState();
}
class _DiscoveryPageState extends State<DiscoveryPage> {
final _searchController = TextEditingController();
final _addressController = TextEditingController();
Timer? _searchDebounce;
Timer? _addressDebounce;
late final LocationService _locationService;
late bool _showMap = widget.showMapInitially;
late double _radiusKm;
String _sort = 'auto';
bool _loading = true;
bool _loadingMore = false;
Object? _error;
ApiErrorCode? _errorCode;
ApiErrorCode? _loadMoreErrorCode;
List<DiscoveryItem> _items = const [];
DiscoveryMeta? _meta;
List<CategoryOption> _categoryOptions = const [];
double? _gpsLatitude;
double? _gpsLongitude;
LocationAccess? _locationAccess;
List<GeoSearchResult> _addressResults = const [];
ApiErrorCode? _addressErrorCode;
bool _addressLoading = false;
bool _locationRequestInFlight = false;
GeoSearchResult? _selectedAddress;
final Map<String, String> _typedFilters = <String, String>{};
final Set<String> _favoriteKeys = <String>{};
final Set<String> _favoriteBusyKeys = <String>{};
String _selectedCategory = '';
String _eventDatePreset = 'all';
int _loadGeneration = 0;
int _addressGeneration = 0;
@override
void initState() {
super.initState();
_radiusKm = (widget.geoConfig?.defaultRadiusKm ?? 10)
.clamp(MapPolicy.minRadiusKm, MapPolicy.maxRadiusKm)
.toDouble();
_selectedAddress = widget.initialSelectedAddress;
_gpsLatitude =
widget.locationController?.coordinate?.latitude ??
widget.initialUserLatitude;
_gpsLongitude =
widget.locationController?.coordinate?.longitude ??
widget.initialUserLongitude;
_selectedCategory = widget.initialCategory;
_locationService = widget.locationService ?? LocationService();
_locationAccess = widget.locationController?.access;
widget.locationController?.addListener(_onLocationControllerChanged);
if (_isJobsSurface) {
// The dedicated Jobs route is already the employment context. Keep the
// dependency explicit for the canonical REST contract while it stays
// hidden from the user-facing Jobs controls.
_typedFilters['offer_type'] = 'employment';
_typedFilters['job_salary_interval'] = 'month';
}
_load();
unawaited(_loadFavorites());
}
Future<void> _loadFavorites() async {
final accountRepository = widget.accountRepository;
if (accountRepository == null) return;
try {
final response = await accountRepository.favorites();
if (!mounted) return;
setState(() {
_favoriteKeys.addAll(
response.items.map((item) => '${item.objectType}:${item.objectId}'),
);
});
} catch (_) {
// A logged-out discovery surface remains usable without saved-state data.
}
}
@override
void dispose() {
widget.locationController?.removeListener(_onLocationControllerChanged);
_searchDebounce?.cancel();
_addressDebounce?.cancel();
_searchController.dispose();
_addressController.dispose();
super.dispose();
}
void _onLocationControllerChanged() {
if (!mounted || widget.locationController == null || _isEditorialSurface) {
return;
}
final controller = widget.locationController!;
final nextLatitude = controller.coordinate?.latitude;
final nextLongitude = controller.coordinate?.longitude;
final changed =
nextLatitude != _gpsLatitude ||
nextLongitude != _gpsLongitude ||
controller.access != _locationAccess;
if (!changed) return;
setState(() {
_gpsLatitude = nextLatitude;
_gpsLongitude = nextLongitude;
_locationAccess = controller.access;
});
// Restored coordinates become active on every geo page. During an
// explicit request, _requestCurrentLocation performs the single reload.
if (!_locationRequestInFlight &&
!controller.isLoading &&
_selectedAddress == null) {
unawaited(_load());
}
}
Future<void> _load() async {
final generation = ++_loadGeneration;
setState(() {
_loading = true;
_loadingMore = false;
_error = null;
_errorCode = null;
_loadMoreErrorCode = null;
});
try {
final response = await widget.repository.discover(
types: widget.types,
search: _searchController.text,
latitude: _queryLatitude,
longitude: _queryLongitude,
radiusKm: _isEditorialSurface ? null : _radiusKm,
category: _selectedCategorySlug,
filters: _typedFilters,
sort: _sort,
);
if (!mounted || generation != _loadGeneration) return;
final focusItem = widget.initialFocusItem;
final items =
focusItem != null &&
!response.items.any((item) => item.id == focusItem.id)
? [focusItem, ...response.items]
: response.items;
setState(() {
_items = items;
_meta = response.meta;
_categoryOptions = response.meta.categories;
_loading = false;
});
} catch (error) {
if (!mounted || generation != _loadGeneration) return;
setState(() {
_error = error;
_errorCode = error is ApiException ? error.code : ApiErrorCode.unknown;
_loading = false;
});
}
}
Future<void> _loadMore() async {
final meta = _meta;
if (_loading ||
_loadingMore ||
meta == null ||
meta.page >= meta.totalPages) {
return;
}
setState(() {
_loadingMore = true;
_loadMoreErrorCode = null;
});
final generation = _loadGeneration;
try {
final response = await widget.repository.discover(
types: widget.types,
search: _searchController.text,
latitude: _queryLatitude,
longitude: _queryLongitude,
radiusKm: _isEditorialSurface ? null : _radiusKm,
category: _selectedCategorySlug,
filters: _typedFilters,
page: meta.page + 1,
sort: _sort,
);
if (!mounted || generation != _loadGeneration) return;
setState(() {
_items = [..._items, ...response.items];
_meta = response.meta;
if (response.meta.categories.isNotEmpty) {
_categoryOptions = response.meta.categories;
}
_loadingMore = false;
});
} catch (error) {
if (!mounted || generation != _loadGeneration) return;
setState(() {
_loadingMore = false;
_loadMoreErrorCode = error is ApiException
? error.code
: ApiErrorCode.unknown;
});
}
}
Future<void> _requestLocation() async {
await _requestCurrentLocation();
}
Future<bool> _requestCurrentLocation({bool clearAddress = false}) async {
if (_locationRequestInFlight) return false;
if (clearAddress && mounted) {
_addressDebounce?.cancel();
setState(() {
_selectedAddress = null;
_addressController.clear();
_addressResults = const [];
_addressErrorCode = null;
});
}
_locationRequestInFlight = true;
if (mounted) setState(() {});
try {
final result = widget.locationController != null
? await widget.locationController!.requestLocation()
: await _locationService.requestCurrentLocation();
if (!mounted) return false;
setState(() {
_locationAccess = result.access;
_gpsLatitude = result.coordinate?.latitude;
_gpsLongitude = result.coordinate?.longitude;
});
// The permission button must finish as soon as the location decision is
// known. Loading nearby results is independent and already bounded by
// the repository timeout; awaiting it here made every filter sheet look
// stuck when an API request or a browser cache was slow.
unawaited(_load());
return result.access == LocationAccess.granted &&
result.coordinate != null;
} catch (_) {
if (!mounted) return false;
setState(() {
_locationAccess = LocationAccess.unavailable;
_gpsLatitude = null;
_gpsLongitude = null;
});
unawaited(_load());
return false;
} finally {
_locationRequestInFlight = false;
if (mounted) setState(() {});
}
}
Future<void> _searchAddress() async {
final generation = ++_addressGeneration;
final query = _addressController.text.trim();
if (query.length < 2 || widget.geoRepository == null) return;
if (widget.geoConfig?.geocodingConfigured != true) {
setState(() => _addressErrorCode = ApiErrorCode.serviceUnavailable);
return;
}
setState(() {
_addressLoading = true;
_addressErrorCode = null;
_addressResults = const [];
});
try {
final results = await widget.geoRepository!.searchAddress(query);
if (!mounted || generation != _addressGeneration) return;
setState(() {
_addressResults = results;
_addressLoading = false;
_addressErrorCode = results.isEmpty ? ApiErrorCode.notFound : null;
});
} catch (error) {
if (!mounted || generation != _addressGeneration) return;
setState(() {
_addressLoading = false;
_addressErrorCode = error is ApiException
? error.code
: ApiErrorCode.unknown;
});
}
}
void _scheduleContentSearch(String value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 400), _load);
}
void _scheduleAddressSearch(String value) {
_addressDebounce?.cancel();
if (value.trim().length < 2) {
if (_addressResults.isNotEmpty || _addressErrorCode != null) {
setState(() {
_addressResults = const [];
_addressErrorCode = null;
});
}
return;
}
_addressDebounce = Timer(const Duration(milliseconds: 400), _searchAddress);
}
Future<void> _selectAddress(GeoSearchResult result) async {
setState(() {
_selectedAddress = result;
// A manually selected address is a search origin, not a request to keep
// publishing the user's private GPS position. The map receives the
// address center and therefore must not render a second personal marker.
_gpsLatitude = null;
_gpsLongitude = null;
_addressResults = const [];
_addressErrorCode = null;
});
await _load();
}
double? get _queryLatitude =>
_isEditorialSurface ? null : (_selectedAddress?.latitude ?? _gpsLatitude);
double? get _queryLongitude => _isEditorialSurface
? null
: (_selectedAddress?.longitude ?? _gpsLongitude);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final title = widget.titleBuilder(l10n);
final showSurfaceChrome = widget.showTitle || widget.types.length == 1;
final configuredFallback = widget.fallbackHero;
final hasConfiguredFallback =
configuredFallback != null &&
(configuredFallback.imageUrl.trim().isNotEmpty ||
configuredFallback.title.values.any((value) => value.isNotEmpty) ||
configuredFallback.body.values.any((value) => value.isNotEmpty));
final fallbackHero = _isPlacesSurface && widget.surfaceHero == null
? (hasConfiguredFallback ? configuredFallback : null)
: widget.surfaceHero;
return RefreshIndicator(
onRefresh: _load,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showSurfaceChrome) ...[
// Jobs keeps its editorial hero/search treatment, but it
// now enters the same discovery shell as every other
// geo-resource. Address/GPS, radius, category, mode and
// filter controls must not be hidden in a Jobs-only
// layout.
if (_isJobsSurface) ...[
_JobSurfaceHero(
hero: widget.surfaceHero,
imageUrl: _jobHeroImageUrl,
controller: _searchController,
onChanged: _scheduleContentSearch,
onSubmit: _load,
),
const SizedBox(height: 12),
_jobEmploymentChips(l10n),
const SizedBox(height: 12),
] else ...[
if (widget.showTitle && widget.surfaceHero == null) ...[
_DiscoveryIntro(title: title),
const SizedBox(height: 14),
],
if (fallbackHero != null) ...[
_ModuleSurfaceHero(
hero: fallbackHero,
fallbackTitle: title,
fallbackBody: l10n.discoveryIntroBody,
imageFallbackUrl: widget.imageFallbackUrl,
search: _JobSearchField(
controller: _searchController,
onChanged: _scheduleContentSearch,
onSubmit: _load,
hintText: l10n.searchHint,
),
),
const SizedBox(height: 12),
] else
_SearchBar(
controller: _searchController,
onChanged: _scheduleContentSearch,
onSubmit: _load,
),
const SizedBox(height: 12),
],
if (_isGeoSurface) ...[
_AddressSearchPanel(
controller: _addressController,
loading: _addressLoading,
results: _addressResults,
selected: _selectedAddress,
errorCode: _addressErrorCode,
configured:
widget.geoConfig?.geocodingConfigured == true,
locationActive:
_selectedAddress == null &&
_gpsLatitude != null &&
_gpsLongitude != null,
onChanged: _scheduleAddressSearch,
onSubmit: _searchAddress,
onSelect: _selectAddress,
onUseLocation: _requestLocation,
),
const SizedBox(height: 10),
..._sharedGeoSurfaceControls(l10n),
] else ...[
_genericDiscoveryControls(l10n),
// Jobs uses the same filter entry point as the geo
// shell, with its salary conversion and employment
// dependencies rendered inside that sheet. Do not
// render a second inline FieldSchema form underneath
// the sheet, otherwise every label appears twice.
if (!_isJobsSurface) ...[
..._categoryFilterWidgets(l10n),
..._typedFilterWidgets(l10n),
],
],
],
if (!_isEditorialSurface &&
(_locationAccess == LocationAccess.denied ||
_locationAccess == LocationAccess.unavailable))
_LocationNotice(access: _locationAccess!),
const SizedBox(height: 12),
],
),
),
),
if (_loading)
const SliverFillRemaining(
hasScrollBody: false,
child: LoadingState(),
)
else if (_error != null && _items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: ErrorState(onRetry: _load, code: _errorCode),
)
else if (_items.isEmpty) ...[
if (_showMap) _mapSurfaceSliver(),
SliverFillRemaining(
hasScrollBody: false,
child: EmptyState(
title: l10n.noResultsTitle,
body: _isEditorialSurface
? l10n.editorialNoResultsBody
: l10n.noResultsBody,
),
),
] else ...[
if (_error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: SizedBox(
height: 220,
child: ErrorState(onRetry: _load, code: _errorCode),
),
),
),
..._contentSlivers(l10n),
],
],
),
);
}
Widget _genericDiscoveryControls(AppLocalizations l10n) {
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed: _isJobsSurface ? _openJobFilters : _openGenericFilters,
icon: const Icon(Icons.tune_outlined, size: 18),
label: Text(l10n.filterLabel),
),
if (widget.allowMap)
SegmentedButton<bool>(
segments: [
ButtonSegment(
value: false,
label: Text(l10n.listView),
icon: const Icon(Icons.view_list_outlined),
),
ButtonSegment(
value: true,
label: Text(l10n.mapView),
icon: const Icon(Icons.map_outlined),
),
],
selected: {_showMap},
onSelectionChanged: (value) =>
setState(() => _showMap = value.first),
),
if (widget.allowMap)
SizedBox(
width: 250,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.radiusLabel(_formatRadius(context)),
style: Theme.of(context).textTheme.labelLarge,
),
DiscoveryRadiusFilter(
value: _radiusKm,
label: l10n.radiusLabel(_formatRadius(context)),
onChanged: (value) => setState(() => _radiusKm = value),
onChangeEnd: (_) => _load(),
),
],
),
),
DropdownButton<String>(
value: _sort,
underline: const SizedBox.shrink(),
items: [
DropdownMenuItem(value: 'auto', child: Text(l10n.sortAuto)),
if (widget.allowMap)
DropdownMenuItem(
value: 'distance',
child: Text(l10n.sortDistance),
),
DropdownMenuItem(value: 'newest', child: Text(l10n.sortNewest)),
DropdownMenuItem(value: 'title', child: Text(l10n.sortTitle)),
],
onChanged: (value) {
if (value == null) return;
setState(() => _sort = value);
_load();
},
),
if (widget.allowMap)
OutlinedButton.icon(
onPressed: _requestLocation,
icon: Icon(
_gpsLatitude != null && _gpsLongitude != null
? Icons.my_location
: Icons.my_location_outlined,
),
label: Text(
_gpsLatitude != null && _gpsLongitude != null
? l10n.locationActive
: l10n.useLocation,
),
),
],
);
}
bool get _isGeoSurface => widget.allowMap && widget.geoRepository != null;
List<Widget> _sharedGeoSurfaceControls(AppLocalizations l10n) {
final categoryOptions = _visibleCategoryOptions();
return [
if (_isEventsSurface) ...[
_eventDateChips(l10n),
const SizedBox(height: 10),
],
if (_isRealEstateSurface) ...[
_realEstateTransactionChips(l10n),
const SizedBox(height: 10),
],
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed: _isJobsSurface ? _openJobFilters : _openSharedGeoFilters,
icon: const Icon(Icons.tune_outlined, size: 18),
label: Text(l10n.filterLabel),
),
SegmentedButton<bool>(
segments: [
ButtonSegment(
value: false,
label: Text(l10n.listView),
icon: const Icon(Icons.view_list_outlined),
),
ButtonSegment(
value: true,
label: Text(l10n.mapView),
icon: const Icon(Icons.map_outlined),
),
],
selected: {_showMap},
onSelectionChanged: (value) =>
setState(() => _showMap = value.first),
),
SizedBox(
width: 220,
child: DiscoveryRadiusFilter(
value: _radiusKm,
label: l10n.radiusLabel(_formatRadius(context)),
onChanged: (value) => setState(() => _radiusKm = value),
onChangeEnd: (_) => _load(),
),
),
DropdownButton<String>(
value: _sort,
underline: const SizedBox.shrink(),
items: [
DropdownMenuItem(value: 'auto', child: Text(l10n.sortAuto)),
DropdownMenuItem(
value: 'distance',
child: Text(l10n.sortDistance),
),
DropdownMenuItem(value: 'newest', child: Text(l10n.sortNewest)),
DropdownMenuItem(value: 'title', child: Text(l10n.sortTitle)),
],
onChanged: (value) {
if (value == null) return;
setState(() => _sort = value);
_load();
},
),
],
),
if (categoryOptions.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
l10n.categoryFilterLabel,
style: Theme.of(context).textTheme.titleMedium,
),
_categoryNavigation(l10n),
],
const SizedBox(height: 8),
Text(
l10n.resultsSummary(_meta?.total ?? _items.length),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.palette(context).muted),
),
];
}
Future<void> _openSharedGeoFilters() async {
final l10n = AppLocalizations.of(context);
var draftRadius = _radiusKm;
var draftCategory = _selectedCategory;
var draftDatePreset = _eventDatePreset;
final draftFilters = <String, String>{..._typedFilters};
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
builder: (sheetContext) => StatefulBuilder(
builder: (sheetContext, setSheetState) => SizedBox(
height: MediaQuery.sizeOf(sheetContext).height * .88,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 16),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
l10n.filterLabel,
style: Theme.of(sheetContext).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w800),
),
),
TextButton(
onPressed: () {
setSheetState(() {
draftRadius =
(widget.geoConfig?.defaultRadiusKm ?? 10)
.clamp(
MapPolicy.minRadiusKm,
MapPolicy.maxRadiusKm,
)
.toDouble();
draftCategory = '';
draftDatePreset = 'all';
draftFilters.clear();
});
setState(() {
_selectedCategory = '';
_eventDatePreset = 'all';
_typedFilters.clear();
_selectedAddress = null;
_gpsLatitude = null;
_gpsLongitude = null;
_addressController.clear();
_addressResults = const [];
_addressErrorCode = null;
});
},
child: Text(l10n.clearLabel),
),
],
),
Expanded(
child: ListView(
children: [
_AddressSearchPanel(
controller: _addressController,
loading: _addressLoading,
results: _addressResults,
selected: _selectedAddress,
errorCode: _addressErrorCode,
configured:
widget.geoConfig?.geocodingConfigured == true,
locationActive:
_selectedAddress == null &&
_gpsLatitude != null &&
_gpsLongitude != null,
onChanged: _scheduleAddressSearch,
onSubmit: _searchAddress,
onSelect: (result) {
setState(() {
_selectedAddress = result;
_gpsLatitude = null;
_gpsLongitude = null;
_addressResults = const [];
_addressErrorCode = null;
});
setSheetState(() {});
},
onUseLocation: () async {
final granted = await _requestCurrentLocation(
clearAddress: true,
);
if (mounted && granted) setSheetState(() {});
},
),
const SizedBox(height: 18),
if (_isEventsSurface) ...[
Text(
l10n.eventDate,
style: Theme.of(sheetContext).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
_eventDateChips(
l10n,
selectedPreset: draftDatePreset,
onPresetSelected: (value) => setSheetState(() {
draftDatePreset = value;
}),
),
const SizedBox(height: 18),
],
if (_isRealEstateSurface &&
_realEstateTransactionValues.isNotEmpty) ...[
Text(
l10n.detailTransactionType,
style: Theme.of(sheetContext).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
_realEstateTransactionChips(
l10n,
filters: draftFilters,
onSelected: (value) => setSheetState(() {
if (value.isEmpty) {
draftFilters.remove('transaction_type');
} else {
draftFilters['transaction_type'] = value;
}
}),
),
const SizedBox(height: 18),
],
if (_visibleCategoryOptions().isNotEmpty) ...[
Text(
l10n.categoryFilterLabel,
style: Theme.of(sheetContext).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
_categoryNavigation(
l10n,
selectedCategory: draftCategory,
onCategorySelected: (value) => setSheetState(() {
draftCategory = value;
// Category-dependent fields belong to the
// previous taxonomy branch. Keep only the
// event-date bounds; all other typed values must
// be reselected for the new API category.
draftFilters.removeWhere(
(key, _) =>
key != 'start_after' && key != 'start_before',
);
}),
),
const SizedBox(height: 18),
],
..._typedFilterWidgets(
l10n,
filters: draftFilters,
category: draftCategory,
onStateChanged: setSheetState,
),
const SizedBox(height: 18),
DiscoveryRadiusFilter(
value: draftRadius,
label: l10n.radiusLabel(
_formatRadiusValue(context, draftRadius),
),
onChanged: (value) => setSheetState(() {
draftRadius = value;
}),
),
],
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
final dateFilters = _isEventsSurface
? _eventDateFilters(draftDatePreset, DateTime.now())
: const <String, String>{};
setState(() {
_radiusKm = draftRadius;
_selectedCategory = draftCategory;
_eventDatePreset = draftDatePreset;
_typedFilters
..clear()
..addAll(draftFilters)
..removeWhere(
(key, _) =>
key == 'start_after' || key == 'start_before',
)
..addAll(dateFilters);
});
Navigator.of(sheetContext).pop();
_load();
},
child: Text(_filterApplyLabel(l10n)),
),
),
],
),
),
),
),
);
}
Future<void> _openGenericFilters() async {
final l10n = AppLocalizations.of(context);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) {
var draftRadius = _radiusKm;
var draftCategory = _selectedCategory;
return StatefulBuilder(
builder: (sheetContext, setSheetState) => SizedBox(
height: MediaQuery.sizeOf(sheetContext).height * .86,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 16),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
l10n.filterLabel,
style: Theme.of(sheetContext).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w800),
),
),
TextButton(
onPressed: () {
setSheetState(() {
draftCategory = '';
draftRadius =
(widget.geoConfig?.defaultRadiusKm ?? 10)
.clamp(
MapPolicy.minRadiusKm,
MapPolicy.maxRadiusKm,
)
.toDouble();
});
setState(() {
_selectedCategory = '';
_selectedAddress = null;
_gpsLatitude = null;
_gpsLongitude = null;
_addressController.clear();
_addressResults = const [];
_addressErrorCode = null;
});
},
child: Text(l10n.clearLabel),
),
],
),
Expanded(
child: ListView(
children: [
if (widget.geoRepository != null)
_AddressSearchPanel(
controller: _addressController,
loading: _addressLoading,
results: _addressResults,
selected: _selectedAddress,
errorCode: _addressErrorCode,
configured:
widget.geoConfig?.geocodingConfigured == true,
onChanged: _scheduleAddressSearch,
onSubmit: _searchAddress,
onSelect: _selectAddress,
),
OutlinedButton.icon(
onPressed: () async {
final granted = await _requestCurrentLocation(
clearAddress: true,
);
if (mounted && granted) setSheetState(() {});
},
icon: Icon(
_gpsLatitude != null && _gpsLongitude != null
? Icons.my_location
: Icons.my_location_outlined,
),
label: Text(
_gpsLatitude != null && _gpsLongitude != null
? l10n.locationActive
: l10n.useLocation,
),
),
if (_visibleCategoryOptions().isNotEmpty) ...[
const SizedBox(height: 16),
Text(
l10n.categoryFilterLabel,
style: Theme.of(sheetContext).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
_categoryNavigation(
l10n,
selectedCategory: draftCategory,
onCategorySelected: (value) => setSheetState(() {
draftCategory = value;
}),
),
],
const SizedBox(height: 16),
DiscoveryRadiusFilter(
value: draftRadius,
label: l10n.radiusLabel(
_formatRadiusValue(context, draftRadius),
),
onChanged: (value) => setSheetState(() {
draftRadius = value;
}),
),
],
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
setState(() {
_radiusKm = draftRadius;
_selectedCategory = draftCategory;
});
Navigator.of(sheetContext).pop();
_load();
},
child: Text(_filterApplyLabel(l10n)),
),
),
],
),
),
),
);
},
);
}
ContentFieldSchema? _jobSchema(String fieldKey) {
for (final entry in widget.fieldSchemas.entries) {
if (entry.key.split('.').last == fieldKey) return entry.value;
}
return null;
}
Widget _jobEmploymentChips(AppLocalizations l10n) {
final schema = _jobSchema('employment_type');
final values = schema?.values ?? const <String>[];
if (values.isEmpty) return const SizedBox.shrink();
final selected = _typedFilters['employment_type'] ?? '';
final chipValues = <String, String>{};
for (final value in values) {
final label = listingValueLabel(l10n, value, fieldKey: 'employment_type');
// The API is authoritative for values, but a newer backend must not
// create a row of identical visible "Other" chips in the app.
chipValues.putIfAbsent(label, () => value);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_JobChoiceChip(
label: l10n.filterAll,
selected: selected.isEmpty,
onSelected: () {
setState(() {
_typedFilters
..['offer_type'] = 'employment'
..remove('employment_type');
});
_load();
},
),
for (final entry in chipValues.entries)
Padding(
padding: const EdgeInsets.only(left: 8),
child: _JobChoiceChip(
label: entry.key,
selected: selected == entry.value,
onSelected: () {
setState(() {
_typedFilters
..['offer_type'] = 'employment'
..['employment_type'] = entry.value;
});
_load();
},
),
),
],
),
);
}
void _openDetail(DiscoveryItem item) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ContentDetailPage(
item: item,
repository: widget.repository,
imageSuggestionRepository: widget.imageSuggestionRepository,
accountRepository: widget.accountRepository,
onOpenMap: () => _openMapWithFocus(item),
geoConfig: widget.geoConfig,
userLatitude: _gpsLatitude,
userLongitude: _gpsLongitude,
fieldSchemas: widget.fieldSchemas,
fieldIcons: widget.fieldIcons,
),
),
);
}
Future<void> _toggleFavorite(DiscoveryItem item) async {
final l10n = AppLocalizations.of(context);
final accountRepository = widget.accountRepository;
if (accountRepository == null) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.authRequiredBody)));
return;
}
final key = '${item.resource}:${item.id}';
if (_favoriteBusyKeys.contains(key)) return;
final nextSaved = !_favoriteKeys.contains(key);
setState(() => _favoriteBusyKeys.add(key));
try {
final saved = await accountRepository.updateFavorite(
objectType: item.resource,
objectId: item.id,
saved: nextSaved,
);
if (!mounted) return;
setState(() {
if (saved) {
_favoriteKeys.add(key);
} else {
_favoriteKeys.remove(key);
}
_favoriteBusyKeys.remove(key);
});
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
saved ? l10n.favoriteSavedBody : l10n.favoriteRemovedBody,
),
),
);
} catch (error) {
if (!mounted) return;
setState(() => _favoriteBusyKeys.remove(key));
if (error is ApiException &&
error.code == ApiErrorCode.permissionDenied) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.authRequiredBody)));
return;
}
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.favoriteError)));
}
}
void _openMapWithFocus(DiscoveryItem item) {
if (!CanonicalModuleRoutes.isGeoResource(item.resource)) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context).mapView)),
body: DiscoveryPage(
repository: widget.repository,
types: widget.types,
geoRepository: widget.geoRepository,
geoConfig: widget.geoConfig,
accountRepository: widget.accountRepository,
showMapInitially: true,
showTitle: false,
initialFocusItem: item,
initialSelectedAddress: _selectedAddress,
initialUserLatitude: _gpsLatitude,
initialUserLongitude: _gpsLongitude,
locationController: widget.locationController,
initialCategory: _selectedCategory,
categorySchema: widget.categorySchema,
fieldSchemas: widget.fieldSchemas,
fieldIcons: widget.fieldIcons,
titleBuilder: (l10n) => l10n.mapView,
),
),
),
);
}
bool get _canLoadMore => _meta != null && _meta!.page < _meta!.totalPages;
MapSurface _mapSurface() => MapSurface(
items: _items,
meta: _meta,
geoConfig: widget.geoConfig,
initialSelectedItem: widget.initialFocusItem,
searchLatitude: _selectedAddress?.latitude,
searchLongitude: _selectedAddress?.longitude,
userLatitude: _gpsLatitude,
userLongitude: _gpsLongitude,
radiusKm: _radiusKm,
visible: _showMap,
onItemTap: _openDetail,
onRetry: _load,
);
SliverToBoxAdapter _mapSurfaceSliver() => SliverToBoxAdapter(
child: Offstage(
offstage: !_showMap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _mapSurface(),
),
),
);
List<Widget> _contentSlivers(AppLocalizations l10n) {
if (_showMap) {
return [
_mapSurfaceSliver(),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: Semantics(
header: true,
child: Row(
children: [
const Icon(Icons.map_outlined, size: 18),
const SizedBox(width: 8),
Text(
l10n.resultsSummary(_meta?.total ?? _items.length),
style: Theme.of(context).textTheme.labelLarge,
),
],
),
),
),
),
];
}
return [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
sliver: SliverList.separated(
itemCount: _items.length + (_isJobsSurface ? 0 : 1),
itemBuilder: (context, index) {
if (!_isJobsSurface && index == 0) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Semantics(
header: true,
child: Text(
l10n.resultsSummary(_meta?.total ?? _items.length),
style: Theme.of(context).textTheme.labelLarge,
),
),
);
}
final item = _items[_isJobsSurface ? index : index - 1];
if (_isJobsSurface) {
return _JobDiscoveryCard(
item: item,
onTap: () => _openDetail(item),
isFavorite: _favoriteKeys.contains(
'${item.resource}:${item.id}',
),
onFavorite: () => _toggleFavorite(item),
);
}
if (_isPlacesSurface) {
return _PlaceDiscoveryCard(
item: item,
categoryLabel: _placeItemCategory(item),
onTap: () => _openDetail(item),
isFavorite: _favoriteKeys.contains(
'${item.resource}:${item.id}',
),
onFavorite: () => _toggleFavorite(item),
);
}
if (_isEventsSurface) {
return _EventDiscoveryCard(
item: item,
categoryLabel: _itemCategoryLabel(item),
onTap: () => _openDetail(item),
isFavorite: _favoriteKeys.contains(
'${item.resource}:${item.id}',
),
onFavorite: () => _toggleFavorite(item),
);
}
if (_isClassifiedsSurface) {
return ClassifiedDiscoveryCard(
item: item,
categoryLabel: _itemCategoryLabel(item),
onTap: () => _openDetail(item),
isFavorite: _favoriteKeys.contains(
'${item.resource}:${item.id}',
),
onFavorite: () => _toggleFavorite(item),
);
}
if (_isRealEstateSurface) {
return RealEstateDiscoveryCard(
item: item,
categoryLabel: _itemCategoryLabel(item),
onTap: () => _openDetail(item),
isFavorite: _favoriteKeys.contains(
'${item.resource}:${item.id}',
),
onFavorite: () => _toggleFavorite(item),
);
}
if (_isNewsSurface) {
return NewsDiscoveryCard(
item: item,
categoryLabel: _itemCategoryLabel(item),
onTap: () => _openDetail(item),
isFavorite: _favoriteKeys.contains(
'${item.resource}:${item.id}',
),
onFavorite: () => _toggleFavorite(item),
);
}
return _DiscoveryCard(item: item, onTap: () => _openDetail(item));
},
separatorBuilder: (context, index) => const SizedBox(height: 12),
),
),
if (_canLoadMore)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
child: OutlinedButton(
onPressed: _loadingMore ? null : _loadMore,
child: _loadingMore
? Text(l10n.loadingMore)
: Text(
_loadMoreErrorCode == null ? l10n.loadMore : l10n.retry,
),
),
),
),
];
}
String _formatRadius(BuildContext context) {
return _formatRadiusValue(context, _radiusKm);
}
String _formatRadiusValue(BuildContext context, double radiusKm) {
final l10n = AppLocalizations.of(context);
if (radiusKm < 1) {
return '${(radiusKm * 1000).round()} ${l10n.radiusMetersUnit}';
}
final locale = Localizations.localeOf(context).toLanguageTag();
return '${NumberFormat.decimalPattern(locale).format(radiusKm)} ${l10n.radiusKilometersUnit}';
}
String? get _selectedCategorySlug {
if (_selectedCategory.isEmpty) return null;
// A visible category must always be a real term returned by WordPress for
// this route. FieldSchemas profiles only control dependent fields and are
// never a fallback source for taxonomy labels or query values.
return _categoryOption(_selectedCategory) == null
? null
: _selectedCategory;
}
Future<void> _openJobFilters() async {
final next = await showModalBottomSheet<Map<String, String>>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.white,
builder: (_) => _JobFilterSheet(
fieldSchemas: widget.fieldSchemas,
initialFilters: _typedFilters,
categoryOptions: _visibleCategoryOptions(),
categorySchema: widget.categorySchema,
initialCategory: _selectedCategory,
initialRadiusKm: _radiusKm,
defaultRadiusKm: widget.geoConfig?.defaultRadiusKm ?? 10,
locationLabel:
_selectedAddress?.label ??
AppLocalizations.of(
context,
).jobsLocationSummary(_formatRadius(context)),
locationController: _addressController,
locationLoading: _addressLoading,
locationResults: _addressResults,
gpsActive:
_selectedAddress == null &&
_gpsLatitude != null &&
_gpsLongitude != null,
onLocationChanged: _scheduleAddressSearch,
onLocationSubmit: _searchAddress,
onUseLocation: () => _requestCurrentLocation(clearAddress: true),
onSelectLocation: (result) {
_selectAddress(result);
Navigator.of(context).pop();
},
),
);
if (!mounted || next == null) return;
final nextCategory = next['category'];
final nextRadius = double.tryParse(next['__radius_km'] ?? '');
next.remove('category');
next.remove('__radius_km');
setState(() {
_typedFilters
..clear()
..addAll(next)
..['offer_type'] = 'employment';
_selectedCategory = nextCategory ?? _selectedCategory;
if (nextRadius != null) {
_radiusKm = nextRadius
.clamp(MapPolicy.minRadiusKm, MapPolicy.maxRadiusKm)
.toDouble();
}
});
await _load();
}
bool get _isJobsSurface =>
widget.types.length == 1 && widget.types.first == 'jobs';
bool get _isServicesSurface =>
widget.types.length == 1 && widget.types.first == 'services';
bool get _isPlacesSurface =>
widget.types.length == 1 && widget.types.first == 'places';
bool get _isEventsSurface =>
widget.types.length == 1 && widget.types.first == 'events';
bool get _isClassifiedsSurface =>
widget.types.length == 1 && widget.types.first == 'classifieds';
bool get _isRealEstateSurface =>
widget.types.length == 1 && widget.types.first == 'real-estate';
bool get _isNewsSurface =>
widget.types.length == 1 && widget.types.first == 'news';
bool get _isEditorialSurface =>
widget.types.length == 1 &&
(widget.types.first == 'news' || widget.types.first == 'guides');
String _filterApplyLabel(AppLocalizations l10n) {
if (_isEventsSurface) return l10n.eventFilterApply;
if (_isServicesSurface) return l10n.servicesFilterApply;
if (_isClassifiedsSurface) return l10n.classifiedsFilterApply;
if (_isJobsSurface) return l10n.jobsFilterApply;
if (_isRealEstateSurface) return l10n.realEstateFilterApply;
return l10n.placesFilterApply;
}
List<CategoryOption> _visibleCategoryOptions() => _categoryOptions
.where((option) => option.slug.isNotEmpty)
.toList(growable: false);
CategoryOption? _categoryOption(String slug) {
for (final option in _visibleCategoryOptions()) {
if (option.slug == slug) return option;
}
return null;
}
Widget _categoryNavigation(
AppLocalizations l10n, {
String? selectedCategory,
ValueChanged<String>? onCategorySelected,
}) {
return DiscoveryCategoryNavigation(
options: _visibleCategoryOptions(),
selectedSlug: selectedCategory ?? _selectedCategory,
allLabel: l10n.filterAll,
onSelected: (value) {
if (onCategorySelected != null) {
onCategorySelected(value);
return;
}
setState(() {
_selectedCategory = value;
_typedFilters.removeWhere(
(key, _) => key != 'start_after' && key != 'start_before',
);
});
_load();
},
);
}
Widget _eventDateChips(
AppLocalizations l10n, {
String? selectedPreset,
ValueChanged<String>? onPresetSelected,
}) {
final values = <(String, String)>[
('all', l10n.eventDateAll),
('today', l10n.eventDateToday),
('weekend', l10n.eventDateWeekend),
('next7', l10n.eventDateNext7Days),
];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final (value, label) in values)
Padding(
padding: const EdgeInsets.only(right: 8),
child: _JobChoiceChip(
label: label,
selected: (selectedPreset ?? _eventDatePreset) == value,
onSelected: () {
if (onPresetSelected != null) {
onPresetSelected(value);
return;
}
_setEventDatePreset(value);
},
),
),
],
),
);
}
List<String> get _realEstateTransactionValues {
final schema = widget.fieldSchemas['listing.transaction_type'];
return canonicalRealEstateTransactionValues(
schema?.values ?? const <String>[],
);
}
Widget _realEstateTransactionChips(
AppLocalizations l10n, {
Map<String, String>? filters,
ValueChanged<String>? onSelected,
}) {
final activeFilters = filters ?? _typedFilters;
final selected = activeFilters['transaction_type'] ?? '';
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_JobChoiceChip(
label: l10n.filterAll,
selected: selected.isEmpty,
onSelected: () {
if (onSelected != null) {
onSelected('');
return;
}
setState(() => _typedFilters.remove('transaction_type'));
_load();
},
),
for (final value in _realEstateTransactionValues)
Padding(
padding: const EdgeInsets.only(left: 8),
child: _JobChoiceChip(
label: listingValueLabel(l10n, value),
selected: selected == value,
onSelected: () {
if (onSelected != null) {
onSelected(value);
return;
}
setState(() => _typedFilters['transaction_type'] = value);
_load();
},
),
),
],
),
);
}
void _setEventDatePreset(String preset, {bool reload = true}) {
final today = DateTime.now();
final start = DateTime(today.year, today.month, today.day);
final filters = _eventDateFilters(preset, start);
setState(() {
_eventDatePreset = preset;
_typedFilters.remove('start_after');
_typedFilters.remove('start_before');
_typedFilters.addAll(filters);
});
if (reload) _load();
}
Map<String, String> _eventDateFilters(String preset, DateTime start) {
DateTime? after;
DateTime? before;
switch (preset) {
case 'today':
after = start;
before = start;
case 'weekend':
final daysUntilSaturday = (DateTime.saturday - start.weekday) % 7;
after = start.add(Duration(days: daysUntilSaturday));
before = after.add(const Duration(days: 1));
case 'next7':
after = start;
before = start.add(const Duration(days: 6));
}
if (after == null || before == null) return const {};
return {'start_after': _dateOnly(after), 'start_before': _dateOnly(before)};
}
String _dateOnly(DateTime value) =>
'${value.year.toString().padLeft(4, '0')}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
String _placeItemCategory(DiscoveryItem item) {
final slugs = item.terms['mw_place_category'] ?? const <String>[];
for (final slug in slugs) {
final option = _categoryOption(slug);
if (option != null) return option.name;
}
return '';
}
String _itemCategoryLabel(DiscoveryItem item) {
final options = _visibleCategoryOptions();
for (final values in item.terms.values) {
for (final slug in values) {
for (final option in options) {
if (option.slug == slug) return option.name;
}
}
}
return '';
}
String get _jobHeroImageUrl {
for (final item in _items) {
if (item.imageUrl.trim().isNotEmpty) return item.imageUrl.trim();
}
return '';
}
List<Widget> _categoryFilterWidgets(AppLocalizations l10n) {
final options = _visibleCategoryOptions();
if (options.isEmpty) return const [];
final dedicatedListingRoute =
widget.types.length == 1 &&
(widget.types.first == 'classifieds' || widget.types.first == 'jobs');
if (dedicatedListingRoute) {
return [
const SizedBox(height: 4),
Text(
l10n.categoryFilterLabel,
style: Theme.of(context).textTheme.titleMedium,
),
_categoryNavigation(l10n),
];
}
if (widget.types.length == 1 && widget.types.first == 'real-estate') {
final transactionSchema = widget.fieldSchemas['listing.transaction_type'];
final transactionValues = canonicalRealEstateTransactionValues(
transactionSchema?.values ?? const <String>[],
);
final selectedTransaction = _typedFilters['transaction_type'];
final transactionFilterConfigured = transactionValues.isNotEmpty;
final transactionSelected =
!transactionFilterConfigured ||
transactionValues.contains(selectedTransaction);
final segments = transactionValues
.where((value) => value.isNotEmpty)
.map(
(value) => ButtonSegment<String>(
value: value,
label: Text(listingValueLabel(l10n, value)),
),
)
.toList(growable: false);
final transactionWidgets = <Widget>[
const SizedBox(height: 4),
Text(
l10n.detailTransactionType,
style: Theme.of(context).textTheme.titleMedium,
),
if (segments.isNotEmpty)
SegmentedButton<String>(
segments: segments,
selected:
selectedTransaction == null ||
!transactionValues.contains(selectedTransaction)
? const <String>{}
: <String>{selectedTransaction},
emptySelectionAllowed: true,
onSelectionChanged: (next) {
setState(() {
if (next.isEmpty) {
_typedFilters.remove('transaction_type');
} else {
_typedFilters['transaction_type'] = next.first;
}
_selectedCategory = '';
_typedFilters.removeWhere(
(key, _) =>
key != 'transaction_type' &&
key != 'commission_applicable',
);
});
_load();
},
),
];
if (!transactionSelected) return transactionWidgets;
return [
...transactionWidgets,
const SizedBox(height: 12),
Text(
l10n.detailPropertyKind,
style: Theme.of(context).textTheme.titleMedium,
),
_categoryNavigation(
l10n,
onCategorySelected: (value) {
setState(() {
_selectedCategory = value;
_typedFilters.removeWhere(
(key, _) =>
key != 'transaction_type' && key != 'commission_applicable',
);
});
_load();
},
),
];
}
return [
const SizedBox(height: 4),
Text(
l10n.categoryFilterLabel,
style: Theme.of(context).textTheme.titleMedium,
),
_categoryNavigation(l10n),
];
}
List<Widget> _typedFilterWidgets(
AppLocalizations l10n, {
Map<String, String>? filters,
String? category,
StateSetter? onStateChanged,
}) {
final activeFilters = filters ?? _typedFilters;
final activeCategory = category ?? _selectedCategory;
void updateFilter(String key, String value) {
if (value.trim().isEmpty) {
activeFilters.remove(key);
} else {
activeFilters[key] = value.trim();
}
if (onStateChanged != null) {
onStateChanged(() {});
} else {
setState(() {});
_load();
}
}
final fields = widget.fieldSchemas.entries
.where((entry) => entry.value.filterEnabled)
.where(
(entry) =>
!(widget.types.length == 1 &&
widget.types.first == 'real-estate' &&
entry.key == 'listing.transaction_type'),
)
.where(
(entry) => _categoryAllowsFilter(
entry.key,
entry.value,
category: activeCategory,
),
)
.where(
(entry) =>
_conditionMatches(entry.value.conditions, filters: activeFilters),
)
.toList(growable: false);
if (fields.isEmpty) return const [];
final children = <Widget>[];
for (final entry in fields) {
final fieldKey = entry.key.split('.').last;
final schema = entry.value;
if (schema.filterKind == 'range') {
final minKey = '${fieldKey}_min';
final maxKey = '${fieldKey}_max';
children.add(
SizedBox(
width: 320,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(listingFieldLabel(l10n, fieldKey)),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: TextFormField(
initialValue: activeFilters[minKey] ?? '',
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
labelText: l10n.filterMinimum,
isDense: true,
border: const OutlineInputBorder(),
),
onChanged: (value) => updateFilter(minKey, value),
onFieldSubmitted: (_) {
if (onStateChanged == null) _load();
},
),
),
const SizedBox(width: 8),
Expanded(
child: TextFormField(
initialValue: activeFilters[maxKey] ?? '',
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
labelText: l10n.filterMaximum,
isDense: true,
border: const OutlineInputBorder(),
),
onChanged: (value) => updateFilter(maxKey, value),
onFieldSubmitted: (_) {
if (onStateChanged == null) _load();
},
),
),
],
),
],
),
),
);
continue;
}
final values = <String>[
'',
...(schema.values.isNotEmpty
? schema.values
: schema.filterKind == 'boolean'
? const ['1', '0']
: const <String>[]),
];
if (values.length <= 1) continue;
final selected = activeFilters[fieldKey] ?? '';
children.add(
SizedBox(
width: 220,
child: DropdownButtonFormField<String>(
initialValue: values.contains(selected) ? selected : '',
isExpanded: true,
decoration: InputDecoration(
labelText: listingFieldLabel(l10n, fieldKey),
isDense: true,
border: const OutlineInputBorder(),
),
items: values
.map(
(value) => DropdownMenuItem<String>(
value: value,
child: Text(
value.isEmpty
? l10n.filterAll
: listingValueLabel(
l10n,
schema.filterKind == 'boolean'
? (value == '1' ? 'yes' : 'no')
: value,
fieldKey: fieldKey,
),
),
),
)
.toList(growable: false),
onChanged: (value) {
final next = value ?? '';
if (next.isEmpty) {
activeFilters.remove(fieldKey);
} else {
activeFilters[fieldKey] = next;
}
if ('offer_type' == fieldKey) {
activeFilters.removeWhere(
(key, _) =>
key != 'offer_type' &&
key != 'provider_type' &&
key != 'price_type',
);
}
if (onStateChanged != null) {
onStateChanged(() {});
} else {
setState(() {});
_load();
}
},
),
),
);
}
if (children.isEmpty) return const [];
return [
const SizedBox(height: 4),
Wrap(spacing: 8, runSpacing: 8, children: children),
];
}
bool _categoryAllowsFilter(
String schemaKey,
ContentFieldSchema schema, {
String? category,
}) {
if (schema.categoryConditions.isEmpty) {
return true;
}
final selectedCategory = category ?? _selectedCategory;
if (selectedCategory.isEmpty) return false;
final profile = _categoryProfileForSlug(selectedCategory);
if (profile == null) return false;
final fieldKey = schemaKey.split('.').last;
// Profile fields are API keys from the same WordPress category contract.
// Do not infer category support from the flattened category_conditions
// list: it intentionally contains all aliases for all matching profiles.
return profile.fields.contains(fieldKey) ||
profile.requiredFields.contains(fieldKey);
}
ContentCategoryProfile? _categoryProfileForSlug(String slug) {
final profiles = widget.categorySchema?.profiles ?? const {};
final direct = profiles[slug];
if (direct != null) return direct;
for (final entry in profiles.entries) {
if (entry.value.slugs.contains(slug)) return entry.value;
}
return null;
}
bool _conditionMatches(
List<Map<String, dynamic>> conditions, {
Map<String, String>? filters,
}) {
final activeFilters = filters ?? _typedFilters;
for (final condition in conditions) {
final dependency = (condition['field'] as String? ?? '').split('.').last;
// Category conditions are already resolved against the selected public
// category profile in _categoryAllowsFilter. They are not typed REST
// filters and must not be looked up in _typedFilters.
if (dependency == 'category') continue;
final value = (activeFilters[dependency] ?? '').trim();
switch (condition['operator']) {
case 'equals':
final expected = (condition['value'] as String? ?? '').trim();
if (value != expected) return false;
case 'in':
final allowed = condition['values'] is List
? (condition['values'] as List)
.whereType<String>()
.map((entry) => entry.trim())
.toSet()
: <String>{};
if (allowed.isEmpty || !allowed.contains(value)) return false;
case 'not_empty':
if (value.isEmpty) return false;
}
}
return true;
}
}
class _DiscoveryIntro extends StatelessWidget {
const _DiscoveryIntro({required this.title});
final String title;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final palette = AppTheme.palette(context);
return Container(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: palette.muted.withValues(alpha: .22)),
boxShadow: AppTheme.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -.4,
),
),
const SizedBox(height: 8),
Text(
l10n.discoveryIntroBody,
style: theme.textTheme.bodyLarge?.copyWith(
color: palette.muted,
height: 1.35,
),
),
],
),
);
}
}
class _SearchBar extends StatelessWidget {
const _SearchBar({
required this.controller,
required this.onChanged,
required this.onSubmit,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return DiscoverySearchField(
controller: controller,
hintText: l10n.searchHint,
onChanged: onChanged,
onSubmit: onSubmit,
prefixIcon: Icons.search,
actionIcon: Icons.arrow_forward,
);
}
}
class _LocationNotice extends StatelessWidget {
const _LocationNotice({required this.access});
final LocationAccess access;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final denied = access == LocationAccess.denied;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
denied
? '${l10n.locationDenied}. ${l10n.permissionDeniedBody}'
: l10n.locationUnavailable,
style: TextStyle(color: AppTheme.palette(context).muted),
),
);
}
}
class _AddressSearchPanel extends StatelessWidget {
const _AddressSearchPanel({
required this.controller,
required this.loading,
required this.results,
required this.selected,
required this.errorCode,
required this.configured,
this.locationActive = false,
required this.onChanged,
required this.onSubmit,
required this.onSelect,
this.onUseLocation,
});
final TextEditingController controller;
final bool loading;
final List<GeoSearchResult> results;
final GeoSearchResult? selected;
final ApiErrorCode? errorCode;
final bool configured;
final bool locationActive;
final ValueChanged<String> onChanged;
final VoidCallback onSubmit;
final ValueChanged<GeoSearchResult> onSelect;
final VoidCallback? onUseLocation;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.addressSearchTitle,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 6),
DiscoverySearchField(
controller: controller,
hintText: l10n.addressSearchHint,
onChanged: onChanged,
onSubmit: onSubmit,
prefixIcon: Icons.place_outlined,
actionIcon: Icons.search,
enabled: !loading,
),
if (onUseLocation != null) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onUseLocation,
icon: Icon(
locationActive
? Icons.my_location
: Icons.my_location_outlined,
),
label: Text(
locationActive ? l10n.locationActive : l10n.useLocation,
),
),
],
if (loading) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(),
],
if (selected != null) ...[
const SizedBox(height: 8),
Text(
l10n.addressSearchSelected(selected!.label),
style: TextStyle(color: AppTheme.palette(context).muted),
),
],
if (!configured && errorCode == ApiErrorCode.serviceUnavailable)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.addressSearchNotConfigured,
style: TextStyle(color: AppTheme.palette(context).muted),
),
),
if (errorCode == ApiErrorCode.notFound)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.addressSearchNoResults,
style: TextStyle(color: AppTheme.palette(context).muted),
),
),
if (errorCode != null &&
errorCode != ApiErrorCode.notFound &&
errorCode != ApiErrorCode.serviceUnavailable)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.errorBody,
style: TextStyle(color: AppTheme.palette(context).muted),
),
),
if (results.isNotEmpty)
Card(
margin: const EdgeInsets.only(top: 8),
child: Column(
children: [
for (final result in results)
ListTile(
dense: true,
leading: const Icon(Icons.location_on_outlined),
title: Text(result.label),
onTap: () => onSelect(result),
),
],
),
),
],
),
),
);
}
}
class _JobSurfaceHero extends StatelessWidget {
const _JobSurfaceHero({
required this.hero,
required this.imageUrl,
required this.controller,
required this.onChanged,
required this.onSubmit,
});
final SurfaceHeroConfig? hero;
final String imageUrl;
final TextEditingController controller;
final ValueChanged<String> onChanged;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return _ModuleSurfaceHero(
hero: hero ?? HomeHeroConfig.fallback(),
fallbackTitle: l10n.jobsHeroTitle,
fallbackBody: l10n.jobsHeroBody,
imageFallbackUrl: imageUrl,
search: _JobSearchField(
controller: controller,
onChanged: onChanged,
onSubmit: onSubmit,
),
);
}
}
class _ModuleSurfaceHero extends StatelessWidget {
const _ModuleSurfaceHero({
required this.hero,
required this.fallbackTitle,
required this.fallbackBody,
required this.imageFallbackUrl,
this.search,
});
final SurfaceHeroConfig hero;
final String fallbackTitle;
final String fallbackBody;
final String imageFallbackUrl;
final Widget? search;
@override
Widget build(BuildContext context) {
final palette = AppTheme.palette(context);
final locale = Localizations.localeOf(context).toString();
final imageUrl = hero.imageUrl.trim().isNotEmpty
? hero.imageUrl.trim()
: imageFallbackUrl.trim();
final title = hero.text(hero.title, locale, fallbackTitle);
final body = hero.text(hero.body, locale, fallbackBody);
final eyebrow = hero.text(hero.eyebrow, locale, '');
final titleColor = _surfaceHeroColor(hero.colors.title, Colors.white);
final bodyColor = _surfaceHeroColor(
hero.colors.body,
Colors.white.withValues(alpha: .92),
);
final eyebrowColor = _surfaceHeroColor(hero.colors.eyebrow, palette.brand);
final fit = hero.imageFit == 'contain' ? BoxFit.contain : BoxFit.cover;
final buttonColor = _surfaceHeroColor(hero.colors.button, palette.brand);
final buttonTextColor = _surfaceHeroColor(
hero.colors.buttonText,
Colors.white,
);
return ClipRRect(
borderRadius: BorderRadius.circular(22),
child: SizedBox(
height: 222,
child: Stack(
fit: StackFit.expand,
children: [
if (imageUrl.isNotEmpty)
PublicMediaImage(
url: imageUrl,
alt: hero.imageAlt.isNotEmpty ? hero.imageAlt : title,
fit: fit,
includeSemantics: false,
)
else
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [palette.secondary, palette.brand],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
palette.secondary.withValues(alpha: .72),
palette.secondary.withValues(alpha: .14),
],
begin: Alignment.bottomLeft,
end: Alignment.topRight,
),
),
),
Positioned(
left: 20,
right: 20,
top: 18,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hero.logoUrl.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: PublicMediaImage(
url: hero.logoUrl,
width: 112,
height: 34,
fit: BoxFit.contain,
alt: hero.logoAlt,
includeSemantics: false,
),
),
if (eyebrow.isNotEmpty)
Text(
eyebrow,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: eyebrowColor,
fontWeight: FontWeight.w800,
),
),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: titleColor,
fontWeight: FontWeight.w800,
),
),
if (body.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
body,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: bodyColor),
),
],
],
),
),
if (search != null)
Positioned(
left: 14,
right: 14,
bottom: 12,
child: _ThemedSurfaceSearch(
buttonColor: buttonColor,
buttonTextColor: buttonTextColor,
child: search!,
),
),
],
),
),
);
}
}
class _ThemedSurfaceSearch extends StatelessWidget {
const _ThemedSurfaceSearch({
required this.buttonColor,
required this.buttonTextColor,
required this.child,
});
final Color buttonColor;
final Color buttonTextColor;
final Widget child;
@override
Widget build(BuildContext context) {
if (child is! _JobSearchField) return child;
final search = child as _JobSearchField;
return _JobSearchField(
controller: search.controller,
onChanged: search.onChanged,
onSubmit: search.onSubmit,
buttonColor: buttonColor,
buttonTextColor: buttonTextColor,
hintText: search.hintText,
);
}
}
Color _surfaceHeroColor(String value, Color fallback) {
final normalized = value.trim().replaceFirst('#', '');
if (normalized.length != 6) return fallback;
final parsed = int.tryParse(normalized, radix: 16);
return parsed == null ? fallback : Color(0xFF000000 | parsed);
}
class _JobSearchField extends StatelessWidget {
const _JobSearchField({
required this.controller,
required this.onChanged,
required this.onSubmit,
this.buttonColor,
this.buttonTextColor,
this.hintText,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
final VoidCallback onSubmit;
final Color? buttonColor;
final Color? buttonTextColor;
final String? hintText;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final palette = AppTheme.palette(context);
final actionColor = buttonColor ?? palette.brand;
final actionTextColor = buttonTextColor ?? Colors.white;
return DiscoverySearchField(
controller: controller,
hintText: hintText ?? l10n.jobsSearchHint,
onChanged: onChanged,
onSubmit: onSubmit,
prefixIcon: Icons.search,
actionIcon: Icons.search,
prefixColor: actionColor,
actionColor: actionColor,
actionTextColor: actionTextColor,
actionBackground: true,
);
}
}
class _JobChoiceChip extends StatelessWidget {
const _JobChoiceChip({
required this.label,
required this.selected,
required this.onSelected,
});
final String label;
final bool selected;
final VoidCallback onSelected;
@override
Widget build(BuildContext context) {
final palette = AppTheme.palette(context);
return ChoiceChip(
label: Text(label),
selected: selected,
onSelected: (_) => onSelected(),
selectedColor: palette.brand,
labelStyle: TextStyle(
color: selected ? Colors.white : palette.ink,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
side: BorderSide.none,
backgroundColor: Colors.white,
shape: const StadiumBorder(),
showCheckmark: false,
);
}
}
class _JobFilterSection extends StatelessWidget {
const _JobFilterSection({
required this.icon,
required this.title,
required this.child,
});
final IconData icon;
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
final palette = AppTheme.palette(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, size: 22, color: palette.ink),
const SizedBox(width: 10),
Text(
title,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
),
],
),
const SizedBox(height: 10),
child,
],
);
}
}
class _JobFilterSheet extends StatefulWidget {
const _JobFilterSheet({
required this.fieldSchemas,
required this.initialFilters,
required this.categoryOptions,
required this.categorySchema,
required this.initialCategory,
required this.initialRadiusKm,
required this.defaultRadiusKm,
required this.locationLabel,
required this.locationController,
required this.locationLoading,
required this.locationResults,
required this.gpsActive,
required this.onLocationChanged,
required this.onLocationSubmit,
required this.onUseLocation,
required this.onSelectLocation,
});
final Map<String, ContentFieldSchema> fieldSchemas;
final Map<String, String> initialFilters;
final List<CategoryOption> categoryOptions;
final ContentCategorySchema? categorySchema;
final String initialCategory;
final double initialRadiusKm;
final double defaultRadiusKm;
final String locationLabel;
final TextEditingController locationController;
final bool locationLoading;
final List<GeoSearchResult> locationResults;
final bool gpsActive;
final ValueChanged<String> onLocationChanged;
final VoidCallback onLocationSubmit;
final Future<bool> Function() onUseLocation;
final ValueChanged<GeoSearchResult> onSelectLocation;
@override
State<_JobFilterSheet> createState() => _JobFilterSheetState();
}
class _JobFilterSheetState extends State<_JobFilterSheet> {
late final Map<String, String> _filters = {
...widget.initialFilters,
'offer_type': 'employment',
'job_salary_interval':
widget.initialFilters['job_salary_interval'] == 'year'
? 'year'
: 'month',
};
late String _salaryInterval = _filters['job_salary_interval'] == 'year'
? 'year'
: 'month';
late bool _gpsSelected = widget.gpsActive;
bool _gpsLoading = false;
late String _selectedIndustry = widget.initialCategory;
late double _radiusKm = widget.initialRadiusKm
.clamp(MapPolicy.minRadiusKm, MapPolicy.maxRadiusKm)
.toDouble();
late final TextEditingController _companyController = TextEditingController(
text: _filters['company'] ?? '',
);
late bool _topOnly = _filters['job_featured'] == '1';
late bool _recentOnly = _filters['recent_only'] == '1';
late final TextEditingController _salaryMin = TextEditingController(
text: _filters['salary_min'] ?? '',
);
late final TextEditingController _salaryMax = TextEditingController(
text: _filters['salary_max'] ?? '',
);
@override
void dispose() {
_salaryMin.dispose();
_salaryMax.dispose();
_companyController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Padding(
padding: EdgeInsets.fromLTRB(
22,
8,
22,
16 + MediaQuery.viewInsetsOf(context).bottom,
),
child: SizedBox(
height: MediaQuery.sizeOf(context).height * .88,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
tooltip: l10n.back,
icon: const Icon(Icons.close),
),
Expanded(
child: Text(
l10n.filterLabel,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
TextButton(
onPressed: () => setState(() {
_filters
..clear()
..['offer_type'] = 'employment'
..['job_salary_interval'] = 'month';
_salaryInterval = 'month';
_salaryMin.clear();
_salaryMax.clear();
_selectedIndustry = '';
_radiusKm = widget.defaultRadiusKm
.clamp(MapPolicy.minRadiusKm, MapPolicy.maxRadiusKm)
.toDouble();
_companyController.clear();
_topOnly = false;
_recentOnly = false;
}),
child: Text(l10n.jobsFilterReset),
),
],
),
Expanded(
child: ListView(
padding: const EdgeInsets.only(top: 6),
children: [
_JobFilterSection(
icon: Icons.location_on_outlined,
title: l10n.jobsLocation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_locationField(context, l10n),
const SizedBox(height: 12),
DiscoveryRadiusFilter(
value: _radiusKm,
label: l10n.radiusLabel(
_formatRadiusValue(context, _radiusKm),
),
onChanged: (value) => setState(() {
_radiusKm = value;
}),
),
],
),
),
const SizedBox(height: 18),
_JobFilterSection(
icon: Icons.work_outline,
title: l10n.jobsWorkModel,
child: _choiceGroup(
context,
l10n,
fieldKey: 'work_mode',
schema: _schemaFor('work_mode'),
),
),
const SizedBox(height: 18),
_JobFilterSection(
icon: Icons.description_outlined,
title: l10n.jobsEmploymentType,
child: _choiceGroup(
context,
l10n,
fieldKey: 'employment_type',
schema: _schemaFor('employment_type'),
),
),
const SizedBox(height: 18),
_JobFilterSection(
icon: Icons.euro_outlined,
title: l10n.jobsSalaryGross,
child: _salaryFields(context, l10n),
),
const SizedBox(height: 18),
_JobFilterSection(
icon: Icons.sell_outlined,
title: l10n.jobsIndustry,
child: DiscoveryCategoryNavigation(
options: widget.categoryOptions,
selectedSlug: _selectedIndustry,
allLabel: l10n.filterAll,
onSelected: (value) => setState(() {
_selectedIndustry = value;
}),
),
),
const SizedBox(height: 18),
_JobFilterSection(
icon: Icons.business_outlined,
title: l10n.jobsCompany,
child: _filterSummaryTile(
context,
_companyController.text.trim().isEmpty
? l10n.jobsAllCompanies
: _companyController.text.trim(),
onTap: () => _openCompanyPicker(context, l10n),
),
),
const SizedBox(height: 18),
_JobFilterSection(
icon: Icons.tune_outlined,
title: l10n.jobsMoreFilters,
child: Column(
children: [
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(l10n.jobsTopOnly),
value: _topOnly,
onChanged: (value) => setState(() {
_topOnly = value;
}),
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(l10n.jobsRecentOnly),
value: _recentOnly,
onChanged: (value) => setState(() {
_recentOnly = value;
}),
),
],
),
),
],
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: () {
final result = {..._filters};
result['job_salary_interval'] = _salaryInterval;
result['category'] = _selectedIndustry;
result['__radius_km'] = _radiusKm.toStringAsFixed(2);
if (_companyController.text.trim().isEmpty) {
result.remove('company');
} else {
result['company'] = _companyController.text.trim();
}
if (_topOnly) {
result['job_featured'] = '1';
} else {
result.remove('job_featured');
}
if (_recentOnly) {
result['recent_only'] = '1';
} else {
result.remove('recent_only');
}
final locale = Localizations.localeOf(context).toLanguageTag();
final min = parseJobSalaryFilterAmount(
_salaryMin.text,
locale: locale,
);
final max = parseJobSalaryFilterAmount(
_salaryMax.text,
locale: locale,
);
if (min == null) {
result.remove('salary_min');
} else {
result['salary_min'] = min.toString();
}
if (max == null) {
result.remove('salary_max');
} else {
result['salary_max'] = max.toString();
}
Navigator.of(context).pop(result);
},
child: Text(l10n.jobsFilterApply),
),
],
),
),
);
}
ContentFieldSchema? _schemaFor(String fieldKey) {
for (final entry in widget.fieldSchemas.entries) {
if (entry.key.split('.').last == fieldKey) return entry.value;
}
return null;
}
String _formatRadiusValue(BuildContext context, double radiusKm) {
if (radiusKm < 1) {
return '${(radiusKm * 1000).round()} ${AppLocalizations.of(context).radiusMetersUnit}';
}
final locale = Localizations.localeOf(context).toLanguageTag();
return '${NumberFormat.decimalPattern(locale).format(radiusKm)} ${AppLocalizations.of(context).radiusKilometersUnit}';
}
Widget _locationField(BuildContext context, AppLocalizations l10n) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: widget.locationController,
onChanged: widget.onLocationChanged,
onSubmitted: (_) => widget.onLocationSubmit(),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.location_on_outlined),
suffixIcon: widget.locationLoading
? const Padding(
padding: EdgeInsets.all(14),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: IconButton(
onPressed: widget.onLocationSubmit,
tooltip: l10n.searchSubmit,
icon: const Icon(Icons.search),
),
hintText: _gpsSelected
? l10n.mapCurrentLocation
: widget.locationLabel,
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: widget.locationLoading || _gpsLoading
? null
: () async {
setState(() => _gpsLoading = true);
try {
final selected = await widget.onUseLocation();
if (!mounted) return;
setState(() => _gpsSelected = selected);
} finally {
if (mounted) setState(() => _gpsLoading = false);
}
},
icon: const Icon(Icons.my_location_outlined),
label: Text(_gpsSelected ? l10n.locationActive : l10n.useLocation),
),
if (_gpsSelected)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
l10n.addressSearchSelected(l10n.mapCurrentLocation),
style: TextStyle(color: AppTheme.palette(context).muted),
),
),
if (widget.locationResults.isNotEmpty)
Card(
margin: const EdgeInsets.only(top: 8),
child: Column(
children: [
for (final result in widget.locationResults)
ListTile(
dense: true,
leading: const Icon(Icons.location_on_outlined),
title: Text(result.label),
onTap: () => widget.onSelectLocation(result),
),
],
),
),
],
);
}
Widget _choiceGroup(
BuildContext context,
AppLocalizations l10n, {
required String fieldKey,
required ContentFieldSchema? schema,
}) {
final values = schema?.values ?? const <String>[];
final selected = _filters[fieldKey] ?? '';
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_JobChoiceChip(
label: l10n.filterAll,
selected: selected.isEmpty,
onSelected: () => setState(() => _filters.remove(fieldKey)),
),
for (final value in values)
_JobChoiceChip(
label: listingValueLabel(l10n, value, fieldKey: fieldKey),
selected: selected == value,
onSelected: () => setState(() => _filters[fieldKey] = value),
),
],
);
}
Widget _filterSummaryTile(
BuildContext context,
String label, {
required VoidCallback onTap,
}) {
return Material(
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: AppTheme.palette(context).muted.withValues(alpha: .22),
),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: ListTile(
dense: true,
title: Text(label),
trailing: const Icon(Icons.chevron_right),
),
),
);
}
Future<void> _openCompanyPicker(
BuildContext context,
AppLocalizations l10n,
) async {
final draft = TextEditingController(text: _companyController.text);
final applied = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.jobsCompany),
content: TextField(
controller: draft,
autofocus: true,
textInputAction: TextInputAction.done,
decoration: InputDecoration(hintText: l10n.jobsAllCompanies),
onSubmitted: (_) => Navigator.of(context).pop(true),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.back),
),
TextButton(
onPressed: () {
draft.clear();
Navigator.of(context).pop(true);
},
child: Text(l10n.clearLabel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.jobsFilterApply),
),
],
),
);
if (applied == true && mounted) {
setState(() => _companyController.text = draft.text.trim());
}
draft.dispose();
}
Widget _salaryFields(BuildContext context, AppLocalizations l10n) {
final locale = Localizations.localeOf(context).toLanguageTag();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.jobsSalaryInterval,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_JobChoiceChip(
label: l10n.jobsSalaryMonthly,
selected: _salaryInterval == 'month',
onSelected: () => _changeSalaryInterval('month', locale),
),
_JobChoiceChip(
label: l10n.jobsSalaryYearly,
selected: _salaryInterval == 'year',
onSelected: () => _changeSalaryInterval('year', locale),
),
],
),
const SizedBox(height: 12),
Text(
_salaryInterval == 'year'
? l10n.jobsSalaryYearly
: l10n.jobsSalaryMonthly,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: TextField(
controller: _salaryMin,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
labelText: l10n.filterMinimum,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _salaryMax,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
labelText: l10n.filterMaximum,
border: const OutlineInputBorder(),
),
),
),
],
),
const SizedBox(height: 12),
Text(
l10n.jobsSalaryConversionHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.palette(context).muted,
),
),
],
);
}
void _changeSalaryInterval(String next, String locale) {
if (_salaryInterval == next) return;
final min = convertJobSalaryAmount(
parseJobSalaryFilterAmount(_salaryMin.text, locale: locale),
fromInterval: _salaryInterval,
toInterval: next,
);
final max = convertJobSalaryAmount(
parseJobSalaryFilterAmount(_salaryMax.text, locale: locale),
fromInterval: _salaryInterval,
toInterval: next,
);
setState(() {
_salaryInterval = next;
_filters['job_salary_interval'] = next;
if (min != null) _salaryMin.text = formatLocalizedNumber(min, locale);
if (max != null) _salaryMax.text = formatLocalizedNumber(max, locale);
});
}
}
class _JobDiscoveryCard extends StatelessWidget {
const _JobDiscoveryCard({
required this.item,
required this.onTap,
required this.isFavorite,
required this.onFavorite,
});
final DiscoveryItem item;
final VoidCallback onTap;
final bool isFavorite;
final VoidCallback onFavorite;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final listing = item.listing;
final salary = jobSalaryLabel(listing, locale);
final employment = listing?.employmentType.isNotEmpty == true
? listingValueLabel(
l10n,
listing!.employmentType,
fieldKey: 'employment_type',
)
: '';
final workMode = listing?.workMode.isNotEmpty == true
? listingValueLabel(l10n, listing!.workMode, fieldKey: 'work_mode')
: '';
final company = listing?.companyName ?? '';
return Semantics(
button: true,
label: '${item.title}, ${l10n.moduleJobs}',
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Thumbnail(url: item.imageUrl, label: company, size: 76),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15.5,
height: 1.18,
fontWeight: FontWeight.w800,
),
),
if (company.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
company,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
height: 1.2,
color: AppTheme.palette(context).muted,
),
),
],
if (salary.isNotEmpty || employment.isNotEmpty) ...[
const SizedBox(height: 5),
Wrap(
spacing: 5,
runSpacing: 4,
children: [
if (salary.isNotEmpty)
_JobMetaPill(label: salary, emphasized: true),
if (employment.isNotEmpty)
_JobMetaPill(label: employment),
],
),
],
if (item.address.formatted.isNotEmpty ||
workMode.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
[
item.address.formatted,
workMode,
].where((value) => value.isNotEmpty).join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
height: 1.2,
color: AppTheme.palette(context).muted,
),
),
],
if (item.excerpt.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
item.excerpt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
height: 1.2,
color: AppTheme.palette(context).muted,
),
),
],
],
),
),
const SizedBox(width: 2),
IconButton(
onPressed: onFavorite,
tooltip: isFavorite ? l10n.favoriteRemove : l10n.save,
icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
),
color: isFavorite
? AppTheme.palette(context).brand
: AppTheme.palette(context).muted,
iconSize: 21,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: 34,
height: 34,
),
visualDensity: VisualDensity.compact,
),
],
),
),
),
),
);
}
}
class _PlaceDiscoveryCard extends StatelessWidget {
const _PlaceDiscoveryCard({
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 locale = Localizations.localeOf(context).toLanguageTag();
final details = item.placeDetails;
final hasRating = details != null && details.ratingCount > 0;
final distance = item.distanceKm == null
? ''
: formatDistanceLabel(item.distanceKm, locale);
return Semantics(
button: true,
label: '${item.title}, ${l10n.contentTypePlace}',
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Thumbnail(url: item.imageUrl, label: item.title, size: 108),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15.5,
height: 1.18,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 2),
IconButton(
onPressed: onFavorite,
tooltip: isFavorite
? l10n.favoriteRemove
: l10n.save,
icon: Icon(
isFavorite
? Icons.favorite
: Icons.favorite_border,
),
color: isFavorite
? AppTheme.palette(context).brand
: AppTheme.palette(context).muted,
iconSize: 20,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: 32,
height: 32,
),
visualDensity: VisualDensity.compact,
),
],
),
if (hasRating) ...[
const SizedBox(height: 2),
Text(
${formatRating(details.ratingAverage, locale)} (${formatCount(details.ratingCount, locale)})',
style: TextStyle(
color: AppTheme.palette(context).brand,
fontSize: 12,
height: 1.2,
fontWeight: FontWeight.w700,
),
),
],
if (categoryLabel.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
categoryLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).muted,
fontSize: 12,
height: 1.2,
),
),
],
if (item.address.formatted.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
item.address.formatted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).muted,
fontSize: 12,
height: 1.2,
),
),
],
if (distance.isNotEmpty) ...[
const SizedBox(height: 5),
_PlacePill(label: distance),
],
],
),
),
],
),
),
),
),
);
}
}
class _PlacePill extends StatelessWidget {
const _PlacePill({required this.label});
final String label;
@override
Widget build(BuildContext context) {
final palette = AppTheme.palette(context);
return DecoratedBox(
decoration: BoxDecoration(
color: palette.surface,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
child: Text(
label,
style: TextStyle(
color: palette.muted,
fontSize: 11.5,
height: 1.15,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
class _EventDiscoveryCard extends StatelessWidget {
const _EventDiscoveryCard({
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 locale = Localizations.localeOf(context).toLanguageTag();
final event = item.event;
final date = event == null ? null : DateTime.tryParse(event.start);
final location = item.address.formatted;
final time = date == null
? ''
: DateFormat.Hm(locale).format(date.toLocal());
return Semantics(
button: true,
label: '${item.title}, ${l10n.contentTypeEvent}',
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 126,
height: 118,
child: Stack(
fit: StackFit.expand,
children: [
_Thumbnail(
url: item.imageUrl,
label: item.title,
size: 126,
),
if (categoryLabel.isNotEmpty)
Positioned(
right: 8,
bottom: 8,
child: ServiceCardBadge(label: categoryLabel),
),
if (date != null)
Positioned(
left: 8,
top: 8,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(
blurRadius: 6,
color: Color(0x22000000),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 5,
),
child: Column(
children: [
Text(
DateFormat.E(
locale,
).format(date.toLocal()).toUpperCase(),
style: TextStyle(
color: AppTheme.palette(context).brand,
fontSize: 10,
fontWeight: FontWeight.w800,
),
),
Text(
'${date.toLocal().day}',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w900,
height: 1,
),
),
Text(
DateFormat.MMM(
locale,
).format(date.toLocal()).toUpperCase(),
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(height: 1),
),
],
),
),
),
),
],
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15.5,
height: 1.18,
fontWeight: FontWeight.w800,
),
),
),
IconButton(
onPressed: onFavorite,
tooltip: isFavorite
? l10n.favoriteRemove
: l10n.save,
icon: Icon(
isFavorite
? Icons.favorite
: Icons.favorite_border,
),
color: isFavorite
? AppTheme.palette(context).brand
: AppTheme.palette(context).muted,
iconSize: 20,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: 32,
height: 32,
),
visualDensity: VisualDensity.compact,
),
],
),
if (location.isNotEmpty)
_EventMetaLine(
icon: Icons.location_on_outlined,
text: location,
),
if (date != null)
_EventMetaLine(
icon: Icons.schedule_outlined,
text:
'${formatDate(event!.start, locale)}${time.isEmpty ? '' : ' · $time'}',
),
if (item.excerpt.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
item.excerpt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).muted,
fontSize: 12,
),
),
],
],
),
),
],
),
),
),
),
);
}
}
class _EventMetaLine extends StatelessWidget {
const _EventMetaLine({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: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).muted,
fontSize: 12,
),
),
),
],
),
);
}
}
class _DiscoveryCard extends StatelessWidget {
const _DiscoveryCard({required this.item, required this.onTap});
final DiscoveryItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final type = switch (item.resource) {
'places' => l10n.contentTypePlace,
'events' => l10n.contentTypeEvent,
_ => localizedResourceIdLabel(l10n, item.resource),
};
final geoMeta = [
item.address.formatted,
if (item.distanceKm != null) formatDistanceLabel(item.distanceKm, locale),
].where((value) => value.isNotEmpty).join(' · ');
return Semantics(
button: true,
label: '${item.title}, $type',
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Thumbnail(url: item.imageUrl, label: item.title),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
type.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: AppTheme.palette(context).brand,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
item.title,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (item.event?.start.isNotEmpty == true) ...[
const SizedBox(height: 4),
Text(
formatDate(
item.event!.start,
Localizations.localeOf(context).toLanguageTag(),
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: AppTheme.palette(context).muted,
),
),
],
if (item.excerpt.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
item.excerpt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).muted,
),
),
],
const SizedBox(height: 8),
if (CanonicalModuleRoutes.isGeoResource(item.resource) &&
geoMeta.isNotEmpty)
Text(
geoMeta,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: AppTheme.palette(context).muted,
),
),
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
),
);
}
}
class _Thumbnail extends StatelessWidget {
const _Thumbnail({required this.url, required this.label, this.size = 88});
final String url;
final String label;
final double size;
@override
Widget build(BuildContext context) {
return Semantics(
image: true,
label: label,
child: PublicMediaImage(
url: url,
alt: label,
width: size,
height: size,
fit: BoxFit.cover,
emptyIcon: Icons.place_outlined,
includeSemantics: false,
),
);
}
}
class _JobMetaPill extends StatelessWidget {
const _JobMetaPill({required this.label, this.emphasized = false});
final String label;
final bool emphasized;
@override
Widget build(BuildContext context) {
final palette = AppTheme.palette(context);
return DecoratedBox(
decoration: BoxDecoration(
color: emphasized ? palette.brandSurface : palette.surface,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: emphasized ? palette.brand : palette.ink,
fontSize: 11.5,
height: 1.15,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
