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

869 lines
28 KiB
Dart

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 {
context.push(
"/home/scooter-sheet",
extra: {'scooters': scooters, 'currentLocation': _currentPosition},
);
/*final scoot = await showModalBottomSheet<Scooter>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
builder: (context) {
return BlocProvider(
create: (context) =>
ScooterDetailModalBloc(
getIt<GetAddressByPointUsecase>(),
getIt<GetScooterUsecase>(),
getIt<GetPedestrianRoutesUsecase>(),
)..add(
ScooterDetailModalStarted(
scooters,
_currentPosition!.latitude,
_currentPosition!.longitude,
),
),
child: ScooterBottomSheet(),
);
},
);*/
/*bool? isBooking = false;
if (scoot != null) {
final result = await context.push('/home/scooter/${scoot.id}');
if (result == true) {
// Даем небольшую задержку, чтобы навигация завершилась корректно
await Future.delayed(Duration(milliseconds: 300), () async {
isBooking = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
builder: (context) => BlocProvider(
create: (context) => TariffSheetBloc(
getIt<GetAvailableTariffsUsecase>(),
getIt<GetPaymentCardsUsecase>(),
getIt<BookScooterUsecase>(),
),
child: TariffSheet(scooter: scoot),
),
);
});
}
}
if (isBooking ?? false) {
showModalBottomSheet(
context: context,
builder: (context) => BlocProvider(
create: (context) =>
CurrentRidesBloc(getIt<GetClientOrdersUsecase>())
..add(LoadClientOrders(1)),
child: CurrentRidesSheet(clientId: 1),
),
);
}*/
}
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: () => 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,
),
);
}
}