Add favorites search and enriched places UI
Build and deploy Flutter Web / build (push) Successful in 2m41s

This commit is contained in:
Ruslan Bakiev
2026-06-12 21:09:15 +07:00
parent 8aa2a43cc7
commit 318126d83a
58 changed files with 11222 additions and 238 deletions
@@ -111,6 +111,9 @@ class _MapContent extends StatelessWidget {
alignment: Alignment.topLeft,
child: _UserAvatar(
user: state.currentUser,
onAdminReviews: state.currentUser?.isAdmin == true
? () => context.push('/admin/reviews')
: null,
onLogout: () {
telegram_session.clearMapflowSession();
placeCubit.load();
@@ -119,15 +122,29 @@ class _MapContent extends StatelessWidget {
),
),
),
if (state.currentUser?.isAdmin == true)
SafeArea(
child: Align(
alignment: Alignment.topRight,
child: _AdminReviewsButton(
onPressed: () => context.push('/admin/reviews'),
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: _MapSearchButton(
query: state.searchQuery,
onSearch: placeCubit.searchPlaces,
onClear: state.searchQuery.isEmpty
? null
: placeCubit.clearSearch,
),
),
),
SafeArea(
child: Align(
alignment: Alignment.topRight,
child: _AddReviewButton(
onPressed: () => _openAddFlow(
context,
userCoordinate ?? selected?.coordinate,
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
@@ -144,26 +161,12 @@ class _MapContent extends StatelessWidget {
_PlaceCarousel(
places: state.recommendations,
onSelect: (place) => placeCubit.selectPlace(place.id),
onFavoriteToggle: placeCubit.toggleFavorite,
),
],
),
),
),
SafeArea(
child: Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 12),
child: FloatingActionButton(
onPressed: () => _openAddFlow(
context,
userCoordinate ?? selected?.coordinate,
),
child: const Icon(Icons.add_location_alt_outlined),
),
),
),
),
],
),
);
@@ -217,9 +220,14 @@ class _UserLocationMarker extends StatelessWidget {
}
class _UserAvatar extends StatelessWidget {
const _UserAvatar({required this.user, required this.onLogout});
const _UserAvatar({
required this.user,
required this.onAdminReviews,
required this.onLogout,
});
final AppUser? user;
final VoidCallback? onAdminReviews;
final VoidCallback onLogout;
@override
@@ -238,12 +246,26 @@ class _UserAvatar extends StatelessWidget {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onSelected: (action) {
switch (action) {
case _AvatarAction.adminReviews:
onAdminReviews?.call();
case _AvatarAction.logout:
onLogout();
}
},
itemBuilder: (_) => const [
PopupMenuItem<_AvatarAction>(
itemBuilder: (_) => [
if (onAdminReviews != null)
const PopupMenuItem<_AvatarAction>(
value: _AvatarAction.adminReviews,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.table_rows_outlined, size: 18),
SizedBox(width: 10),
Text('Отзывы'),
],
),
),
const PopupMenuItem<_AvatarAction>(
value: _AvatarAction.logout,
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -287,8 +309,8 @@ class _UserAvatar extends StatelessWidget {
}
}
class _AdminReviewsButton extends StatelessWidget {
const _AdminReviewsButton({required this.onPressed});
class _AddReviewButton extends StatelessWidget {
const _AddReviewButton({required this.onPressed});
final VoidCallback onPressed;
@@ -299,24 +321,115 @@ class _AdminReviewsButton extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(top: 8, right: 12),
child: FilledButton.icon(
child: IconButton.filled(
onPressed: onPressed,
icon: const Icon(Icons.table_rows_outlined, size: 18),
label: const Text('Отзывы'),
icon: const Icon(Icons.add_location_alt_outlined),
style: FilledButton.styleFrom(
backgroundColor: tokens.mapPanel,
foregroundColor: colorScheme.onSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.panelRadius),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
tooltip: 'Добавить отзыв',
),
);
}
}
enum _AvatarAction { logout }
class _MapSearchButton extends StatelessWidget {
const _MapSearchButton({
required this.query,
required this.onSearch,
required this.onClear,
});
final String query;
final Future<void> Function(String) onSearch;
final Future<void> Function()? onClear;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final label = query.isEmpty ? 'Поиск' : query;
return Padding(
padding: const EdgeInsets.only(top: 8, left: 70, right: 70),
child: Material(
color: tokens.mapPanel,
borderRadius: BorderRadius.circular(tokens.panelRadius),
child: InkWell(
onTap: () => _openSearch(context),
borderRadius: BorderRadius.circular(tokens.panelRadius),
child: SizedBox(
height: 44,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 12),
const Icon(Icons.auto_awesome_outlined, size: 18),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
if (onClear != null) ...[
const SizedBox(width: 2),
IconButton(
onPressed: () => onClear!(),
icon: const Icon(Icons.close, size: 18),
tooltip: 'Сбросить',
),
] else
const SizedBox(width: 12),
],
),
),
),
),
);
}
Future<void> _openSearch(BuildContext context) async {
final controller = TextEditingController(text: query);
final result = await showDialog<String>(
context: context,
builder: (context) {
return AlertDialog(
contentPadding: const EdgeInsets.fromLTRB(18, 18, 18, 8),
content: TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.search,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.auto_awesome_outlined),
hintText: 'Что ищем?',
),
onSubmitted: (value) => Navigator.of(context).pop(value),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text),
child: const Text('Найти'),
),
],
);
},
);
controller.dispose();
if (result == null) {
return;
}
await onSearch(result);
}
}
enum _AvatarAction { adminReviews, logout }
class _AvatarImage extends StatelessWidget {
const _AvatarImage({required this.url, required this.fallback});
@@ -624,10 +737,15 @@ class _PlaceMarker extends StatelessWidget {
}
class _PlaceCarousel extends StatelessWidget {
const _PlaceCarousel({required this.places, required this.onSelect});
const _PlaceCarousel({
required this.places,
required this.onSelect,
required this.onFavoriteToggle,
});
final List<PlaceRecommendation> places;
final ValueChanged<PlaceRecommendation> onSelect;
final ValueChanged<PlaceRecommendation> onFavoriteToggle;
@override
Widget build(BuildContext context) {
@@ -644,7 +762,11 @@ class _PlaceCarousel extends StatelessWidget {
separatorBuilder: (_, _) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final place = places[index];
return PlacePhotoCard(place: place, onTap: () => onSelect(place));
return PlacePhotoCard(
place: place,
onTap: () => onSelect(place),
onFavoriteToggle: () => onFavoriteToggle(place),
);
},
),
);
@@ -668,6 +790,14 @@ class AddExperienceFlow extends StatefulWidget {
class _AddExperienceFlowState extends State<AddExperienceFlow> {
static const _minimumInformationUnits = 16.0;
static const _nearbyPlaceRadiusMeters = 50;
static const _voicePromptHints = [
'атмосфера',
'еда',
'сервис',
'люди',
'шум',
'цены',
];
final _waveController = WaveformRecorderController(
interval: const Duration(milliseconds: 45),
@@ -691,6 +821,7 @@ class _AddExperienceFlowState extends State<AddExperienceFlow> {
var _voicePeakDb = -34.0;
var _liveLevel = 0.0;
DateTime? _lastInformationAt;
PlaceRecommendation? _selectedPlaceForSubmit;
@override
void initState() {
@@ -810,6 +941,7 @@ class _AddExperienceFlowState extends State<AddExperienceFlow> {
0 => _IntroStep(onNext: () => setState(() => _step = 1)),
1 => _VoiceStep(
placeName: '',
promptHints: _voicePromptHints,
hasTelegramAuth: widget.hasTelegramAuth,
informationProgress: informationProgress,
isRecording: _recording,
@@ -833,27 +965,21 @@ class _AddExperienceFlowState extends State<AddExperienceFlow> {
radiusMeters: _nearbyPlaceRadiusMeters,
isSubmitting: _submitting,
onSelect: (place) async {
setState(() => _submitting = true);
controller.setReviewPlace(place.name);
final file = _waveController.file;
if (file == null) {
throw StateError('Voice recording file is required.');
}
final bytes = await file.readAsBytes();
await controller.publishReview(
place: place,
audioObjectKey:
'web-recording-${DateTime.now().microsecondsSinceEpoch}-${file.name}',
audioContentBase64: base64Encode(bytes),
audioMimeType: file.mimeType ?? 'audio/wav',
);
if (!context.mounted) {
return;
}
context.pop();
setState(() {
_selectedPlaceForSubmit = place;
_step = 3;
});
},
),
};
final contentWithFavoriteStep = _step == 3
? _FavoriteStep(
place: _selectedPlaceForSubmit,
isSubmitting: _submitting,
onSubmit: _submitSelectedPlace,
)
: content;
return Scaffold(
backgroundColor: tokens.darkSurface,
@@ -869,7 +995,7 @@ class _AddExperienceFlowState extends State<AddExperienceFlow> {
children: [
_StoryProgress(
step: _step,
total: 3,
total: 4,
dark: true,
onClose: () => context.pop(),
),
@@ -877,7 +1003,10 @@ class _AddExperienceFlowState extends State<AddExperienceFlow> {
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
child: KeyedSubtree(key: ValueKey(_step), child: content),
child: KeyedSubtree(
key: ValueKey(_step),
child: contentWithFavoriteStep,
),
),
),
],
@@ -886,6 +1015,47 @@ class _AddExperienceFlowState extends State<AddExperienceFlow> {
),
);
}
Future<void> _submitSelectedPlace(bool addToFavorites) async {
final place = _selectedPlaceForSubmit;
if (place == null) {
throw StateError('Place is required to publish a review.');
}
setState(() => _submitting = true);
final controller = context.read<PlaceCubit>();
final file = _waveController.file;
if (file == null) {
throw StateError('Voice recording file is required.');
}
final bytes = await file.readAsBytes();
await controller.publishReview(
place: place,
audioObjectKey:
'web-recording-${DateTime.now().microsecondsSinceEpoch}-${file.name}',
audioContentBase64: base64Encode(bytes),
audioMimeType: file.mimeType ?? 'audio/wav',
addToFavorites: addToFavorites,
promptHintsShown: _voicePromptHints,
recordingPayload: {
'source': 'web',
'nearbyPlaceRadiusMeters': _nearbyPlaceRadiusMeters,
'informationUnits': _informationUnits,
'minimumInformationUnits': _minimumInformationUnits,
'selectedPlace': {
'googlePlaceId': place.googlePlaceId,
'name': place.name,
'googlePrimaryType': place.googlePrimaryType,
'googleTypes': place.googleTypes,
'latitude': place.coordinate.latitude,
'longitude': place.coordinate.longitude,
},
},
);
if (!mounted) {
return;
}
context.pop();
}
}
class _IntroStep extends StatelessWidget {
@@ -1020,6 +1190,71 @@ class _PlaceStep extends StatelessWidget {
}
}
class _FavoriteStep extends StatelessWidget {
const _FavoriteStep({
required this.place,
required this.isSubmitting,
required this.onSubmit,
});
final PlaceRecommendation? place;
final bool isSubmitting;
final Future<void> Function(bool) onSubmit;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final selectedPlace = place;
if (selectedPlace == null) {
return const SizedBox.shrink();
}
return _StepLayout(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.favorite_border, color: tokens.onDark, size: 48),
const SizedBox(height: 18),
Text(
selectedPlace.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: tokens.onDark,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
const SizedBox(height: 26),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: isSubmitting ? null : () => onSubmit(false),
child: const Text('Не сейчас'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: isSubmitting ? null : () => onSubmit(true),
child: isSubmitting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('В избранное'),
),
),
],
),
],
),
);
}
}
class _NearbyPlaceCard extends StatelessWidget {
const _NearbyPlaceCard({
required this.place,
@@ -1130,6 +1365,7 @@ class _PlaceTypeChip extends StatelessWidget {
class _VoiceStep extends StatelessWidget {
const _VoiceStep({
required this.placeName,
required this.promptHints,
required this.hasTelegramAuth,
required this.informationProgress,
required this.isRecording,
@@ -1142,6 +1378,7 @@ class _VoiceStep extends StatelessWidget {
});
final String placeName;
final List<String> promptHints;
final bool hasTelegramAuth;
final double informationProgress;
final bool isRecording;
@@ -1171,6 +1408,26 @@ class _VoiceStep extends StatelessWidget {
),
const SizedBox(height: 10),
],
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 6,
runSpacing: 6,
children: [
for (final hint in promptHints)
Chip(
label: Text(hint),
side: BorderSide.none,
backgroundColor: tokens.onDark.withValues(alpha: 0.12),
labelStyle: TextStyle(
color: tokens.onDark.withValues(alpha: 0.92),
fontWeight: FontWeight.w700,
),
),
],
),
),
Expanded(child: _VoiceProgressGrid(progress: informationProgress)),
if (!micAllowed)
Padding(