This commit is contained in:
@@ -26,7 +26,7 @@ class _AdminVoiceExperiencesScreenState
|
||||
final tokens = context.mapflowTokens;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: tokens.mapPanel,
|
||||
backgroundColor: tokens.darkSurface,
|
||||
appBar: AppBar(title: const Text('Отзывы')),
|
||||
body: FutureBuilder<List<VoiceExperienceDebug>>(
|
||||
future: _future,
|
||||
@@ -64,8 +64,11 @@ class _AdminVoiceExperienceRow extends StatelessWidget {
|
||||
final tokens = context.mapflowTokens;
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||
color: tokens.mapPanel,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||
side: BorderSide(color: tokens.mapPanelBorder),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
|
||||
@@ -99,7 +99,7 @@ class _MapContent extends StatelessWidget {
|
||||
point: place.coordinate,
|
||||
child: _PlaceMarker(
|
||||
selected: selected?.id == place.id,
|
||||
onTap: () => placeCubit.selectPlace(place.id),
|
||||
onTap: () => _selectAndShowPlace(context, place),
|
||||
),
|
||||
),
|
||||
if (userCoordinate != null)
|
||||
@@ -162,7 +162,8 @@ class _MapContent extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(bottom: 70),
|
||||
child: _PlaceCarousel(
|
||||
places: state.recommendations,
|
||||
onSelect: (place) => placeCubit.selectPlace(place.id),
|
||||
onSelect: (place) =>
|
||||
_selectAndShowPlace(context, place),
|
||||
onFavoriteToggle: placeCubit.toggleFavorite,
|
||||
),
|
||||
)
|
||||
@@ -226,6 +227,48 @@ class _MapContent extends StatelessWidget {
|
||||
void _openAddFlow(BuildContext context, LatLng? coordinate) {
|
||||
context.push(MapflowRoutes.addExperienceLocation(coordinate: coordinate));
|
||||
}
|
||||
|
||||
void _selectAndShowPlace(BuildContext context, PlaceRecommendation place) {
|
||||
final placeCubit = context.read<PlaceCubit>()..selectPlace(place.id);
|
||||
unawaited(
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
return _PlaceDetailsSheet(
|
||||
place: place,
|
||||
onFavoriteToggle: () => placeCubit.toggleFavorite(place),
|
||||
onPhotoTap: (index) =>
|
||||
_openPhotoViewer(sheetContext, place.photoUrls, index),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openPhotoViewer(
|
||||
BuildContext context,
|
||||
List<String> photoUrls,
|
||||
int initialIndex,
|
||||
) {
|
||||
if (photoUrls.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierColor: Colors.black,
|
||||
builder: (_) {
|
||||
return _PlacePhotoViewer(
|
||||
photoUrls: photoUrls,
|
||||
initialIndex: initialIndex,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<PlaceTrait, int> _countPlaceTraits(List<PlaceRecommendation> places) {
|
||||
@@ -256,7 +299,7 @@ class _UserLocationMarker extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
border: Border.all(color: context.mapflowTokens.mapPanel, width: 3),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -373,6 +416,7 @@ class _AddReviewAction extends StatelessWidget {
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: tokens.mapPanel,
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
side: BorderSide(color: tokens.mapPanelBorder),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
),
|
||||
),
|
||||
@@ -394,7 +438,10 @@ class _ActiveRecommendationChip extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(top: 62),
|
||||
child: Material(
|
||||
color: tokens.mapPanel,
|
||||
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||
side: BorderSide(color: tokens.mapPanelBorder),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
|
||||
child: Row(
|
||||
@@ -441,6 +488,10 @@ class _SearchLauncherButton extends StatelessWidget {
|
||||
label: const Text('Поиск'),
|
||||
backgroundColor: tokens.mapPanel,
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||
side: BorderSide(color: tokens.mapPanelBorder),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -884,9 +935,9 @@ class _TraitPickerSheet extends StatelessWidget {
|
||||
label: Text('${item.label} ${traitCounts[item] ?? 0}'),
|
||||
selected: item == selectedTrait,
|
||||
onSelected: (_) => onSelect(item),
|
||||
backgroundColor: tokens.mapPanel,
|
||||
backgroundColor: tokens.darkSurfaceAlt,
|
||||
selectedColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
side: BorderSide.none,
|
||||
side: BorderSide(color: tokens.mapPanelBorder),
|
||||
shape: const StadiumBorder(),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
@@ -915,7 +966,7 @@ class _PlaceMarker extends StatelessWidget {
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? color : Colors.white,
|
||||
color: selected ? color : tokens.mapPanel,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color, width: selected ? 3 : 2),
|
||||
boxShadow: [
|
||||
@@ -926,7 +977,7 @@ class _PlaceMarker extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(Icons.place, color: selected ? Colors.white : color),
|
||||
child: Icon(Icons.place, color: selected ? tokens.onDark : color),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -969,6 +1020,413 @@ class _PlaceCarousel extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaceDetailsSheet extends StatefulWidget {
|
||||
const _PlaceDetailsSheet({
|
||||
required this.place,
|
||||
required this.onFavoriteToggle,
|
||||
required this.onPhotoTap,
|
||||
});
|
||||
|
||||
final PlaceRecommendation place;
|
||||
final VoidCallback onFavoriteToggle;
|
||||
final ValueChanged<int> onPhotoTap;
|
||||
|
||||
@override
|
||||
State<_PlaceDetailsSheet> createState() => _PlaceDetailsSheetState();
|
||||
}
|
||||
|
||||
class _PlaceDetailsSheetState extends State<_PlaceDetailsSheet> {
|
||||
late var _isFavorite = widget.place.isFavorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.mapflowTokens;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final openingLabel = _openingLabel(widget.place);
|
||||
final weekdayDescriptions = _weekdayDescriptions(widget.place);
|
||||
final typeLabel = _placeTypeLabel(widget.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),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (widget.place.googleRating != null)
|
||||
_InfoChip(
|
||||
icon: Icons.star_rounded,
|
||||
label: _ratingLabel(widget.place),
|
||||
),
|
||||
if (openingLabel != null)
|
||||
_InfoChip(
|
||||
icon: Icons.schedule_outlined,
|
||||
label: openingLabel,
|
||||
highlighted: openingLabel == 'Открыто сейчас',
|
||||
),
|
||||
if (widget.place.googleBusinessStatus != null)
|
||||
_InfoChip(
|
||||
icon: Icons.storefront_outlined,
|
||||
label: _businessStatusLabel(
|
||||
widget.place.googleBusinessStatus!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.place.traits.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (weekdayDescriptions.isNotEmpty) ...[
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
'Режим работы',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.55,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final line in weekdayDescriptions)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Text(
|
||||
line,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleFavorite() {
|
||||
setState(() => _isFavorite = !_isFavorite);
|
||||
widget.onFavoriteToggle();
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaceDetailsPhotos extends StatelessWidget {
|
||||
const _PlaceDetailsPhotos({
|
||||
required this.photoUrls,
|
||||
required this.onPhotoTap,
|
||||
});
|
||||
|
||||
final List<String> photoUrls;
|
||||
final ValueChanged<int> onPhotoTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.mapflowTokens;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
if (photoUrls.isEmpty) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.mapPanelBorder,
|
||||
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.place_outlined,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 210,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: photoUrls.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
return GestureDetector(
|
||||
onTap: () => onPhotoTap(index),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 4 / 3,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||
child: Image.network(
|
||||
photoUrls[index],
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) {
|
||||
return child;
|
||||
}
|
||||
return ColoredBox(
|
||||
color: tokens.mapPanelBorder,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, _, _) => ColoredBox(
|
||||
color: tokens.mapPanelBorder,
|
||||
child: Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlacePhotoViewer extends StatelessWidget {
|
||||
const _PlacePhotoViewer({
|
||||
required this.photoUrls,
|
||||
required this.initialIndex,
|
||||
});
|
||||
|
||||
final List<String> photoUrls;
|
||||
final int initialIndex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = PageController(initialPage: initialIndex);
|
||||
return Dialog.fullscreen(
|
||||
backgroundColor: Colors.black,
|
||||
child: Stack(
|
||||
children: [
|
||||
PageView.builder(
|
||||
controller: controller,
|
||||
itemCount: photoUrls.length,
|
||||
itemBuilder: (context, index) {
|
||||
return InteractiveViewer(
|
||||
minScale: 1,
|
||||
maxScale: 4,
|
||||
child: Center(
|
||||
child: Image.network(
|
||||
photoUrls[index],
|
||||
fit: BoxFit.contain,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) {
|
||||
return child;
|
||||
}
|
||||
return const CircularProgressIndicator();
|
||||
},
|
||||
errorBuilder: (_, _, _) => const Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: Colors.white,
|
||||
size: 42,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: IconButton.filled(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Закрыть',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoChip extends StatelessWidget {
|
||||
const _InfoChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.highlighted = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool highlighted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final foreground = highlighted
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant;
|
||||
return Chip(
|
||||
avatar: Icon(icon, size: 16, color: foreground),
|
||||
label: Text(label),
|
||||
labelStyle: TextStyle(color: foreground, fontWeight: FontWeight.w700),
|
||||
side: BorderSide.none,
|
||||
backgroundColor: highlighted
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _ratingLabel(PlaceRecommendation place) {
|
||||
final rating = place.googleRating?.toStringAsFixed(1);
|
||||
final count = place.googleUserRatingCount;
|
||||
if (rating == null) {
|
||||
return '';
|
||||
}
|
||||
if (count == null || count == 0) {
|
||||
return rating;
|
||||
}
|
||||
return '$rating ($count)';
|
||||
}
|
||||
|
||||
String? _openingLabel(PlaceRecommendation place) {
|
||||
final value = place.googleCurrentOpeningHours?['openNow'];
|
||||
if (value is bool) {
|
||||
return value ? 'Открыто сейчас' : 'Сейчас закрыто';
|
||||
}
|
||||
final legacyValue = place.googleCurrentOpeningHours?['open_now'];
|
||||
if (legacyValue is bool) {
|
||||
return legacyValue ? 'Открыто сейчас' : 'Сейчас закрыто';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> _weekdayDescriptions(PlaceRecommendation place) {
|
||||
final current = _stringList(
|
||||
place.googleCurrentOpeningHours?['weekdayDescriptions'],
|
||||
);
|
||||
if (current.isNotEmpty) {
|
||||
return current;
|
||||
}
|
||||
final regular = _stringList(
|
||||
place.googleRegularOpeningHours?['weekdayDescriptions'],
|
||||
);
|
||||
if (regular.isNotEmpty) {
|
||||
return regular;
|
||||
}
|
||||
final legacy = _stringList(place.googleRegularOpeningHours?['weekday_text']);
|
||||
if (legacy.isNotEmpty) {
|
||||
return legacy;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
List<String> _stringList(Object? value) {
|
||||
if (value is! List) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
for (final item in value)
|
||||
if (item is String && item.trim().isNotEmpty) item,
|
||||
];
|
||||
}
|
||||
|
||||
String? _placeTypeLabel(PlaceRecommendation place) {
|
||||
final raw =
|
||||
place.googlePrimaryType ??
|
||||
(place.googleTypes.isEmpty ? null : place.googleTypes.first);
|
||||
if (raw == null || raw.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return raw.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
String _businessStatusLabel(String status) {
|
||||
return switch (status) {
|
||||
'OPERATIONAL' => 'Работает',
|
||||
'CLOSED_TEMPORARILY' => 'Временно закрыто',
|
||||
'CLOSED_PERMANENTLY' => 'Закрыто',
|
||||
_ => status.replaceAll('_', ' ').toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
class AddExperienceFlow extends StatefulWidget {
|
||||
const AddExperienceFlow({super.key, required this.coordinate});
|
||||
|
||||
@@ -1571,11 +2029,11 @@ class _VoiceStep extends StatelessWidget {
|
||||
for (final hint in promptHints)
|
||||
Chip(
|
||||
label: Text(hint),
|
||||
side: BorderSide.none,
|
||||
backgroundColor: tokens.onDark.withValues(alpha: 0.12),
|
||||
side: BorderSide(color: tokens.voiceAccent),
|
||||
backgroundColor: tokens.voiceAccent,
|
||||
labelStyle: TextStyle(
|
||||
color: tokens.onDark.withValues(alpha: 0.92),
|
||||
fontWeight: FontWeight.w700,
|
||||
color: tokens.onVoiceControl,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -18,7 +18,6 @@ class PlacePhotoCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.mapflowTokens;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final photoUrls = place.photoUrls.take(3).toList();
|
||||
|
||||
return GestureDetector(
|
||||
@@ -27,7 +26,7 @@ class PlacePhotoCard extends StatelessWidget {
|
||||
width: 226,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||
border: Border.all(color: colorScheme.surface),
|
||||
border: Border.all(color: tokens.mapPanelBorder),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: tokens.mapShadow,
|
||||
@@ -42,8 +41,8 @@ class PlacePhotoCard extends StatelessWidget {
|
||||
children: [
|
||||
if (photoUrls.isEmpty)
|
||||
ColoredBox(
|
||||
color: colorScheme.primary,
|
||||
child: Icon(Icons.place_outlined, color: colorScheme.onPrimary),
|
||||
color: tokens.mapPanelBorder,
|
||||
child: Icon(Icons.place_outlined, color: tokens.onDark),
|
||||
)
|
||||
else
|
||||
_PhotoTriptych(
|
||||
@@ -152,12 +151,32 @@ class _PhotoTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Image.network(
|
||||
url,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return ColoredBox(
|
||||
color: fallbackColor,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, _, _) => ColoredBox(
|
||||
color: fallbackColor,
|
||||
child: const Icon(Icons.place_outlined),
|
||||
child: Icon(Icons.broken_image_outlined, color: colorScheme.outline),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user