import 'package:consumer_app/core/location/location_service.dart';
import 'package:consumer_app/core/location/location_controller.dart';
import 'package:consumer_app/core/config/app_config.dart';
import 'package:consumer_app/features/discovery/discovery_page.dart';
import 'package:consumer_app/l10n/app_localizations.dart';
import 'package:consumer_app/models/content_models.dart';
import 'package:consumer_app/repositories/discovery_repository.dart';
import 'package:consumer_app/repositories/geo_repository.dart';
import 'package:consumer_app/shared/map/map_surface.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart';
void main() {
testWidgets('location denial reloads discovery without private coordinates', (
tester,
) async {
final repository = _RecordingDiscoveryRepository();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
titleBuilder: (l10n) => l10n.placesTitle,
locationService: LocationService(
provider: _DeniedLocationProvider(),
),
),
),
),
);
await tester.pumpAndSettle();
final locationButton = find.widgetWithText(
OutlinedButton,
'Meinen Standort verwenden',
);
await tester.ensureVisible(locationButton);
await tester.tap(locationButton);
await tester.pumpAndSettle();
expect(repository.calls, greaterThanOrEqualTo(2));
expect(repository.lastLatitude, isNull);
expect(repository.lastLongitude, isNull);
expect(find.textContaining('Standort nicht freigegeben'), findsOneWidget);
});
testWidgets('discovery map/list control renders only the active surface', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: _RecordingDiscoveryRepository(),
titleBuilder: (l10n) => l10n.placesTitle,
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(MapSurface), findsNothing);
await tester.tap(find.text('Karte'));
await tester.pumpAndSettle();
expect(find.byType(MapSurface), findsOneWidget);
await tester.tap(find.text('Liste'));
await tester.pumpAndSettle();
expect(find.byType(MapSurface), findsNothing);
});
testWidgets('Jobs route renders the reference listing and filter sheet', (
tester,
) async {
final repository = _RecordingDiscoveryRepository();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
types: const ['jobs'],
allowMap: false,
titleBuilder: (l10n) => l10n.moduleJobs,
fieldSchemas: const {
'listing.employment_type': ContentFieldSchema(
type: 'string',
required: false,
input: 'enum',
readOnly: false,
values: ['full_time', 'part_time', 'minijob'],
filterEnabled: true,
filterKind: 'enum',
),
'listing.work_mode': ContentFieldSchema(
type: 'string',
required: false,
input: 'enum',
readOnly: false,
values: ['onsite', 'hybrid', 'remote'],
filterEnabled: true,
filterKind: 'enum',
),
'listing.salary_min': ContentFieldSchema(
type: 'number',
required: false,
input: 'number',
readOnly: false,
filterEnabled: true,
filterKind: 'range',
),
},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Jobs in Weimar'), findsOneWidget);
expect(find.text('Vollzeit'), findsOneWidget);
expect(find.text('Filter'), findsOneWidget);
expect(repository.lastFilters['job_salary_interval'], 'month');
await tester.tap(find.text('Filter').last);
await tester.pumpAndSettle();
expect(find.text('Arbeitsmodell'), findsOneWidget);
expect(find.text('Beschäftigungsart'), findsOneWidget);
await tester.drag(find.byType(ListView).last, const Offset(0, -240));
await tester.pumpAndSettle();
expect(find.text('Gehalt (brutto)'), findsOneWidget);
expect(find.text('Brutto monatlich'), findsWidgets);
expect(find.text('Brutto jährlich'), findsWidgets);
final yearlyChoice = find.widgetWithText(ChoiceChip, 'Brutto jährlich');
await tester.tap(yearlyChoice);
await tester.pumpAndSettle();
expect(find.text('Jobs anzeigen'), findsOneWidget);
await tester.tap(find.text('Jobs anzeigen'));
await tester.pumpAndSettle();
expect(repository.lastFilters['job_salary_interval'], 'year');
});
testWidgets('Jobs filter can use GPS instead of entering an address', (
tester,
) async {
final repository = _RecordingDiscoveryRepository();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
types: const ['jobs'],
allowMap: false,
titleBuilder: (l10n) => l10n.moduleJobs,
locationService: LocationService(
provider: _GrantedLocationProvider(),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Filter').last);
await tester.pumpAndSettle();
expect(find.text('Meinen Standort verwenden'), findsOneWidget);
await tester.tap(find.text('Meinen Standort verwenden'));
await tester.pumpAndSettle();
expect(find.text('Ausgewählter Standort: Mein Standort'), findsOneWidget);
expect(repository.lastLatitude, closeTo(50.98, 0.000001));
expect(repository.lastLongitude, closeTo(11.32, 0.000001));
});
testWidgets('Jobs uses the shared geo discovery shell', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: _RecordingDiscoveryRepository(),
types: const ['jobs'],
titleBuilder: (l10n) => l10n.moduleJobs,
geoRepository: _EmptyGeoRepository(),
geoConfig: GeoConfig.fallback(),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Meinen Standort verwenden'), findsOneWidget);
expect(find.text('Liste'), findsOneWidget);
expect(find.text('Karte'), findsOneWidget);
expect(find.textContaining('Radius:'), findsOneWidget);
expect(find.text('Filter'), findsOneWidget);
});
testWidgets('shared geo filter uses the route-specific apply label', (
tester,
) async {
const routes = <({String type, String label})>[
(type: 'events', label: 'Events anzeigen'),
(type: 'services', label: 'Services anzeigen'),
(type: 'classifieds', label: 'Kleinanzeigen anzeigen'),
(type: 'real-estate', label: 'Immobilien anzeigen'),
];
for (final route in routes) {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: _RecordingDiscoveryRepository(),
types: [route.type],
titleBuilder: (l10n) => l10n.discoveryTitle,
geoRepository: _EmptyGeoRepository(),
geoConfig: GeoConfig.fallback(),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Filter'));
await tester.pumpAndSettle();
expect(find.text(route.label), findsOneWidget);
await tester.tap(find.text(route.label));
await tester.pumpAndSettle();
}
});
testWidgets('renders schema-driven listing filters and forwards selection', (
tester,
) async {
final repository = _RecordingDiscoveryRepository();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
titleBuilder: (l10n) => l10n.moduleJobs,
fieldSchemas: const {
'listing.offer_type': ContentFieldSchema(
type: 'string',
required: false,
input: 'enum',
readOnly: false,
values: ['offer', 'employment'],
filterEnabled: true,
filterKind: 'enum',
),
},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Angebotsart'), findsOneWidget);
await tester.tap(find.byType(DropdownButtonFormField<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Stelle / Beschäftigung').last);
await tester.pumpAndSettle();
expect(repository.lastFilters['offer_type'], 'employment');
});
testWidgets('renders schema-driven range filters and forwards bounds', (
tester,
) async {
final repository = _RecordingDiscoveryRepository();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
types: const ['real-estate'],
titleBuilder: (l10n) => l10n.moduleRealEstate,
fieldSchemas: const {
'listing.area': ContentFieldSchema(
type: 'number',
required: false,
input: 'number',
readOnly: false,
filterEnabled: true,
filterKind: 'range',
unit: 'm²',
),
},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Fläche'), findsOneWidget);
expect(find.text('Von'), findsOneWidget);
expect(find.text('Bis'), findsOneWidget);
final fields = find.byType(TextFormField);
expect(fields, findsNWidgets(2));
await tester.enterText(fields.at(0), '50');
await tester.enterText(fields.at(1), '120');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(repository.lastFilters['area_min'], '50');
expect(repository.lastFilters['area_max'], '120');
});
testWidgets('category selection forwards the canonical taxonomy slug', (
tester,
) async {
final repository = _RecordingDiscoveryRepository();
const categorySchema = ContentCategorySchema(
taxonomy: 'mw_classified_category',
profiles: {
'electronics': ContentCategoryProfile(
slugs: ['electronics'],
labels: {'electronics': 'Elektronik'},
fields: ['brand'],
requiredFields: [],
),
},
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
types: const ['classifieds'],
titleBuilder: (l10n) => l10n.moduleClassifieds,
categorySchema: categorySchema,
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Kategorie'), findsOneWidget);
await tester.tap(find.widgetWithText(ChoiceChip, 'Elektronik'));
await tester.pumpAndSettle();
expect(repository.lastCategory, 'electronics');
});
testWidgets('Places categories come from the API taxonomy hierarchy', (
tester,
) async {
final repository = _RecordingDiscoveryRepository();
const categorySchema = ContentCategorySchema(
taxonomy: 'mw_place_category',
profiles: {
'gastronomy': ContentCategoryProfile(
slugs: ['restaurants', 'imbiss'],
labels: {},
fields: [],
requiredFields: [],
),
},
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
types: const ['places'],
titleBuilder: (l10n) => l10n.placesTitle,
categorySchema: categorySchema,
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Kunst & Kultur'), findsOneWidget);
expect(find.text('Museum'), findsNothing);
expect(find.text('Dienstleistungen'), findsNothing);
await tester.tap(find.text('Kunst & Kultur'));
await tester.pumpAndSettle();
expect(find.text('Museum'), findsOneWidget);
await tester.tap(find.text('Museum'));
await tester.pumpAndSettle();
expect(repository.lastCategory, 'museum-2');
});
testWidgets(
'Services do not render FieldSchemas profiles when the API has no categories',
(tester) async {
final repository = _RecordingDiscoveryRepository();
const categorySchema = ContentCategorySchema(
taxonomy: 'mw_service_category',
profiles: {
'services': ContentCategoryProfile(
slugs: ['services'],
labels: {'services': 'Dienstleistungen'},
fields: [],
requiredFields: [],
),
},
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
types: const ['services'],
allowMap: true,
titleBuilder: (l10n) => l10n.moduleServices,
categorySchema: categorySchema,
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Kategorie'), findsNothing);
expect(find.text('Dienstleistungen'), findsNothing);
expect(find.text('Nichts gefunden'), findsOneWidget);
expect(repository.lastCategory, isNull);
},
);
testWidgets('Guides never render a legacy address in the discovery card', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: _RecordingDiscoveryRepository(),
types: const ['guides'],
allowMap: false,
titleBuilder: (l10n) => l10n.moduleGuides,
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Guide mit Legacy-Adresse'), findsOneWidget);
expect(find.text('Alte Straße 1, 99423 Weimar'), findsNothing);
});
testWidgets('editorial resources never send geo query parameters', (
tester,
) async {
final repository = _RecordingDiscoveryRepository();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
types: const ['news'],
allowMap: false,
titleBuilder: (l10n) => l10n.moduleNews,
),
),
),
);
await tester.pumpAndSettle();
expect(repository.lastLatitude, isNull);
expect(repository.lastLongitude, isNull);
expect(repository.lastRadiusKm, isNull);
});
testWidgets('editorial resources never render a geo access notice', (
tester,
) async {
final locationController = LocationController(
service: LocationService(provider: _DeniedLocationProvider()),
)..access = LocationAccess.denied;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: _RecordingDiscoveryRepository(),
types: const ['news'],
allowMap: false,
locationController: locationController,
titleBuilder: (l10n) => l10n.moduleNews,
),
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Standort nicht freigegeben'), findsNothing);
});
testWidgets(
'Places filter renders categories, typed labels and applies radius',
(tester) async {
final repository = _RecordingDiscoveryRepository();
const categorySchema = ContentCategorySchema(
taxonomy: 'mw_place_category',
profiles: {
'gastronomy': ContentCategoryProfile(
slugs: ['restaurants'],
labels: {},
fields: [],
requiredFields: [],
),
},
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
types: const ['places'],
allowMap: true,
titleBuilder: (l10n) => l10n.placesTitle,
categorySchema: categorySchema,
fieldSchemas: const {
'place_details.rating_average': ContentFieldSchema(
type: 'number',
required: false,
input: 'number',
readOnly: false,
filterEnabled: true,
filterKind: 'range',
),
'place_details.featured': ContentFieldSchema(
type: 'boolean',
required: false,
input: 'boolean',
readOnly: false,
filterEnabled: true,
filterKind: 'boolean',
),
},
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Filter'));
await tester.pumpAndSettle();
expect(find.text('Kunst & Kultur'), findsNWidgets(2));
expect(find.text('Dienstleistungen'), findsNothing);
expect(find.text('Bewertung'), findsOneWidget);
expect(find.text('Hervorgehoben'), findsOneWidget);
expect(find.text('Von'), findsOneWidget);
expect(find.text('Bis'), findsOneWidget);
final slider = find.byType(Slider);
// The shared discovery page keeps the same radius control visible in
// the page toolbar while the filter sheet is open. Interact with the
// sheet instance, not the covered page instance.
expect(slider, findsNWidgets(2));
await tester.drag(slider.last, const Offset(100, 0));
await tester.pumpAndSettle();
await tester.tap(find.text('Orte anzeigen'));
await tester.pumpAndSettle();
expect(repository.lastRadiusKm, greaterThan(10));
},
);
testWidgets('category-dependent filters follow the public category schema', (
tester,
) async {
final repository = _RecordingDiscoveryRepository();
const categorySchema = ContentCategorySchema(
taxonomy: 'mw_classified_category',
profiles: {
'electronics': ContentCategoryProfile(
slugs: ['electronics'],
labels: {'electronics': 'Elektronik'},
fields: ['brand'],
requiredFields: [],
),
'services': ContentCategoryProfile(
slugs: ['services'],
labels: {'services': 'Dienstleistungen'},
fields: [],
requiredFields: [],
),
},
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
types: const ['classifieds'],
titleBuilder: (l10n) => l10n.moduleClassifieds,
categorySchema: categorySchema,
fieldSchemas: const {
'listing.brand': ContentFieldSchema(
type: 'string',
required: false,
input: 'enum',
readOnly: false,
values: ['brand-a'],
filterEnabled: true,
filterKind: 'enum',
categoryConditions: ['electronics'],
conditions: [
{
'field': 'category',
'operator': 'in',
'values': ['electronics'],
},
],
),
},
),
),
),
);
await tester.pumpAndSettle();
// Detail filters are progressive: choose a category first.
expect(find.text('Marke'), findsNothing);
await tester.tap(find.widgetWithText(ChoiceChip, 'Dienstleistungen'));
await tester.pumpAndSettle();
expect(find.text('Marke'), findsNothing);
await tester.tap(find.widgetWithText(ChoiceChip, 'Elektronik'));
await tester.pumpAndSettle();
expect(find.text('Marke'), findsOneWidget);
});
testWidgets('required category fields remain available to filters', (
tester,
) async {
final repository = _RecordingDiscoveryRepository();
const categorySchema = ContentCategorySchema(
taxonomy: 'mw_property_category',
profiles: {
'apartment': ContentCategoryProfile(
slugs: ['apartment'],
labels: {'apartment': 'Wohnung'},
fields: [],
requiredFields: ['area'],
),
'land': ContentCategoryProfile(
slugs: ['land'],
labels: {'land': 'Grundstück'},
fields: ['area'],
requiredFields: ['area'],
),
},
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
types: const ['real-estate'],
titleBuilder: (l10n) => l10n.moduleRealEstate,
categorySchema: categorySchema,
fieldSchemas: const {
'listing.area': ContentFieldSchema(
type: 'number',
required: false,
input: 'number',
readOnly: false,
filterEnabled: true,
filterKind: 'range',
categoryConditions: ['apartment', 'land'],
conditions: [
{
'field': 'category',
'operator': 'in',
'values': ['apartment', 'land'],
},
],
),
},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Fläche'), findsNothing);
await tester.tap(find.widgetWithText(ChoiceChip, 'Wohnung'));
await tester.pumpAndSettle();
expect(find.text('Fläche'), findsOneWidget);
});
testWidgets(
'real estate discovery uses transaction segments and category chips',
(tester) async {
final repository = _RecordingDiscoveryRepository();
const categorySchema = ContentCategorySchema(
taxonomy: 'mw_property_category',
profiles: {
'apartment': ContentCategoryProfile(
slugs: ['apartment'],
labels: {'apartment': 'Wohnung'},
fields: [],
requiredFields: [],
),
'land': ContentCategoryProfile(
slugs: ['land'],
labels: {'land': 'Grundstück'},
fields: [],
requiredFields: [],
),
'wg-room': ContentCategoryProfile(
slugs: ['room', 'wg-zimmer'],
labels: {},
fields: [],
requiredFields: [],
),
'temporary-living': ContentCategoryProfile(
slugs: ['temporary-living', 'wohnen-auf-zeit'],
labels: {},
fields: [],
requiredFields: [],
),
'office-practice': ContentCategoryProfile(
slugs: ['office-practice', 'buero-praxis'],
labels: {'office-practice': 'Büro / Praxis'},
fields: [],
requiredFields: [],
),
},
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
types: const ['real-estate'],
titleBuilder: (l10n) => l10n.moduleRealEstate,
categorySchema: categorySchema,
fieldSchemas: const {
'listing.transaction_type': ContentFieldSchema(
type: 'string',
required: false,
input: 'enum',
readOnly: false,
// Legacy aliases may still be present in a cached/older
// bootstrap; they must not become duplicate UI segments.
values: ['sale', 'rent', 'lease', 'temporary'],
filterEnabled: true,
filterKind: 'enum',
),
'listing.commission_applicable': ContentFieldSchema(
type: 'string',
required: false,
input: 'enum',
readOnly: false,
values: ['no', 'yes'],
filterEnabled: true,
filterKind: 'enum',
categoryConditions: ['apartment', 'land'],
conditions: [
{
'field': 'category',
'operator': 'in',
'values': ['apartment', 'land'],
},
],
),
},
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(SegmentedButton<String>), findsOneWidget);
expect(find.text('Kaufen'), findsOneWidget);
expect(find.text('Miete'), findsOneWidget);
expect(find.widgetWithText(ChoiceChip, 'Wohnung'), findsNothing);
expect(find.widgetWithText(ChoiceChip, 'Grundstück'), findsNothing);
expect(find.text('Provision fällig?'), findsNothing);
await tester.tap(find.text('Kaufen').first);
await tester.pumpAndSettle();
expect(repository.lastFilters['transaction_type'], 'sale');
expect(find.widgetWithText(ChoiceChip, 'Wohnung'), findsOneWidget);
expect(find.widgetWithText(ChoiceChip, 'Grundstück'), findsOneWidget);
expect(find.widgetWithText(ChoiceChip, 'WG-Zimmer'), findsOneWidget);
expect(
find.widgetWithText(ChoiceChip, 'Wohnen auf Zeit'),
findsOneWidget,
);
expect(find.widgetWithText(ChoiceChip, 'Büro / Praxis'), findsOneWidget);
await tester.tap(find.widgetWithText(ChoiceChip, 'Wohnung'));
await tester.pumpAndSettle();
expect(repository.lastCategory, 'apartment');
},
);
testWidgets('dedicated classifieds discovery uses category chips', (
tester,
) async {
final repository = _RecordingDiscoveryRepository();
const categorySchema = ContentCategorySchema(
taxonomy: 'mw_classified_category',
profiles: {
'electronics': ContentCategoryProfile(
slugs: ['electronics'],
labels: {'electronics': 'Elektronik'},
fields: [],
requiredFields: [],
),
},
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
types: const ['classifieds'],
titleBuilder: (l10n) => l10n.moduleClassifieds,
categorySchema: categorySchema,
),
),
),
);
await tester.pumpAndSettle();
expect(find.widgetWithText(ChoiceChip, 'Elektronik'), findsOneWidget);
expect(find.byType(DropdownButtonFormField<String>), findsNothing);
await tester.tap(find.widgetWithText(ChoiceChip, 'Elektronik'));
await tester.pumpAndSettle();
expect(repository.lastCategory, 'electronics');
});
testWidgets('schema dependent filters support an in condition', (
tester,
) async {
final repository = _RecordingDiscoveryRepository();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
titleBuilder: (l10n) => l10n.moduleJobs,
fieldSchemas: const {
'listing.offer_type': ContentFieldSchema(
type: 'string',
required: false,
input: 'enum',
readOnly: false,
values: ['offer', 'employment'],
filterEnabled: true,
filterKind: 'enum',
),
'listing.employment_type': ContentFieldSchema(
type: 'string',
required: false,
input: 'enum',
readOnly: false,
values: ['full_time', 'part_time'],
filterEnabled: true,
filterKind: 'enum',
conditions: [
{
'field': 'listing.offer_type',
'operator': 'in',
'values': ['employment'],
},
],
),
},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Beschäftigungsart'), findsNothing);
final offerType = find.byType(DropdownButtonFormField<String>).first;
await tester.tap(offerType);
await tester.pumpAndSettle();
await tester.tap(find.text('Stelle / Beschäftigung').last);
await tester.pumpAndSettle();
expect(find.text('Beschäftigungsart'), findsOneWidget);
expect(repository.lastFilters['offer_type'], 'employment');
});
testWidgets('schema dependent filters normalize condition whitespace', (
tester,
) async {
final repository = _RecordingDiscoveryRepository();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: Scaffold(
body: DiscoveryPage(
repository: repository,
titleBuilder: (l10n) => l10n.moduleJobs,
fieldSchemas: const {
'listing.offer_type': ContentFieldSchema(
type: 'string',
required: false,
input: 'enum',
readOnly: false,
values: ['employment'],
filterEnabled: true,
filterKind: 'enum',
),
'listing.application_method': ContentFieldSchema(
type: 'string',
required: false,
input: 'enum',
readOnly: false,
values: ['url'],
filterEnabled: true,
filterKind: 'enum',
conditions: [
{
'field': 'listing.offer_type',
'operator': 'equals',
'value': ' employment ',
},
],
),
},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Bewerbungsweg'), findsNothing);
await tester.tap(find.byType(DropdownButtonFormField<String>).first);
await tester.pumpAndSettle();
await tester.tap(find.text('Stelle / Beschäftigung').last);
await tester.pumpAndSettle();
expect(find.text('Bewerbungsweg'), findsOneWidget);
});
}
class _RecordingDiscoveryRepository implements DiscoveryRepository {
int calls = 0;
double? lastLatitude;
double? lastLongitude;
double? lastRadiusKm = 10;
String? lastCategory;
Map<String, String> lastFilters = const {};
@override
Future<DiscoveryResponse> discover({
required List<String> types,
String? search,
double? latitude,
double? longitude,
double? radiusKm = 10,
int page = 1,
int perPage = 40,
String? category,
String? region,
String? status,
Map<String, String> filters = const {},
String sort = 'auto',
double? minLatitude,
double? maxLatitude,
double? minLongitude,
double? maxLongitude,
}) async {
calls++;
lastLatitude = latitude;
lastLongitude = longitude;
lastRadiusKm = radiusKm;
lastCategory = category;
lastFilters = filters;
final categories = types.length == 1 && types.first == 'places'
? [
{
'slug': 'freizeit-erleben',
'name': 'Freizeit & Erleben',
'parent_slug': '',
'count': 68,
'taxonomy': 'mw_place_category',
},
{
'slug': 'kunst-kultur',
'name': 'Kunst & Kultur',
'parent_slug': '',
'count': 32,
'taxonomy': 'mw_place_category',
},
{
'slug': 'spielplaetze',
'name': 'Spielplätze',
'parent_slug': '',
'count': 68,
'taxonomy': 'mw_place_category',
},
{
'slug': 'museum-2',
'name': 'Museum',
'parent_slug': 'kunst-kultur',
'count': 12,
'taxonomy': 'mw_place_category',
},
]
: types.length == 1 && types.first == 'classifieds'
? [
{
'slug': 'electronics',
'name': 'Elektronik',
'parent_slug': '',
'count': 4,
'taxonomy': 'mw_classified_category',
},
{
'slug': 'services',
'name': 'Dienstleistungen',
'parent_slug': '',
'count': 2,
'taxonomy': 'mw_classified_category',
},
]
: types.length == 1 && types.first == 'real-estate'
? [
{
'slug': 'apartment',
'name': 'Wohnung',
'parent_slug': '',
'count': 4,
'taxonomy': 'mw_property_category',
},
{
'slug': 'land',
'name': 'Grundstück',
'parent_slug': '',
'count': 2,
'taxonomy': 'mw_property_category',
},
{
'slug': 'wg-zimmer',
'name': 'WG-Zimmer',
'parent_slug': '',
'count': 2,
'taxonomy': 'mw_property_category',
},
{
'slug': 'wohnen-auf-zeit',
'name': 'Wohnen auf Zeit',
'parent_slug': '',
'count': 2,
'taxonomy': 'mw_property_category',
},
{
'slug': 'office-practice',
'name': 'Büro / Praxis',
'parent_slug': '',
'count': 2,
'taxonomy': 'mw_property_category',
},
]
: types.length == 1 && types.first == 'jobs'
? [
{
'slug': 'it-tech',
'name': 'IT & Technik',
'parent_slug': '',
'count': 3,
'taxonomy': 'mw_job_category',
},
]
: const [];
final items = types.length == 1 && types.first == 'guides'
? [
{
'id': 901,
'resource': 'guides',
'title': 'Guide mit Legacy-Adresse',
'excerpt': 'Redaktioneller Guide',
'geo': {'available': true, 'lat': 50.98, 'lng': 11.32},
'address': {
'line1': 'Alte Straße 1',
'locality': 'Weimar',
'postal_code': '99423',
},
},
]
: const [];
return DiscoveryResponse.fromJson({
'items': items,
'meta': {
'page': 1,
'per_page': 40,
'total': 0,
'total_pages': 0,
'categories': categories,
},
});
}
@override
Future<DiscoveryItem> detail({required String resource, required int id}) {
throw UnimplementedError();
}
}
class _DeniedLocationProvider implements LocationProvider {
@override
Future<bool> isLocationServiceEnabled() async => true;
@override
Future<LocationPermission> checkPermission() async =>
LocationPermission.denied;
@override
Future<LocationPermission> requestPermission() async =>
LocationPermission.denied;
@override
Future<Position> getCurrentPosition() async => throw StateError('denied');
}
class _EmptyGeoRepository implements GeoRepository {
@override
Future<List<GeoSearchResult>> searchAddress(
String query, {
int limit = 5,
}) async => const [];
}
class _GrantedLocationProvider implements LocationProvider {
@override
Future<bool> isLocationServiceEnabled() async => true;
@override
Future<LocationPermission> checkPermission() async =>
LocationPermission.whileInUse;
@override
Future<LocationPermission> requestPermission() async =>
LocationPermission.whileInUse;
@override
Future<Position> getCurrentPosition() async => Position(
latitude: 50.98,
longitude: 11.32,
timestamp: DateTime.utc(2026, 9, 21),
accuracy: 5,
altitude: 0,
altitudeAccuracy: 1,
heading: 0,
headingAccuracy: 1,
speed: 0,
speedAccuracy: 0,
);
}
