import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/config/app_config.dart';
import '../../core/content/navigation_labels.dart';
import '../../core/location/location_controller.dart';
import '../../core/location/location_service.dart';
import '../../core/navigation/canonical_module_routes.dart';
import '../../core/networking/api_client.dart';
import '../../core/theme/app_theme.dart';
import '../../features/detail/content_detail_page.dart';
import '../../features/discovery/discovery_page.dart';
import '../../features/discovery/resource_browser_page.dart';
import '../../features/home/home_page.dart';
import '../../features/more/more_page.dart';
import '../../features/community/community_page.dart';
import '../../features/saved/saved_page.dart';
import '../../l10n/app_localizations.dart';
import '../../l10n/locale_controller.dart';
import '../../models/content_models.dart';
import '../../repositories/discovery_repository.dart';
import '../../repositories/account_repository.dart';
import '../../repositories/claim_repository.dart';
import '../../repositories/geo_repository.dart';
import '../../repositories/image_suggestion_repository.dart';
import '../../repositories/notifications_repository.dart';
import '../navigation/app_navigation.dart';
class AppShell extends StatefulWidget {
const AppShell({
super.key,
required this.snapshot,
required this.localeController,
required this.apiClient,
required this.repository,
required this.accountRepository,
required this.claimRepository,
required this.geoRepository,
required this.imageSuggestionRepository,
required this.notificationsRepository,
this.locationController,
this.initialPath,
this.onRetryBootstrap,
});
final BootstrapSnapshot snapshot;
final LocaleController localeController;
final ApiClient apiClient;
final DiscoveryRepository repository;
final AccountRepository accountRepository;
final ClaimRepository claimRepository;
final GeoRepository geoRepository;
final ImageSuggestionRepository imageSuggestionRepository;
final NotificationsRepository notificationsRepository;
final LocationController? locationController;
final String? initialPath;
final VoidCallback? onRetryBootstrap;
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
late final LocationController _locationController =
widget.locationController ?? LocationController();
int _selectedIndex = 0;
bool _showHome = true;
bool _initialPathHandled = false;
String? _activeModuleId;
final Map<String, String> _initialCategories = <String, String>{};
bool _showLocationPrompt = false;
bool _locationPromptBusy = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_openInitialCanonicalRoute();
if (widget.locationController != null) {
unawaited(_maybePromptForLocation());
}
});
}
@override
void dispose() {
if (widget.locationController == null) {
_locationController.dispose();
}
super.dispose();
}
Future<void> _maybePromptForLocation() async {
await _locationController.restore();
if (!mounted) return;
if (_locationController.startupPromptCompleted) {
// Permission has already been explained and accepted before. Refresh
// the locally remembered coordinate silently on app start so nearby
// results do not become stale while the user still keeps control over
// the native permission.
if (_locationController.access == LocationAccess.granted) {
// A background refresh is best effort. A transient WebView/browser
// geolocation failure must not erase the coordinate already restored
// from local storage or make every following module forget GPS.
unawaited(
_locationController.requestLocation(preserveOnUnavailable: true),
);
}
return;
}
if (!mounted) return;
// Keep the first-run explanation in the app shell instead of opening a
// modal route during the initial frame. A native location prompt or a
// slow bootstrap must never leave the whole app behind an undismissable
// gray barrier when the platform cannot paint the dialog immediately.
setState(() => _showLocationPrompt = true);
}
Future<void> _handleLocationPrompt(bool allow) async {
if (_locationPromptBusy) return;
setState(() {
_locationPromptBusy = true;
_showLocationPrompt = false;
});
try {
if (allow) {
// Permission/location lookup continues in the shared controller, but
// the shell must remain usable if a browser/WebView promise stalls.
unawaited(_locationController.requestLocation());
}
unawaited(_locationController.markStartupPromptCompleted());
} finally {
if (mounted) setState(() => _locationPromptBusy = false);
}
}
Future<void> _openInitialCanonicalRoute() async {
if (!mounted || _initialPathHandled) return;
_initialPathHandled = true;
final moduleId =
CanonicalModuleRoutes.idForPath(widget.initialPath) ??
_resourceIdForPath(widget.initialPath);
if (moduleId == null) return;
if (CanonicalModuleRoutes.contains(moduleId)) {
await _pushCanonicalModule(moduleId);
} else if (_resourceFor(moduleId) != null) {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
settings: RouteSettings(name: widget.initialPath),
builder: (_) => _canonicalModulePage(moduleId),
),
);
}
}
String? _resourceIdForPath(String? rawPath) {
if (rawPath == null || rawPath.trim().isEmpty) return null;
final uri = Uri.tryParse(rawPath.trim());
var path = uri?.path ?? rawPath.trim();
if (path == '/' && uri != null && uri.fragment.isNotEmpty) {
path = uri.fragment;
}
path = '/${path.replaceAll(RegExp(r'^/+|/+$'), '')}/';
final navigationRoutes = <String, String>{
for (final item in _navigation)
'/${item.route.replaceAll(RegExp(r'^/+|/+$'), '')}/': item.id,
};
final navigationMatch = navigationRoutes[path];
if (navigationMatch != null && _resourceFor(navigationMatch) != null) {
return navigationMatch;
}
// Deep links for secondary resources such as News and Guides must remain
// resolvable even when the backend intentionally keeps them out of the
// configured bottom/fullscreen navigation. Their resource definitions are
// still the canonical API contract; absence from navigation must not turn
// a valid route into the Home screen.
for (final resource in widget.snapshot.resources) {
final resourcePath =
CanonicalModuleRoutes.pathFor(resource.id) ??
switch (resource.id) {
'news' => '/news/',
'guides' => '/guides/',
_ => '/${resource.id}/',
};
if (resourcePath == path) return resource.id;
}
return null;
}
List<NavigationItem> get _navigation {
final rawItems =
widget.snapshot.config.appNavigationConfigured &&
widget.snapshot.config.bottomNavigation.isNotEmpty
? widget.snapshot.config.bottomNavigation
: _generalNavigation;
return _uniqueNavigation(rawItems);
}
List<NavigationItem> get _generalNavigation {
final configured = widget.snapshot.config.navigation;
return _uniqueNavigation(
configured.isEmpty ? NavigationItem.defaults : configured,
);
}
List<NavigationItem> _uniqueNavigation(Iterable<NavigationItem> items) {
final seen = <String>{};
final result = <NavigationItem>[];
for (final item in items) {
if (!item.enabled) continue;
if (seen.add(_navigationIdentity(item))) {
result.add(item);
}
}
return result;
}
String _navigationIdentity(NavigationItem item) {
final moduleId = _canonicalModuleId(item);
if (moduleId != null) return 'module:$moduleId';
final id = item.id.trim().toLowerCase();
final route = item.route.trim().toLowerCase();
return 'surface:$id:$route';
}
String? _canonicalModuleId(NavigationItem item) {
final canonicalRoute = CanonicalModuleRoutes.idForPath(item.route);
if (canonicalRoute != null) return canonicalRoute;
final id = item.id.trim().toLowerCase();
return CanonicalModuleRoutes.contains(id) ? id : null;
}
List<NavigationItem> get _configuredFullscreenNavigation {
if (widget.snapshot.config.appNavigationConfigured &&
widget.snapshot.config.fullscreenNavigation.isNotEmpty) {
return _uniqueNavigation(widget.snapshot.config.fullscreenNavigation);
}
return const [];
}
List<NavigationItem> get _primaryNavigation {
if (widget.snapshot.config.appNavigationConfigured &&
widget.snapshot.config.bottomNavigation.isNotEmpty) {
return _navigation
.where((item) => item.enabled)
.take(6)
.toList(growable: false);
}
final configured = _navigation.where((item) => item.enabled).toList();
final configuredById = <String, NavigationItem>{};
for (final item in configured) {
final moduleId = _canonicalModuleId(item);
if (moduleId != null) {
configuredById[moduleId] = item;
}
}
final availableResources = widget.snapshot.resources
.map((resource) => resource.id)
.toSet();
if (availableResources.isEmpty) {
availableResources.addAll(const {
'places',
'events',
'map',
'classifieds',
'jobs',
'real-estate',
});
}
const preferredIds = CanonicalModuleRoutes.fallbackPrimaryIds;
final primary = <NavigationItem>[];
for (final id in preferredIds) {
final configuredItem = configuredById[id];
if (configuredItem != null) {
primary.add(configuredItem);
} else if (availableResources.contains(id)) {
primary.add(_defaultNavigationItem(id));
}
}
if (primary.length >= 4) return primary.take(6).toList(growable: false);
return configured.where((item) => item.primary).take(6).toList();
}
List<NavigationItem> get _menuNavigation {
final fullscreen = _configuredFullscreenNavigation;
// The fullscreen surface remains backend-ordered, but the six public
// content routes are mandatory reachability targets. Older/sparse saved
// configs often mirror the bottom bar into fullscreen; that must not make
// a Home quick-access module disappear from the global menu.
final configured = _uniqueNavigation(
(fullscreen.isNotEmpty ? fullscreen : _generalNavigation).where(
(item) => item.id != 'more',
),
);
final knownIds = configured.map(_navigationIdentity).toSet();
for (final id in CanonicalModuleRoutes.ids) {
if (knownIds.contains('module:$id')) continue;
NavigationItem? configuredItem;
for (final item in _generalNavigation) {
if (_navigationIdentity(item) == 'module:$id') {
configuredItem = item;
break;
}
}
configured.add(configuredItem ?? _defaultNavigationItem(id));
knownIds.add('module:$id');
}
for (final resource in widget.snapshot.resources) {
final resourceIdentity = CanonicalModuleRoutes.contains(resource.id)
? 'module:${resource.id}'
: 'surface:${resource.id}';
if (knownIds.contains(resourceIdentity)) {
continue;
}
configured.add(
NavigationItem(
id: resource.id,
route:
CanonicalModuleRoutes.pathFor(resource.id) ?? '/${resource.id}/',
enabled: true,
label: resource.label,
labelKey: _resourceLabelKey(resource.id),
icon: _resourceIconKey(resource.id),
iconUrl: resource.iconUrl,
iconAlt: resource.iconAlt,
),
);
knownIds.add(resourceIdentity);
}
return _uniqueNavigation(configured);
}
NavigationItem _defaultNavigationItem(String id) {
ResourceDefinition? resource;
for (final candidate in widget.snapshot.resources) {
if (candidate.id == id) {
resource = candidate;
break;
}
}
return NavigationItem(
id: id,
route: CanonicalModuleRoutes.pathFor(id) ?? '/$id/',
enabled: true,
labelKey: switch (id) {
'places' => 'navPlaces',
'events' => 'navEvents',
'map' => 'navMap',
'classifieds' => 'moduleClassifieds',
'jobs' => 'moduleJobs',
'real-estate' => 'moduleRealEstate',
_ => 'navMore',
},
icon: switch (id) {
'places' => 'place',
'events' => 'event',
'map' => 'map',
'classifieds' => 'sell',
'jobs' => 'work',
'real-estate' => 'home_work',
_ => 'grid_view',
},
iconUrl: resource?.iconUrl ?? '',
iconAlt: resource?.iconAlt ?? '',
primary: true,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final primary = _primaryNavigation;
final visibleNavigation = _activeModuleId == null
? primary
: [...primary, _defaultNavigationItem(_activeModuleId!)];
final destinations = visibleNavigation
.map(_destinationForNavigationItem)
.toList();
final selected = _activeModuleId != null
? visibleNavigation.length - 1
: destinations.isEmpty
? 0
: _selectedIndex.clamp(0, destinations.length - 1);
final selectedItem = visibleNavigation.isEmpty
? null
: visibleNavigation[selected];
final selectedModuleId = selectedItem == null
? null
: _canonicalModuleId(selectedItem);
final activeDestination = _showHome || destinations.isEmpty
? const _Destination.start()
: destinations[selected];
return Theme(
data: AppTheme.light(colors: widget.snapshot.config.colors),
child: Scaffold(
backgroundColor: Color(widget.snapshot.config.colors.surface),
appBar: _showHome || selectedItem == null
? AppBar(
centerTitle: true,
title: _BrandHeader(
brand: widget.snapshot.config.brand,
fallback: l10n.brandFallback,
),
actions: _offlineActions(l10n),
)
: AppBar(
leading: BackButton(
onPressed: () => setState(() {
_activeModuleId = null;
_showHome = true;
}),
),
title: Text(_labelFor(selectedItem)),
actions: [
if (selectedModuleId != null &&
CanonicalModuleRoutes.isGeoResource(selectedModuleId))
IconButton(
tooltip: l10n.mapView,
icon: const Icon(Icons.map_outlined),
onPressed: () => _openMapForModule(selectedModuleId),
),
..._offlineActions(l10n),
],
),
body: Column(
children: [
if (widget.snapshot.isOffline && widget.onRetryBootstrap != null)
_BootstrapOfflineBanner(onRetry: widget.onRetryBootstrap!),
if (_showLocationPrompt)
_LocationStartupBanner(
onAllow: () => _handleLocationPrompt(true),
onLater: () => _handleLocationPrompt(false),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
// Keep one active page in the shell. The previous
// conditional IndexedStack removed Home and re-used its
// element slots when a bottom item was selected. In the
// release web renderer that left every non-Home surface as
// an unpainted gray body although the shell chrome stayed
// visible. The active destination is already derived from
// the same canonical navigation list, so a direct page is
// deterministic and keeps all routes on one shell path.
child: SizedBox.expand(child: _pageFor(activeDestination)),
),
),
),
],
),
bottomNavigationBar: AppNavigationBar(
items: primary,
selectedIndex: _showHome || _activeModuleId != null ? -1 : selected,
onSelected: (index) => setState(() {
_activeModuleId = null;
_selectedIndex = index;
_showHome = false;
}),
onOpenMenu: _openMenu,
brand: widget.snapshot.config.brand,
labelBuilder: _labelFor,
showLabels: _showHome,
),
),
);
}
List<Widget> _offlineActions(AppLocalizations l10n) => [
if (widget.snapshot.isOffline)
Padding(
padding: const EdgeInsets.only(right: 16),
child: Icon(Icons.cloud_off_outlined, semanticLabel: l10n.offlineTitle),
),
];
Future<void> _openMenu() async {
final selected = await Navigator.of(context).push<NavigationItem>(
MaterialPageRoute<NavigationItem>(
builder: (_) => FullscreenModuleMenuPage(
items: _menuNavigation,
brand: widget.snapshot.config.brand,
labelBuilder: _labelFor,
),
),
);
if (!mounted || selected == null) return;
if (selected.id == 'start') {
setState(() {
_activeModuleId = null;
_showHome = true;
});
return;
}
final selectedModuleId = _canonicalModuleId(selected);
if (selectedModuleId != null) {
await _pushCanonicalModule(selectedModuleId);
return;
}
final primaryIndex = _primaryNavigation.indexWhere(
(item) => _navigationIdentity(item) == _navigationIdentity(selected),
);
if (primaryIndex >= 0) {
setState(() {
_selectedIndex = primaryIndex;
_showHome = false;
});
return;
}
if (selected.id == 'community') {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(title: Text(_labelFor(selected))),
body: _pageFor(const _Destination.community()),
),
),
);
return;
}
if (selected.id == 'map' ||
selected.id == 'saved' ||
selected.id == 'more') {
final destination = _destinationForNavigationItem(selected);
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(title: Text(_labelFor(selected))),
body: _pageFor(destination),
),
),
);
return;
}
if (_resourceFor(selected.id) != null) {
await _pushResource(selected.id, route: selected.route);
}
}
void _openDiscoveryFromHome() {
_pushCanonicalModule('places');
}
Future<void> _openResourceFromHome(String id) async {
await _pushResource(id);
}
Future<void> _openQuickItemFromHome(HomeQuickAccessItem item) async {
final route = item.route.trim();
if (route.isEmpty) return;
await _pushResource(route, initialCategory: item.category.trim());
}
ResourceDefinition? _resourceFor(String id) {
for (final resource in widget.snapshot.resources) {
if (resource.id == id) return resource;
}
return null;
}
Widget _canonicalModulePage(
String id, {
bool showRouteAppBar = false,
String initialCategory = '',
}) {
final resource =
_resourceFor(id) ??
ResourceDefinition(
id: id,
label: '',
endpoint: '',
geo: CanonicalModuleRoutes.isGeoResource(id),
);
return ResourceBrowserPage(
resource: resource,
repository: widget.repository,
geoRepository: widget.geoRepository,
geoConfig: widget.snapshot.config.geo,
showRouteAppBar: showRouteAppBar,
initialCategory: initialCategory,
locationController: _locationController,
fallbackHero: widget.snapshot.config.hero,
fallbackHeroImageUrl: widget.snapshot.config.hero.imageUrl,
imageSuggestionRepository: widget.imageSuggestionRepository,
accountRepository: widget.accountRepository,
categorySchema: widget.snapshot.config.contentCategories[id],
fieldSchemas: widget.snapshot.config.contentSchemas[id] ?? const {},
fieldIcons: widget.snapshot.config.fieldIcons,
surfaceHero: widget.snapshot.config.surfaceHeroes[id],
);
}
Future<void> _pushCanonicalModule(
String id, {
String initialCategory = '',
}) async {
if (!mounted || !CanonicalModuleRoutes.contains(id)) return;
_initialCategories[id] = initialCategory;
final index = _primaryNavigation.indexWhere(
(item) => _canonicalModuleId(item) == id,
);
setState(() {
_activeModuleId = index >= 0 ? null : id;
if (index >= 0) _selectedIndex = index;
_showHome = false;
});
}
Future<void> _pushResource(
String id, {
String? route,
String initialCategory = '',
}) async {
if (!mounted) return;
if (CanonicalModuleRoutes.contains(id)) {
await _pushCanonicalModule(id, initialCategory: initialCategory);
return;
}
if (_resourceFor(id) == null) return;
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
settings: RouteSettings(name: route ?? '/$id/'),
builder: (_) =>
_canonicalModulePage(id, initialCategory: initialCategory),
),
);
}
Future<void> _openHomeItem(DiscoveryItem item) async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => ContentDetailPage(
item: item,
repository: widget.repository,
accountRepository: widget.accountRepository,
imageSuggestionRepository: widget.imageSuggestionRepository,
geoConfig: widget.snapshot.config.geo,
userLatitude: _locationController.coordinate?.latitude,
userLongitude: _locationController.coordinate?.longitude,
onOpenMap: () => _openMapForItem(item),
fieldSchemas:
widget.snapshot.config.contentSchemas[item.resource] ?? const {},
fieldIcons: widget.snapshot.config.fieldIcons,
),
),
);
}
Future<void> _openMapForItem(DiscoveryItem item) async {
if (!CanonicalModuleRoutes.isGeoResource(item.resource)) return;
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context).mapView)),
body: DiscoveryPage(
repository: widget.repository,
types: [item.resource],
geoRepository: widget.geoRepository,
geoConfig: widget.snapshot.config.geo,
accountRepository: widget.accountRepository,
imageSuggestionRepository: widget.imageSuggestionRepository,
locationController: _locationController,
showMapInitially: true,
showTitle: false,
initialFocusItem: item,
categorySchema:
widget.snapshot.config.contentCategories[item.resource],
fieldSchemas:
widget.snapshot.config.contentSchemas[item.resource] ??
const {},
fieldIcons: widget.snapshot.config.fieldIcons,
titleBuilder: (l10n) => l10n.mapView,
),
),
),
);
}
Future<void> _openMapForModule(String moduleId) async {
if (!mounted || !CanonicalModuleRoutes.isGeoResource(moduleId)) return;
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context).mapView)),
body: DiscoveryPage(
repository: widget.repository,
types: [moduleId],
geoRepository: widget.geoRepository,
geoConfig: widget.snapshot.config.geo,
accountRepository: widget.accountRepository,
imageSuggestionRepository: widget.imageSuggestionRepository,
locationController: _locationController,
showMapInitially: true,
showTitle: false,
categorySchema: widget.snapshot.config.contentCategories[moduleId],
fieldSchemas:
widget.snapshot.config.contentSchemas[moduleId] ?? const {},
fieldIcons: widget.snapshot.config.fieldIcons,
titleBuilder: (l10n) => l10n.mapView,
),
),
),
);
}
Future<void> _openCommunityFromHome() async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).navCommunity),
),
body: _pageFor(const _Destination.community()),
),
),
);
}
String _labelFor(NavigationItem item) {
final l10n = AppLocalizations.of(context);
final canonicalId = _canonicalModuleId(item) ?? item.id;
final canonicalLabel = switch (canonicalId) {
'places' => l10n.navPlaces,
'services' => l10n.moduleServices,
'events' => l10n.navEvents,
'classifieds' => l10n.moduleClassifieds,
'jobs' => l10n.moduleJobs,
'real-estate' => l10n.moduleRealEstate,
'news' => l10n.moduleNews,
'guides' => l10n.moduleGuides,
_ => null,
};
if (canonicalLabel != null) return canonicalLabel;
return switch (item.labelKey) {
'navStart' => l10n.navStart,
'navPlaces' => l10n.navPlaces,
'navMap' => l10n.navMap,
'navEvents' => l10n.navEvents,
'navCommunity' => l10n.navCommunity,
'navSaved' => l10n.navSaved,
'navMore' => l10n.navMore,
'moduleServices' => l10n.moduleServices,
'moduleClassifieds' => l10n.moduleClassifieds,
'moduleJobs' => l10n.moduleJobs,
'moduleRealEstate' => l10n.moduleRealEstate,
'moduleNews' => l10n.moduleNews,
'moduleGuides' => l10n.moduleGuides,
_ =>
isTechnicalContentLabel(item.label, item.id)
? l10n.contentTypeOther
: item.label,
};
}
String _resourceLabelKey(String id) => switch (id) {
'places' => 'navPlaces',
'events' => 'navEvents',
'services' => 'moduleServices',
'classifieds' => 'moduleClassifieds',
'jobs' => 'moduleJobs',
'real-estate' => 'moduleRealEstate',
'news' => 'moduleNews',
'guides' => 'moduleGuides',
_ => '',
};
String _resourceIconKey(String id) => switch (id) {
'places' => 'place',
'events' => 'event',
'services' => 'account_balance',
'classifieds' => 'sell',
'jobs' => 'work',
'real-estate' => 'home_work',
'news' => 'article',
'guides' => 'menu_book',
_ => 'grid_view',
};
_Destination _destinationFor(String id) {
if (CanonicalModuleRoutes.contains(id) || _resourceFor(id) != null) {
return _Destination.resource(id);
}
switch (id) {
case 'map':
return const _Destination.map();
case 'community':
return const _Destination.community();
case 'saved':
return const _Destination.saved();
case 'more':
return const _Destination.more();
case 'start':
default:
return const _Destination.start();
}
}
_Destination _destinationForNavigationItem(NavigationItem item) {
final moduleId = _canonicalModuleId(item);
if (moduleId != null) return _Destination.resource(moduleId);
return _destinationFor(item.id);
}
Widget _pageFor(_Destination destination) {
switch (destination.kind) {
case _DestinationKind.start:
return HomePage(
repository: widget.repository,
brand: widget.snapshot.config.brand,
hero: widget.snapshot.config.hero,
resources: widget.snapshot.resources,
locationController: _locationController,
homeSections: widget.snapshot.config.homeSections,
onOpenDiscovery: _openDiscoveryFromHome,
onOpenResource: _openResourceFromHome,
onOpenQuickItem: _openQuickItemFromHome,
onOpenItem: _openHomeItem,
onOpenCommunity: _openCommunityFromHome,
);
case _DestinationKind.resource:
final resourceId = destination.resourceId!;
return _canonicalModulePage(
resourceId,
initialCategory: _initialCategories[resourceId] ?? '',
);
case _DestinationKind.map:
return DiscoveryPage(
repository: widget.repository,
// The global map is the shared geo-discovery surface. Dedicated
// module pages still pass exactly one route and therefore retain
// their own category and typed-filter contracts.
types: CanonicalModuleRoutes.ids,
geoRepository: widget.geoRepository,
geoConfig: widget.snapshot.config.geo,
imageSuggestionRepository: widget.imageSuggestionRepository,
locationController: _locationController,
accountRepository: widget.accountRepository,
fieldSchemas: const {},
showMapInitially: true,
titleBuilder: (l10n) => l10n.mapView,
);
case _DestinationKind.community:
return const CommunityPage();
case _DestinationKind.saved:
return SavedPage(
accountRepository: widget.accountRepository,
discoveryRepository: widget.repository,
geoRepository: widget.geoRepository,
geoConfig: widget.snapshot.config.geo,
imageSuggestionRepository: widget.imageSuggestionRepository,
locationController: _locationController,
contentSchemas: widget.snapshot.config.contentSchemas,
contentCategories: widget.snapshot.config.contentCategories,
fieldIcons: widget.snapshot.config.fieldIcons,
);
case _DestinationKind.more:
return MorePage(
localeController: widget.localeController,
snapshot: widget.snapshot,
accountRepository: widget.accountRepository,
claimRepository: widget.claimRepository,
discoveryRepository: widget.repository,
geoRepository: widget.geoRepository,
imageSuggestionRepository: widget.imageSuggestionRepository,
notificationsRepository: widget.notificationsRepository,
// The More page also exposes secondary resources such as News and
// Guides. They are intentionally not part of the primary canonical
// module navigation, but they still need the same resource opener
// so their rows do not become inert.
onOpenModule: _pushResource,
);
}
}
}
class _BrandHeader extends StatelessWidget {
const _BrandHeader({required this.brand, required this.fallback});
final BrandConfig brand;
final String fallback;
@override
Widget build(BuildContext context) {
final imageUrl = brand.logoLightUrl.isNotEmpty
? brand.logoLightUrl
: brand.logoUrl;
if (imageUrl.isEmpty) {
return Text(
brand.wordmark.isEmpty ? fallback : brand.wordmark,
overflow: TextOverflow.ellipsis,
);
}
return SizedBox(
height: 34,
width: 140,
child: Image.network(
imageUrl,
fit: BoxFit.contain,
semanticLabel: brand.logoLightAlt.isNotEmpty
? brand.logoLightAlt
: brand.logoAlt,
errorBuilder: (_, _, _) => Text(
brand.wordmark.isEmpty ? fallback : brand.wordmark,
overflow: TextOverflow.ellipsis,
),
),
);
}
}
class _BootstrapOfflineBanner extends StatelessWidget {
const _BootstrapOfflineBanner({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final colors = Theme.of(context).colorScheme;
return Material(
color: colors.errorContainer,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.cloud_off_outlined, color: colors.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.offlineTitle,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colors.onErrorContainer,
),
),
Text(
l10n.offlineBody,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.onErrorContainer,
),
),
],
),
),
TextButton(onPressed: onRetry, child: Text(l10n.retry)),
],
),
),
);
}
}
class _LocationStartupBanner extends StatelessWidget {
const _LocationStartupBanner({required this.onAllow, required this.onLater});
final VoidCallback onAllow;
final VoidCallback onLater;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
return Material(
color: scheme.surfaceContainerLowest,
elevation: 2,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.my_location_outlined, color: scheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.locationStartupTitle,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(l10n.locationStartupBody),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
TextButton(onPressed: onLater, child: Text(l10n.later)),
FilledButton(
onPressed: onAllow,
child: Text(l10n.allowLocation),
),
],
),
],
),
),
],
),
),
);
}
}
enum _DestinationKind { start, map, resource, community, saved, more }
class _Destination {
const _Destination(this.kind, {this.resourceId});
const _Destination.start() : this(_DestinationKind.start);
const _Destination.map() : this(_DestinationKind.map);
const _Destination.resource(String id)
: this(_DestinationKind.resource, resourceId: id);
const _Destination.community() : this(_DestinationKind.community);
const _Destination.saved() : this(_DestinationKind.saved);
const _Destination.more() : this(_DestinationKind.more);
final _DestinationKind kind;
final String? resourceId;
}
