Add favorites search and enriched places UI
Build and deploy Flutter Web / build (push) Successful in 2m41s
Build and deploy Flutter Web / build (push) Successful in 2m41s
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user