Load map places from backend
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 3m26s

This commit is contained in:
Ruslan Bakiev
2026-05-08 15:54:15 +07:00
parent 4fb691135d
commit 238521b11b
10 changed files with 370 additions and 996 deletions

View File

@@ -11,11 +11,27 @@ import '../state/place_controller.dart';
class MapflowShell extends ConsumerWidget {
const MapflowShell({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncState = ref.watch(placeControllerProvider);
return asyncState.when(
data: (state) => _MapContent(state: state),
loading: () => const _MapLoading(),
error: (error, _) => _MapError(message: error.toString()),
);
}
}
class _MapContent extends ConsumerWidget {
const _MapContent({required this.state});
static const _center = LatLng(10.7718, 106.6982);
final PlaceState state;
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(placeControllerProvider);
final selected = state.selectedPlace;
return Scaffold(
@@ -101,6 +117,76 @@ class MapflowShell extends ConsumerWidget {
}
}
class _MapLoading extends StatelessWidget {
const _MapLoading();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
FlutterMap(
options: const MapOptions(
initialCenter: LatLng(10.7718, 106.6982),
initialZoom: 14.2,
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.mapflow.app',
),
],
),
const Center(child: CircularProgressIndicator()),
],
),
);
}
}
class _MapError extends StatelessWidget {
const _MapError({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
FlutterMap(
options: const MapOptions(
initialCenter: LatLng(10.7718, 106.6982),
initialZoom: 14.2,
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.mapflow.app',
),
],
),
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12),
color: const Color(0xFFFFFBF5),
child: Text(
message,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
),
),
],
),
);
}
}
class _IntentBar extends ConsumerWidget {
const _IntentBar({required this.intent});
@@ -173,6 +259,10 @@ class _PlaceCarousel extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (places.isEmpty) {
return const SizedBox.shrink();
}
return SizedBox(
height: 172,
child: ListView.separated(
@@ -216,19 +306,25 @@ class _PlacePhotoCard extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
PageView.builder(
itemCount: place.photoUrls.length,
itemBuilder: (context, index) {
return Image.network(
place.photoUrls[index],
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
color: const Color(0xFFE0D8CA),
child: const Icon(Icons.place_outlined),
),
);
},
),
if (place.photoUrls.isEmpty)
const ColoredBox(
color: Color(0xFF0F766E),
child: Icon(Icons.place_outlined, color: Colors.white),
)
else
PageView.builder(
itemCount: place.photoUrls.length,
itemBuilder: (context, index) {
return Image.network(
place.photoUrls[index],
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
color: const Color(0xFFE0D8CA),
child: const Icon(Icons.place_outlined),
),
);
},
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -273,37 +369,23 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
static const _minimumVoiceSeconds = 30;
Timer? _timer;
late final TextEditingController _placeNameController;
var _step = 0;
var _seconds = 0;
var _recording = false;
_GooglePlaceStub? _selectedGooglePlace;
var _submitting = false;
PlaceRecommendation? _selectedPlace;
static const _nearbyPlaces = [
_GooglePlaceStub(
name: 'Secret Garden',
area: '120 m',
coordinate: LatLng(10.7752, 106.7009),
),
_GooglePlaceStub(
name: 'The Workshop',
area: '210 m',
coordinate: LatLng(10.7740, 106.7042),
),
_GooglePlaceStub(
name: 'L\'Usine',
area: '360 m',
coordinate: LatLng(10.7755, 106.7038),
),
_GooglePlaceStub(
name: 'Oc Dao',
area: '780 m',
coordinate: LatLng(10.7607, 106.6898),
),
];
@override
void initState() {
super.initState();
_placeNameController = TextEditingController();
}
@override
void dispose() {
_timer?.cancel();
_placeNameController.dispose();
super.dispose();
}
@@ -324,6 +406,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
@override
Widget build(BuildContext context) {
final controller = ref.read(placeControllerProvider.notifier);
final places = ref.watch(placeControllerProvider).value?.places ?? [];
final time =
'${(_seconds ~/ 60).toString().padLeft(2, '0')}:'
'${(_seconds % 60).toString().padLeft(2, '0')}';
@@ -331,29 +414,38 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
final content = switch (_step) {
0 => _IntroStep(onNext: () => setState(() => _step = 1)),
1 => _PlaceStep(
places: _nearbyPlaces,
places: places,
controller: _placeNameController,
onSelect: (place) {
setState(() {
_selectedGooglePlace = place;
_selectedPlace = place;
_placeNameController.text = place.name;
_step = 2;
});
controller.setReviewPlace(place.name);
},
onManualNext: () {
controller.setReviewPlace(_placeNameController.text);
setState(() => _step = 2);
},
),
_ => _VoiceStep(
place: _selectedGooglePlace,
placeName: _placeNameController.text,
seconds: _seconds,
minimumSeconds: _minimumVoiceSeconds,
time: time,
isRecording: _recording,
isSubmitting: _submitting,
canContinue: _seconds >= _minimumVoiceSeconds,
onToggleRecording: _toggleRecording,
onNext: () {
final coordinate =
_selectedGooglePlace?.coordinate ?? widget.coordinate;
controller.setReviewPlace(_selectedGooglePlace?.name ?? '');
controller.analyzeVoiceReview();
controller.publishReview(coordinate: coordinate);
onNext: () async {
setState(() => _submitting = true);
final coordinate = _selectedPlace?.coordinate ?? widget.coordinate;
controller.setReviewPlace(_placeNameController.text);
await controller.publishReview(coordinate: coordinate);
if (!context.mounted) {
return;
}
Navigator.of(context).pop();
},
),
@@ -430,10 +522,17 @@ class _IntroStep extends StatelessWidget {
}
class _PlaceStep extends StatelessWidget {
const _PlaceStep({required this.places, required this.onSelect});
const _PlaceStep({
required this.places,
required this.controller,
required this.onSelect,
required this.onManualNext,
});
final List<_GooglePlaceStub> places;
final ValueChanged<_GooglePlaceStub> onSelect;
final List<PlaceRecommendation> places;
final TextEditingController controller;
final ValueChanged<PlaceRecommendation> onSelect;
final VoidCallback onManualNext;
@override
Widget build(BuildContext context) {
@@ -442,15 +541,19 @@ class _PlaceStep extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Выбери место рядом',
'Место',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
const SizedBox(height: 6),
const Text('Покажем Google Places по твоей геолокации.'),
const SizedBox(height: 16),
TextField(
controller: controller,
decoration: const InputDecoration(hintText: 'Название места'),
textInputAction: TextInputAction.done,
),
const SizedBox(height: 12),
Expanded(
child: ListView.separated(
itemCount: places.length,
@@ -472,34 +575,36 @@ class _PlaceStep extends StatelessWidget {
place.name,
style: const TextStyle(fontWeight: FontWeight.w800),
),
trailing: Text(place.area),
);
},
),
),
],
),
action: FilledButton(onPressed: onManualNext, child: const Text('Далее')),
);
}
}
class _VoiceStep extends StatelessWidget {
const _VoiceStep({
required this.place,
required this.placeName,
required this.seconds,
required this.minimumSeconds,
required this.time,
required this.isRecording,
required this.isSubmitting,
required this.canContinue,
required this.onToggleRecording,
required this.onNext,
});
final _GooglePlaceStub? place;
final String placeName;
final int seconds;
final int minimumSeconds;
final String time;
final bool isRecording;
final bool isSubmitting;
final bool canContinue;
final VoidCallback onToggleRecording;
final VoidCallback onNext;
@@ -511,7 +616,7 @@ class _VoiceStep extends StatelessWidget {
children: [
const Spacer(),
Text(
place?.name ?? 'Место',
placeName.trim().isEmpty ? 'Место' : placeName.trim(),
textAlign: TextAlign.center,
style: Theme.of(
context,
@@ -524,7 +629,7 @@ class _VoiceStep extends StatelessWidget {
width: 132,
height: 132,
child: FilledButton(
onPressed: onToggleRecording,
onPressed: isSubmitting ? null : onToggleRecording,
style: FilledButton.styleFrom(shape: const CircleBorder()),
child: Icon(isRecording ? Icons.stop : Icons.mic, size: 54),
),
@@ -544,8 +649,8 @@ class _VoiceStep extends StatelessWidget {
],
),
action: FilledButton(
onPressed: canContinue ? onNext : null,
child: const Text('Далее'),
onPressed: canContinue && !isSubmitting ? onNext : null,
child: Text(isSubmitting ? 'Отправляем' : 'Далее'),
),
);
}
@@ -614,15 +719,3 @@ class _StoryProgress extends StatelessWidget {
);
}
}
class _GooglePlaceStub {
const _GooglePlaceStub({
required this.name,
required this.area,
required this.coordinate,
});
final String name;
final String area;
final LatLng coordinate;
}