Files
be_happy_public/lib/presentation/screens/map_screen.dart

890 lines
28 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/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<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;
},
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(),
),
),
);
},
);
}
}
},
buildWhen: (previous, current) =>
previous.scooters != current.scooters ||
previous.zones != current.zones,
builder: (context, state) {
final scooters = _buildScooterPlacemarks(
state.scooters,
state.address ?? "Unknown address",
);
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,
ClusterizedPlacemarkCollection(
mapId: const MapObjectId('scooters_cluster'),
placemarks: scooters,
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 {
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(
List<Scooter> scooters,
String address,
) {
return scooters.map((scooter) {
return PlacemarkMapObject(
mapId: MapObjectId('${scooter.id}'),
point: Point(latitude: scooter.longitude, longitude: scooter.latitude),
icon: PlacemarkIcon.single(
PlacemarkIconStyle(
image: BitmapDescriptor.fromAssetImage(
'assets/icons/scooter_placemark_fill.png',
),
scale: 0.2,
),
),
opacity: 1.0,
onTap: (object, point) async => {
_onMarkerTap([scooter]),
},
);
}).toList();
}
List<MapObject> _buildZonePolygons(List<Zone>? zones) {
if (zones == null || zones.isEmpty) return [];
List<MapObject> objects = [];
List<LinearRing> allZoneHoles = [];
for (var zone in zones) {
var points = zone.points.map((p) => Point(latitude: p.latitude, longitude: p.longitude)).toList();
var cleanPoints = <Point>[];
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<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;
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
),
);
},
);
}