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/domain/usecase/book_scooter_usecase.dart'; import 'package:be_happy/domain/usecase/get_client_orders_usecase.dart'; import 'package:be_happy/domain/usecase/get_map_settings_usecase.dart'; import 'package:be_happy/domain/usecase/get_payment_cards_usecase.dart'; import 'package:be_happy/domain/usecase/get_pedestrian_routes_usecase.dart'; import 'package:be_happy/domain/usecase/get_scooter_usecase.dart'; import 'package:be_happy/domain/usecase/save_map_settings_usecase.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:be_happy/presentation/components/sheet/current_rides_sheet.dart'; import 'package:be_happy/presentation/components/sheet/map_settings_sheet.dart'; import 'package:be_happy/presentation/components/sheet/tariff_sheet.dart'; import 'package:be_happy/presentation/event/current_rides_event.dart'; import 'package:be_happy/presentation/event/map_settings_modal_event.dart'; import 'package:be_happy/presentation/viewmodel/current_rides_bloc.dart'; import 'package:be_happy/presentation/viewmodel/map_settings_modal_bloc.dart'; import 'package:be_happy/presentation/viewmodel/tariff_sheet_bloc.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_address_by_point_usecase.dart'; import '../../domain/usecase/get_available_tariffs_usecase.dart'; import '../../domain/usecase/get_notifications_stream_usecase.dart'; import '../components/notification_toast.dart'; import '../components/sheet/scooter_bottom_sheet.dart'; import '../components/side_menu.dart'; import '../components/unpaid_order_notification_card.dart'; import '../event/map_event.dart'; import '../event/scooter_detail_modal_event.dart'; import '../state/map_state.dart'; import '../viewmodel/map_bloc.dart'; import '../viewmodel/scooter_detail_modal_bloc.dart'; class MapScreen extends StatefulWidget { const MapScreen({super.key}); @override State createState() => _MapScreenState(); } class _MapScreenState extends State { YandexMapController? mapController; Position? _currentPosition; StreamSubscription? _positionStreamSubscription; StreamSubscription? _notificationStreamSubscription; bool _isFirstLocationUpdate = true; Timer? _debounceTimer; @override void initState() { super.initState(); _checkLocationPermission(); _initScooterIcon(); _startNotificationStream(); context.read().add(FetchProfileData()); context.read().add(CheckUser()); } @override void dispose() { _debounceTimer?.cancel(); _positionStreamSubscription?.cancel(); _notificationStreamSubscription?.cancel(); context.read().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( 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().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( 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(); _notificationStreamSubscription = notificationsStreamUseCase().listen( (notification) { if (mounted) { context.read().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().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().add(FetchScooters(areaZones, areaScooters)); } }); } Future _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 scooters) async { context.read().add(CheckUser()); final flags = context.read().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(), getIt(), )..add(MapSettingsModalStarted()), child: MapSettingsSheet(), ); }, );*/ } void _onNotificationTap() { context.push("/home/current-rides-sheet"); /*showModalBottomSheet( context: context, builder: (context) => BlocProvider( create: (context) => CurrentRidesBloc(getIt()) ..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 _buildScooterPlacemarks({ required List 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 _buildZonePolygons(List? zones) { if (zones == null || zones.isEmpty) return []; List objects = []; List allZoneHoles = []; for (var zone in zones) { var points = zone.points.map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList(); var cleanPoints = []; for (var p in points) { if (cleanPoints.isEmpty || cleanPoints.last != p) { cleanPoints.add(p); } } if (cleanPoints.length > 2) { if (cleanPoints.first != cleanPoints.last) { cleanPoints.add(cleanPoints.first); } allZoneHoles.add(LinearRing(points: cleanPoints)); } } 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: allZoneHoles, ), strokeWidth: 0, fillColor: Colors.red.withOpacity(0.15), zIndex: 0, ), ); for (var zone in zones) { Color borderColor; if (zone.type == "Drive") { borderColor = const Color(0xFF5ECD4C); } else if (zone.type == "NotDrive") { borderColor = const Color(0xFFEF4444); } else { borderColor = const Color(0xFFA78BFA); } objects.add( PolylineMapObject( mapId: MapObjectId('zone_contour_${zone.id}'), polyline: Polyline( points: zone.points.map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList(), ), strokeColor: borderColor, strokeWidth: 2.0, zIndex: 1, ), ); } return objects; } Widget _buildTopButtons() { return Stack( children: [ Positioned( top: 16, left: 16, child: Builder( builder: (innerContext) => _RoundIconButton( icon: Icons.menu, onPressed: () => {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), _RoundButton( imagePath: 'assets/icons/scooter_placemark.png', onPressed: () => context.push("/home/current-rides-sheet"), ) ], ), ), ], ); } Widget _buildCentralQrButton() { return Positioned( bottom: 24, left: 0, right: 0, child: Center( child: GestureDetector( onTap: () { final flags = context.read().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().add( UpdateUserLocation( _currentPosition!.latitude, _currentPosition!.longitude, ), ); _moveCameraToPoint( _currentPosition!.latitude, _currentPosition!.longitude, zoom: 17, ); }, ), ], ), ), ); } } Future 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; const _RoundButton({ required this.imagePath, required this.onPressed, }); @override Widget build(BuildContext context) { return Container( width: 40, height: 40, decoration: BoxDecoration( color: AppColors.darkBlue, borderRadius: BorderRadius.circular(12), ), child: GestureDetector( onTap: onPressed, child: Center( child: Image.asset( imagePath, width: 20, height: 20, fit: BoxFit.contain, color: Colors.white, ), ), ), ); } } 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 ), ); }, ); }