Files
be_happy_public/lib/presentation/screens/map_screen.dart
2026-09-04 13:41:57 +03:00

991 lines
33 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:bot_toast/bot_toast.dart';
import 'package:be_happy/domain/entities/client_notification.dart';
import 'package:be_happy/presentation/components/fine_notification_card.dart';
import 'package:be_happy/presentation/components/map_icon_painter/clusterized_icon_painter.dart';
import 'package:be_happy/presentation/components/payment_notification_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart';
import 'package:go_router/go_router.dart';
import 'package:yandex_mapkit/yandex_mapkit.dart';
import '../../core/app_colors.dart';
import '../../di/service_locator.dart';
import '../../domain/entities/scooter.dart';
import '../../domain/entities/zone.dart';
import '../../domain/usecase/get_notifications_stream_usecase.dart';
import '../components/side_menu.dart';
import '../components/unpaid_order_notification_card.dart';
import '../event/map_event.dart';
import '../state/map_state.dart';
import '../viewmodel/map_bloc.dart';
class MapScreen extends StatefulWidget {
const MapScreen({super.key});
@override
State<MapScreen> createState() => _MapScreenState();
}
class _MapScreenState extends State<MapScreen> {
YandexMapController? mapController;
Position? _currentPosition;
StreamSubscription<Position>? _positionStreamSubscription;
StreamSubscription<ClientNotification>? _notificationStreamSubscription;
bool _isFirstLocationUpdate = true;
Timer? _debounceTimer;
@override
void initState() {
super.initState();
_checkLocationPermission();
_initScooterIcon();
_startNotificationStream();
context.read<MapBloc>().add(FetchProfileData());
context.read<MapBloc>().add(CheckUser());
}
@override
void dispose() {
_debounceTimer?.cancel();
_positionStreamSubscription?.cancel();
_notificationStreamSubscription?.cancel();
context.read<MapBloc>().stopNotificationStream();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
drawer: const SideMenu(),
body: Container(
decoration: const BoxDecoration(gradient: AppColors.phoneScreenBg),
child: SafeArea(
child: Stack(
children: [
BlocConsumer<MapBloc, ScooterState>(
listenWhen: (previous, current) {
return current.lastNotification !=
previous.lastNotification ||
current.flags != previous.flags ||
previous.selectedScooterForFocus?.id !=
current.selectedScooterForFocus?.id;
},
listener: (context, state) {
if (state.lastNotification != null) {
_showNotificationToast(state.lastNotification!);
}
if (state.flags != null) {
if (!state.flags.hasCard) {
BotToast.showCustomNotification(
duration: null,
toastBuilder: (_) {
return Container(
margin: const EdgeInsets.only(top: 120),
child: Material(
color: Colors.transparent,
child: PaymentNotificationCard(
onBindCard: () {
BotToast.cleanAll();
context.push("/home/payment-methods");
},
onClose: () => BotToast.cleanAll(),
),
),
);
},
);
}
if (state.flags.hasFine) {
BotToast.showCustomNotification(
duration: null,
toastBuilder: (_) {
return Container(
margin: const EdgeInsets.only(top: 120),
child: Material(
color: Colors.transparent,
child: FineNotificationCard(
/*onBindCard: () {
BotToast.cleanAll();
context.push("/home/payment-methods");
},*/
onClose: () => BotToast.cleanAll(),
),
),
);
},
);
}
if (state.flags.hasUnpaidOrder) {
BotToast.showCustomNotification(
duration: null,
toastBuilder: (_) {
return Container(
margin: const EdgeInsets.only(top: 120),
child: Material(
color: Colors.transparent,
child: UnpaidOrderNotificationCard(
/*onBindCard: () {
BotToast.cleanAll();
context.push("/home/payment-methods");
},*/
onClose: () => BotToast.cleanAll(),
),
),
);
},
);
}
}
if (state.selectedScooterForFocus != null) {
final targetScooter = state.selectedScooterForFocus!;
print("RESERVED SCOOTER: $targetScooter");
_moveCameraToPoint(
targetScooter.longitude,
targetScooter.latitude,
zoom: 17,
);
context.read<MapBloc>().add(ClearMapFocus());
}
},
buildWhen: (previous, current) {
return previous.scooters != current.scooters ||
previous.reservedScooters != current.reservedScooters ||
previous.zones != current.zones ||
previous.status != current.status;
},
builder: (context, state) {
final freeScooters = _buildScooterPlacemarks(
scooters: state.scooters,
iconAsset: 'assets/icons/scooter_placemark_fill.png',
isClickable: true,
);
final reservedScooters = _buildScooterPlacemarks(
scooters: state.reservedScooters ?? [],
iconAsset:
'assets/icons/scooter_reserved_placemark_fill.png',
isClickable: false,
);
final zonePolygons = _buildZonePolygons(state.zones);
return RepaintBoundary(
child: YandexMap(
onMapCreated: (controller) {
controller.toggleUserLayer(visible: true);
mapController = controller;
if (_currentPosition != null) {
_fetchScooters();
}
},
onCameraPositionChanged:
(cameraPosition, reason, finished) {
if (finished) {
_fetchScooters();
}
},
mapObjects: [
...zonePolygons,
...reservedScooters,
ClusterizedPlacemarkCollection(
mapId: const MapObjectId('scooters_cluster'),
placemarks: freeScooters,
radius: 30,
minZoom: 15,
consumeTapEvents: true,
onClusterTap: (collection, cluster) {
final clusteredPlacemarks = cluster.placemarks;
final filtered = state.scooters.where((scooter) {
return clusteredPlacemarks.any(
(pm) => pm.mapId.value == scooter.id.toString(),
);
}).toList();
_onMarkerTap(filtered);
},
onClusterAdded: (self, cluster) async {
return cluster.copyWith(
appearance: cluster.appearance.copyWith(
opacity: 1.0,
icon: PlacemarkIcon.single(
PlacemarkIconStyle(
image: BitmapDescriptor.fromBytes(
await ClusterIconPainter(
cluster.size,
).getClusterIconBytes(),
),
scale: 0.8,
),
),
),
);
},
),
],
),
);
},
),
BlocBuilder<MapBloc, ScooterState>(
buildWhen: (previous, current) =>
previous.status != current.status,
builder: (context, state) {
if (state.status == ScooterStatus.loading) {
return const Positioned(
top: 80,
left: 0,
right: 0,
child: Center(child: CircularProgressIndicator()),
);
}
return const SizedBox.shrink();
},
),
// Кнопки управления (Меню, Уведомления)
_buildTopButtons(),
// Кнопки навигации
if (_currentPosition != null) _buildSideControls(),
_buildCentralQrButton(),
],
),
),
),
);
}
void _startNotificationStream() {
final notificationsStreamUseCase = getIt<GetNotificationsStreamUseCase>();
_notificationStreamSubscription = notificationsStreamUseCase().listen(
(notification) {
if (mounted) {
context.read<MapBloc>().add(NotificationReceived(notification));
}
},
onError: (error) {
print("SSE NOTIFICATION ERROR: $error");
},
);
}
void _checkLocationPermission() async {
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
await Geolocator.requestPermission();
}
_getCurrentLocation();
// _startTrackingLocation();
}
void _startTrackingLocation() {
const LocationSettings locationSettings = LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 10,
);
_positionStreamSubscription =
Geolocator.getPositionStream(
locationSettings: locationSettings,
).listen((Position position) {
if (!mounted) return;
print(
"----------------------------------------------------- tracking... --------------------------------------------------------",
);
setState(() => _currentPosition = position);
context.read<MapBloc>().add(
UpdateUserLocation(position.latitude, position.longitude),
);
if (_isFirstLocationUpdate) {
_moveCameraToPoint(position.latitude, position.longitude, zoom: 15);
_isFirstLocationUpdate = false;
}
_fetchScooters();
});
}
void _getCurrentLocation() async {
try {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
setState(() => _currentPosition = position);
if (mapController != null && mounted) {
await _moveCameraToPoint(position.latitude, position.longitude);
_fetchScooters();
}
} catch (e) {
debugPrint('Ошибка геолокации: $e');
}
}
void _fetchScooters() async {
final controller = mapController;
if (controller == null) return;
if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 500), () async {
final visibleRegion = await controller.getVisibleRegion();
final areaScooters = [
visibleRegion.bottomRight.longitude,
visibleRegion.bottomRight.latitude,
visibleRegion.topLeft.latitude,
visibleRegion.topLeft.longitude,
];
final areaZones = [
visibleRegion.bottomRight.longitude,
visibleRegion.bottomRight.latitude,
visibleRegion.topLeft.longitude,
visibleRegion.topLeft.latitude,
];
if (mounted) {
context.read<MapBloc>().add(FetchScooters(areaZones, areaScooters));
}
});
}
Future<void> _moveCameraToPoint(
double lat,
double lon, {
double zoom = 15,
}) async {
await mapController?.moveCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: Point(latitude: lat, longitude: lon),
zoom: zoom,
),
),
);
}
void _onMarkerTap(List<Scooter> scooters) async {
context.read<MapBloc>().add(CheckUser());
final flags = context.read<MapBloc>().state.flags;
if (!flags.hasCard) {
_showExistingNotification(
child: PaymentNotificationCard(
onBindCard: () {
BotToast.cleanAll();
context.push("/home/payment-methods");
},
onClose: () => BotToast.cleanAll(),
),
);
return;
}
if (flags.hasUnpaidOrder) {
_showExistingNotification(
child: UnpaidOrderNotificationCard(onClose: () => BotToast.cleanAll()),
);
return;
}
if (flags.hasFine) {
_showExistingNotification(
child: FineNotificationCard(onClose: () => BotToast.cleanAll()),
);
return;
}
context.push(
"/home/scooter-sheet",
extra: {'scooters': scooters, 'currentLocation': _currentPosition},
);
}
void _onMapSettingsTap() {
context.push("/home/map-settings-sheet");
/*showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
builder: (context) {
return BlocProvider(
create: (context) => MapSettingsModalBloc(
getIt<GetMapSettingsUsecase>(),
getIt<SaveMapSettingsUsecase>(),
)..add(MapSettingsModalStarted()),
child: MapSettingsSheet(),
);
},
);*/
}
void _onNotificationTap() {
context.push("/home/current-rides-sheet");
/*showModalBottomSheet(
context: context,
builder: (context) => BlocProvider(
create: (context) =>
CurrentRidesBloc(getIt<GetClientOrdersUsecase>())
..add(LoadClientOrders()),
child: CurrentRidesSheet(),
),
);*/
// BotToast.showCustomNotification(
// duration: const Duration(seconds: 4),
//
// toastBuilder: (_) {
// return NotificationToast(
// title: "",
// onClose: () {
// BotToast.cleanAll();
// },
// );
// },
// );
}
void _showNotificationToast(ClientNotification notification) {
String title = _getNotificationTitle(notification.type);
Color backgroundColor = _getNotificationColor(notification.type);
BotToast.showCustomNotification(
duration: null,
toastBuilder: (_) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 50),
child: Material(
elevation: 8,
shadowColor: Colors.black26,
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 60,
child: Image.asset(
'assets/icons/clichnik.png',
fit: BoxFit.contain,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
notification.content,
style: TextStyle(fontSize: 14),
),
],
),
),
),
Align(
alignment: Alignment.topRight,
child: IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () {
BotToast.cleanAll();
},
),
),
],
),
),
),
);
},
);
}
String _getNotificationTitle(NotificationType type) {
switch (type) {
case NotificationType.info:
return 'Информация';
case NotificationType.attention:
return 'Внимание';
case NotificationType.warning:
return 'Предупреждение';
}
}
Color _getNotificationColor(NotificationType type) {
switch (type) {
case NotificationType.info:
return const Color(0xFF2196F3);
case NotificationType.attention:
return const Color(0xFFFF9800);
case NotificationType.warning:
return Colors.red;
}
}
void _initScooterIcon() async {
await ClusterIconPainter.initImage('assets/icons/scooter_placemark.png');
}
List<PlacemarkMapObject> _buildScooterPlacemarks({
required List<Scooter> scooters,
required String iconAsset,
required bool isClickable,
}) {
return scooters.map((scooter) {
return PlacemarkMapObject(
mapId: MapObjectId('${isClickable ? "" : "reserved_"}${scooter.id}'),
// уникальный ID для карты
point: Point(latitude: scooter.longitude, longitude: scooter.latitude),
icon: PlacemarkIcon.single(
PlacemarkIconStyle(
image: BitmapDescriptor.fromAssetImage(iconAsset),
scale: 0.2,
),
),
opacity: 1.0,
consumeTapEvents: isClickable,
onTap: isClickable
? (object, point) async => _onMarkerTap([scooter])
: null,
);
}).toList();
}
List<MapObject> _buildZonePolygons(List<Zone>? zones) {
if (zones == null || zones.isEmpty) return [];
List<MapObject> objects = [];
List<LinearRing> driveOuterRings = [];
for (var zone in zones) {
if (zone.type == "Drive") {
for (var polygonData in zone.geometry) {
if (polygonData.isEmpty) continue;
var outerPoints = polygonData[0]
.map((p) => Point(latitude: p.latitude, longitude: p.longitude))
.toList();
var cleanPoints = <Point>[];
for (var p in outerPoints) {
if (cleanPoints.isEmpty || cleanPoints.last != p) {
cleanPoints.add(p);
}
}
if (cleanPoints.length > 2) {
if (cleanPoints.first != cleanPoints.last) {
cleanPoints.add(cleanPoints.first);
}
driveOuterRings.add(LinearRing(points: cleanPoints));
}
}
}
}
// Добавляем глобальную маску (все, что НЕ Drive — затеняется красным)
objects.add(
PolygonMapObject(
mapId: const MapObjectId('global_inverse_mask'),
polygon: Polygon(
outerRing: const LinearRing(
points: [
Point(latitude: 85, longitude: -179.9),
Point(latitude: 85, longitude: 179.9),
Point(latitude: -85, longitude: 179.9),
Point(latitude: -85, longitude: -179.9),
],
),
innerRings: driveOuterRings, // Вырезаем только зоны Drive
),
strokeWidth: 0,
fillColor: Colors.red.withOpacity(0.15),
zIndex: 0,
),
);
// 2. Отрисовываем каждую зону (включая MultiPolygon и вложенные NotDrive/Finish)
for (var zone in zones) {
Color borderColor;
Color fillColor;
// Стилизация в зависимости от типа зоны
if (zone.type == "Drive") {
borderColor = const Color(0xFF5ECD4C); // Зеленый бордюр
fillColor = Colors.transparent; // Прозрачная внутри маски
} else if (zone.type == "NotDrive") {
borderColor = const Color(0xFFEF4444); // Красный бордюр
fillColor = const Color(0xFFEF4444).withOpacity(
0.3,
); // Красная полупрозрачная заливка для наглядности внутри Drive
} else {
borderColor = const Color(0xFFA78BFA); // Фиолетовый бордюр (Парковка)
fillColor = const Color(
0xFFA78BFA,
).withOpacity(0.25); // Фиолетовая заливка
}
int polyIndex = 0;
for (var polygonData in zone.geometry) {
if (polygonData.isEmpty) continue;
// Строим внешнее кольцо текущего полигона
var outerPoints = polygonData[0]
.map((p) => Point(latitude: p.latitude, longitude: p.longitude))
.toList();
if (outerPoints.first != outerPoints.last) {
outerPoints.add(outerPoints.first);
}
LinearRing outerRing = LinearRing(points: outerPoints);
// Строим внутренние кольца (дыры), если они есть у этого полигона
List<LinearRing> innerRings = [];
for (int i = 1; i < polygonData.length; i++) {
var innerPoints = polygonData[i]
.map((p) => Point(latitude: p.latitude, longitude: p.longitude))
.toList();
if (innerPoints.isNotEmpty) {
if (innerPoints.first != innerPoints.last) {
innerPoints.add(innerPoints.first);
}
innerRings.add(LinearRing(points: innerPoints));
}
}
// Рендерим полноценный PolygonMapObject (он поддерживает и заливку, и обводку)
objects.add(
PolygonMapObject(
mapId: MapObjectId('zone_poly_${zone.type}_${zone.id}_$polyIndex'),
polygon: Polygon(outerRing: outerRing, innerRings: innerRings),
strokeColor: borderColor,
strokeWidth: 2.0,
fillColor: fillColor,
// Поднимаем зоны ограничений и парковок выше маски и контуров Drive
zIndex: zone.type == "Drive" ? 1 : 2,
),
);
polyIndex++;
}
}
return objects;
}
Widget _buildTopButtons() {
return Stack(
children: [
Positioned(
top: 16,
left: 16,
child: Builder(
builder: (innerContext) => _RoundIconButton(
icon: Icons.menu,
onPressed: () {
context.read<MapBloc>().add(FetchProfileData());
Scaffold.of(innerContext).openDrawer();
},
),
),
),
Positioned(
top: 16,
right: 16,
child: Column(
children: [
_RoundIconButton(
icon: Icons.notifications_sharp,
onPressed: () => context.push("/home/notifications"),
),
const SizedBox(height: 12),
BlocBuilder<MapBloc, ScooterState>(
buildWhen: (previous, current) =>
previous.activeRidesCount != current.activeRidesCount,
builder: (context, state) {
return _RoundButton(
imagePath: 'assets/icons/scooter_placemark.png',
count: state.activeRidesCount ?? 0,
onPressed: () {
context.push("/home/current-rides-sheet").then((_) {
context.read<MapBloc>().add(FetchActiveOrders());
});
},
);
},
),
],
),
),
],
);
}
Widget _buildCentralQrButton() {
return Positioned(
bottom: 24,
left: 0,
right: 0,
child: Center(
child: GestureDetector(
onTap: () {
final flags = context.read<MapBloc>().state.flags;
// 🔹 Проверка флагов — показываем те же карточки, что и в listener
if (!flags.hasCard) {
_showExistingNotification(
child: PaymentNotificationCard(
onBindCard: () {
BotToast.cleanAll();
context.push("/home/payment-methods");
},
onClose: () => BotToast.cleanAll(),
),
);
return;
}
if (flags.hasUnpaidOrder) {
_showExistingNotification(
child: UnpaidOrderNotificationCard(
onClose: () => BotToast.cleanAll(),
),
);
return;
}
if (flags.hasFine) {
_showExistingNotification(
child: FineNotificationCard(onClose: () => BotToast.cleanAll()),
);
return;
}
// ✅ Все проверки пройдены — переход на сканирование
context.push("/home/qr-info");
},
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.darkBlue,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.qr_code_scanner,
color: Colors.white,
size: 32,
),
),
),
),
);
}
Widget _buildSideControls() {
return Positioned(
right: 16,
top: 0,
bottom: 0,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_CircleIconButton(icon: Icons.map, onPressed: _onMapSettingsTap),
const SizedBox(height: 16),
_CircleIconButton(
icon: Icons.my_location,
onPressed: () {
context.read<MapBloc>().add(
UpdateUserLocation(
_currentPosition!.latitude,
_currentPosition!.longitude,
),
);
_moveCameraToPoint(
_currentPosition!.latitude,
_currentPosition!.longitude,
zoom: 17,
);
},
),
],
),
),
);
}
}
Future<Uint8List> painterToBytes(CustomPainter painter, Size size) async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
painter.paint(canvas, size);
final picture = recorder.endRecording();
final image = await picture.toImage(size.width.toInt(), size.height.toInt());
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData!.buffer.asUint8List();
}
class _RoundIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onPressed;
const _RoundIconButton({required this.icon, required this.onPressed});
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.darkBlue,
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: Icon(icon, color: Colors.white, size: 24),
onPressed: onPressed,
),
);
}
}
class _RoundButton extends StatelessWidget {
final String imagePath;
final VoidCallback onPressed;
final int count;
const _RoundButton({
required this.imagePath,
required this.onPressed,
this.count = 0,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.darkBlue,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Image.asset(
imagePath,
width: 20,
height: 20,
fit: BoxFit.contain,
color: Colors.white,
),
),
),
if (count > 0)
Positioned(
top: -4,
right: -4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.redAccent,
shape: BoxShape.circle,
),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
child: Center(
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
),
],
),
);
}
}
class _CircleIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onPressed;
const _CircleIconButton({required this.icon, required this.onPressed});
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: AppColors.darkBlue,
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(icon, color: Colors.white, size: 24),
onPressed: onPressed,
),
);
}
}
void _showExistingNotification({required Widget child}) {
BotToast.showCustomNotification(
duration: null, // не исчезает, пока пользователь не закроет
toastBuilder: (_) {
return Container(
margin: const EdgeInsets.only(top: 120),
// тот же отступ, что в listener
child: Material(
color: Colors.transparent,
child:
child, // PaymentNotificationCard / UnpaidOrderNotificationCard / FineNotificationCard
),
);
},
);
}