Load place details lazily
Build and deploy Flutter Web / build (push) Successful in 4m9s

This commit is contained in:
Ruslan Bakiev
2026-06-22 14:03:29 +07:00
parent 3b77899d3e
commit c7c1ca62ef
65 changed files with 4019 additions and 2383 deletions
@@ -223,17 +223,31 @@ class _MapContent extends StatelessWidget {
void _selectAndShowPlace(BuildContext context, PlaceRecommendation place) {
final placeCubit = context.read<PlaceCubit>()..selectPlace(place.id);
final placeDetails = placeCubit.loadPlaceDetails(place);
unawaited(
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
isDismissible: true,
enableDrag: true,
showDragHandle: true,
builder: (sheetContext) {
return _PlaceDetailsSheet(
place: place,
onFavoriteToggle: () => placeCubit.toggleFavorite(place),
onPhotoTap: (index) =>
_openPhotoViewer(sheetContext, place.photoUrls, index),
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.64,
minChildSize: 0.24,
maxChildSize: 0.9,
builder: (_, scrollController) {
return _PlaceDetailsSheet(
place: place,
details: placeDetails,
scrollController: scrollController,
onFavoriteToggle: (selectedPlace) =>
placeCubit.toggleFavorite(selectedPlace),
onPhotoTap: (photoUrls, index) =>
_openPhotoViewer(sheetContext, photoUrls, index),
);
},
);
},
),
@@ -1094,13 +1108,17 @@ class _PlaceCarousel extends StatelessWidget {
class _PlaceDetailsSheet extends StatefulWidget {
const _PlaceDetailsSheet({
required this.place,
required this.details,
required this.scrollController,
required this.onFavoriteToggle,
required this.onPhotoTap,
});
final PlaceRecommendation place;
final VoidCallback onFavoriteToggle;
final ValueChanged<int> onPhotoTap;
final Future<PlaceRecommendation> details;
final ScrollController scrollController;
final ValueChanged<PlaceRecommendation> onFavoriteToggle;
final void Function(List<String> photoUrls, int index) onPhotoTap;
@override
State<_PlaceDetailsSheet> createState() => _PlaceDetailsSheetState();
@@ -1114,93 +1132,110 @@ class _PlaceDetailsSheetState extends State<_PlaceDetailsSheet> {
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final colorScheme = Theme.of(context).colorScheme;
final openingSummary = _openingSummary(widget.place);
final weekdayDescriptions = _weekdayDescriptions(widget.place);
final typeLabel = _placeTypeLabel(widget.place);
return FutureBuilder<PlaceRecommendation>(
future: widget.details,
builder: (context, snapshot) {
final place = snapshot.data ?? widget.place;
final loadingDetails =
snapshot.connectionState != ConnectionState.done &&
place.photoUrls.isEmpty;
final openingSummary = _openingSummary(place);
final weekdayDescriptions = _weekdayDescriptions(place);
final typeLabel = _placeTypeLabel(place);
return SafeArea(
top: false,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.86,
),
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 18),
children: [
_PlaceDetailsPhotos(
photoUrls: widget.place.photoUrls,
onPhotoTap: widget.onPhotoTap,
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.place.name,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
if (typeLabel != null) ...[
const SizedBox(height: 4),
Text(
typeLabel,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
],
),
),
IconButton.filledTonal(
onPressed: _toggleFavorite,
icon: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
),
tooltip: 'Избранное',
),
],
),
const SizedBox(height: 12),
if (openingSummary != null || weekdayDescriptions.isNotEmpty)
_OpeningHoursTile(
summary: openingSummary,
weekdayDescriptions: weekdayDescriptions,
expanded: _hoursExpanded,
onExpansionChanged: (value) =>
setState(() => _hoursExpanded = value),
return SafeArea(
top: false,
child: ListView(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 18),
children: [
_PlaceDetailsPhotos(
photoUrls: place.photoUrls,
loading: loadingDetails,
onPhotoTap: (index) =>
widget.onPhotoTap(place.photoUrls, index),
),
if (widget.place.traits.isNotEmpty) ...[
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final trait in widget.place.traits)
Chip(
avatar: Icon(trait.icon, size: 16),
label: Text(trait.label),
side: BorderSide(color: tokens.mapPanelBorder),
backgroundColor: tokens.mapPanel,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
place.name,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
if (typeLabel != null) ...[
const SizedBox(height: 4),
Text(
typeLabel,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
],
),
),
IconButton.filledTonal(
onPressed: _toggleFavorite,
icon: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
),
tooltip: 'Избранное',
),
],
),
const SizedBox(height: 12),
if (snapshot.hasError) ...[
Text(
'Не удалось загрузить детали заведения',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
],
if (openingSummary != null || weekdayDescriptions.isNotEmpty)
_OpeningHoursTile(
summary: openingSummary,
weekdayDescriptions: weekdayDescriptions,
expanded: _hoursExpanded,
onExpansionChanged: (value) =>
setState(() => _hoursExpanded = value),
),
if (place.traits.isNotEmpty) ...[
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final trait in place.traits)
Chip(
avatar: Icon(trait.icon, size: 16),
label: Text(trait.label),
side: BorderSide(color: tokens.mapPanelBorder),
backgroundColor: tokens.mapPanel,
),
],
),
],
],
],
),
),
),
);
},
);
}
void _toggleFavorite() {
setState(() => _isFavorite = !_isFavorite);
widget.onFavoriteToggle();
widget.onFavoriteToggle(widget.place);
}
}
@@ -1274,10 +1309,12 @@ class _OpeningHoursTile extends StatelessWidget {
class _PlaceDetailsPhotos extends StatelessWidget {
const _PlaceDetailsPhotos({
required this.photoUrls,
required this.loading,
required this.onPhotoTap,
});
final List<String> photoUrls;
final bool loading;
final ValueChanged<int> onPhotoTap;
@override
@@ -1292,10 +1329,20 @@ class _PlaceDetailsPhotos extends StatelessWidget {
color: tokens.mapPanelBorder,
borderRadius: BorderRadius.circular(tokens.panelRadius),
),
child: Icon(
Icons.place_outlined,
color: colorScheme.onSurfaceVariant,
size: 40,
child: Center(
child: loading
? SizedBox.square(
dimension: 28,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: colorScheme.primary,
),
)
: Icon(
Icons.place_outlined,
color: colorScheme.onSurfaceVariant,
size: 40,
),
),
),
);