new changes maps

This commit is contained in:
2026-08-12 10:50:45 +05:30
parent df4e044d74
commit c65d78878a
25 changed files with 6120 additions and 3814 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,209 +1,209 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math' show Random;
import 'package:get/get.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:minio/io.dart';
import 'package:minio/minio.dart';
import 'package:nearle/Models/supportticket/support_ticket.dart';
// Minimal local stub for DigitalOcean Spaces client to avoid undefined name errors.
// Replace this with a real package or implementation for production uploads.
class dospace {
static DOSpaceClient DOSpace({
required String region,
required String accessKey,
required String secretKey,
}) =>
DOSpaceClient(region: region, accessKey: accessKey, secretKey: secretKey);
static final ACL = _ACL();
}
class _ACL {
final String publicRead = 'public-read';
}
class DOSpaceClient {
final String region;
final String accessKey;
final String secretKey;
DOSpaceClient({
required this.region,
required this.accessKey,
required this.secretKey,
});
Future<void> putObject({
required String bucketName,
required String objectName,
required File file,
required String acl,
required String contentType,
}) async {
// No-op stub: implement actual upload logic here or use a proper package.
await Future<void>.value();
}
}
class SupportTicketController extends GetxController {
final RxList<SupportTicketModel> tickets = <SupportTicketModel>[].obs;
final RxBool isLoading = true.obs;
final RxString errorMessage = ''.obs;
final RxBool isSubmitting = false.obs;
@override
void onInit() {
super.onInit();
fetchTickets();
}
// ---------- FETCH TICKETS ----------
Future<void> fetchTickets() async {
try {
isLoading(true);
errorMessage('');
const userId = 1242;
final url = Uri.parse(
'https://jupiter.nearle.app/live/api/v1/partners/getridersupport/?userid=$userId');
final response = await http.get(url, headers: {
'Accept': 'application/json',
});
if (response.statusCode != 200) {
throw Exception('Server error: ${response.statusCode}');
}
final Map<String, dynamic> jsonResponse = json.decode(response.body);
if (jsonResponse['status'] != true) {
throw Exception(jsonResponse['message'] ?? 'Unknown error');
}
final List<dynamic> data = jsonResponse['data'];
tickets.assignAll(data.map((e) => SupportTicketModel.fromJson(e)).toList());
} catch (e) {
errorMessage(e.toString());
} finally {
isLoading(false);
}
}
// ---------- UPLOAD IMAGE TO DO SPACES ----------
Future<String?> uploadImageToDOSpaces(File imageFile, int userId) async {
try {
final rng = Random();
const String region = "sgp1";
const String accessKey = "DO00NQER7N2FRYZAB2HR";
const String secretKey = "nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0";
const String bucketName = "nearle";
const String folderName = "support";
// File name
final String fileName = 'ticket-${rng.nextInt(10000)}-$userId.jpg';
// Object path inside the bucket
final String objectPath = "$folderName/$fileName";
// CDN URL you want
final String cdnUrl = "https://images.nearle.app/$objectPath";
// Initialize Minio
final minio = Minio(
endPoint: "$region.digitaloceanspaces.com",
accessKey: accessKey,
secretKey: secretKey,
region: region,
useSSL: true,
);
print("Uploading: $objectPath");
// Upload to DO Spaces
await minio.fPutObject(
bucketName,
objectPath,
imageFile.path,
metadata: {
"Content-Type": "image/jpeg",
"x-amz-acl": "public-read",
},
);
print("Uploaded Successfully: $cdnUrl");
return cdnUrl;
} catch (e) {
print("Upload error: $e");
Get.snackbar("Error", "Image upload failed.");
return null;
}
}
// ---------- CREATE TICKET ----------
// ---------- CREATE TICKET ----------
Future<bool> createTicket({
required int userid,
required String category,
required String priority,
required String subject,
required String issue,
List<XFile>? attachments,
}) async {
try {
isSubmitting(true);
String imageUrl = "";
// Upload first image if attached
if (attachments != null && attachments.isNotEmpty) {
final XFile xFile = attachments.first;
final File file = File(xFile.path);
final uploadedUrl = await uploadImageToDOSpaces(file, userid);
// Only assign if a valid URL (short length)
if (uploadedUrl != null && uploadedUrl.length < 200) {
imageUrl = uploadedUrl;
} else {
print("⚠️ Skipping image URL because it’s too long or invalid.");
}
}
// Create ticket request body
final Map<String, dynamic> payload = {
'userid': userid,
'category': category,
'priority': priority,
'subject': subject,
'issue': issue,
'image': imageUrl, // ✅ Always short string or empty
};
final response = await http.post(
Uri.parse('https://jupiter.nearle.app/live/api/v1/partners/createridersupport/'),
headers: {'Accept': 'application/json', 'Content-Type': 'application/json'},
body: jsonEncode(payload),
);
if (response.statusCode != 200) {
throw Exception('Ticket creation failed: ${response.statusCode}');
}
final jsonResponse = jsonDecode(response.body);
if (jsonResponse['status'] != true) {
throw Exception(jsonResponse['message'] ?? 'Unknown error');
}
await fetchTickets(); // Refresh list after success
return true;
} catch (e) {
errorMessage(e.toString());
return false;
} finally {
isSubmitting(false);
}
}
import 'dart:convert';
import 'dart:io';
import 'dart:math' show Random;
import 'package:get/get.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:minio/io.dart';
import 'package:minio/minio.dart';
import 'package:nearle/Models/supportticket/support_ticket.dart';
// Minimal local stub for DigitalOcean Spaces client to avoid undefined name errors.
// Replace this with a real package or implementation for production uploads.
class dospace {
static DOSpaceClient DOSpace({
required String region,
required String accessKey,
required String secretKey,
}) =>
DOSpaceClient(region: region, accessKey: accessKey, secretKey: secretKey);
static final ACL = _ACL();
}
class _ACL {
final String publicRead = 'public-read';
}
class DOSpaceClient {
final String region;
final String accessKey;
final String secretKey;
DOSpaceClient({
required this.region,
required this.accessKey,
required this.secretKey,
});
Future<void> putObject({
required String bucketName,
required String objectName,
required File file,
required String acl,
required String contentType,
}) async {
// No-op stub: implement actual upload logic here or use a proper package.
await Future<void>.value();
}
}
class SupportTicketController extends GetxController {
final RxList<SupportTicketModel> tickets = <SupportTicketModel>[].obs;
final RxBool isLoading = true.obs;
final RxString errorMessage = ''.obs;
final RxBool isSubmitting = false.obs;
@override
void onInit() {
super.onInit();
fetchTickets();
}
// ---------- FETCH TICKETS ----------
Future<void> fetchTickets() async {
try {
isLoading(true);
errorMessage('');
const userId = 1242;
final url = Uri.parse(
'https://jupiter.nearle.app/live/api/v1/partners/getridersupport/?userid=$userId');
final response = await http.get(url, headers: {
'Accept': 'application/json',
});
if (response.statusCode != 200) {
throw Exception('Server error: ${response.statusCode}');
}
final Map<String, dynamic> jsonResponse = json.decode(response.body);
if (jsonResponse['status'] != true) {
throw Exception(jsonResponse['message'] ?? 'Unknown error');
}
final List<dynamic> data = jsonResponse['data'];
tickets.assignAll(data.map((e) => SupportTicketModel.fromJson(e)).toList());
} catch (e) {
errorMessage(e.toString());
} finally {
isLoading(false);
}
}
// ---------- UPLOAD IMAGE TO DO SPACES ----------
Future<String?> uploadImageToDOSpaces(File imageFile, int userId) async {
try {
final rng = Random();
const String region = "sgp1";
const String accessKey = "DO00NQER7N2FRYZAB2HR";
const String secretKey = "nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0";
const String bucketName = "nearle";
const String folderName = "support";
// File name
final String fileName = 'ticket-${rng.nextInt(10000)}-$userId.jpg';
// Object path inside the bucket
final String objectPath = "$folderName/$fileName";
// CDN URL you want
final String cdnUrl = "https://images.nearle.app/$objectPath";
// Initialize Minio
final minio = Minio(
endPoint: "$region.digitaloceanspaces.com",
accessKey: accessKey,
secretKey: secretKey,
region: region,
useSSL: true,
);
print("Uploading: $objectPath");
// Upload to DO Spaces
await minio.fPutObject(
bucketName,
objectPath,
imageFile.path,
metadata: {
"Content-Type": "image/jpeg",
"x-amz-acl": "public-read",
},
);
print("Uploaded Successfully: $cdnUrl");
return cdnUrl;
} catch (e) {
print("Upload error: $e");
Get.snackbar("Error", "Image upload failed.");
return null;
}
}
// ---------- CREATE TICKET ----------
// ---------- CREATE TICKET ----------
Future<bool> createTicket({
required int userid,
required String category,
required String priority,
required String subject,
required String issue,
List<XFile>? attachments,
}) async {
try {
isSubmitting(true);
String imageUrl = "";
// Upload first image if attached
if (attachments != null && attachments.isNotEmpty) {
final XFile xFile = attachments.first;
final File file = File(xFile.path);
final uploadedUrl = await uploadImageToDOSpaces(file, userid);
// Only assign if a valid URL (short length)
if (uploadedUrl != null && uploadedUrl.length < 200) {
imageUrl = uploadedUrl;
} else {
print("⚠️ Skipping image URL because it's too long or invalid.");
}
}
// Create ticket request body
final Map<String, dynamic> payload = {
'userid': userid,
'category': category,
'priority': priority,
'subject': subject,
'issue': issue,
'image': imageUrl, // ✅ Always short string or empty
};
final response = await http.post(
Uri.parse('https://jupiter.nearle.app/live/api/v1/partners/createridersupport/'),
headers: {'Accept': 'application/json', 'Content-Type': 'application/json'},
body: jsonEncode(payload),
);
if (response.statusCode != 200) {
throw Exception('Ticket creation failed: ${response.statusCode}');
}
final jsonResponse = jsonDecode(response.body);
if (jsonResponse['status'] != true) {
throw Exception(jsonResponse['message'] ?? 'Unknown error');
}
await fetchTickets(); // Refresh list after success
return true;
} catch (e) {
errorMessage(e.toString());
return false;
} finally {
isSubmitting(false);
}
}
}

View File

@@ -0,0 +1,44 @@
/// Map and routing endpoints.
///
/// Everything here is open source and keyless:
///
/// * **Tiles** — OpenFreeMap (<https://openfreemap.org>), OpenStreetMap data
/// built with Planetiler and served as MapLibre vector tiles. No API key, no
/// registration, no request limits, MIT licensed. MapLibre adds the required
/// attribution automatically.
/// * **Routing** — OSRM (<https://project-osrm.org>), the Open Source Routing
/// Machine. Also keyless.
///
/// Both are overridable at build time so production can point at self-hosted
/// instances without a code change:
///
/// flutter build apk \
/// --dart-define=OSRM_BASE_URL=https://osrm.internal.nearle.in \
/// --dart-define=MAP_STYLE_URL=https://tiles.internal.nearle.in/styles/liberty
class MapConfig {
const MapConfig._();
/// MapLibre style document. Passed straight to the map — no key to inject,
/// so unlike a commercial provider there is nothing to patch or proxy.
///
/// OpenFreeMap styles: `liberty` (detailed), `bright`, `positron` (muted).
static const String styleUrl = String.fromEnvironment(
'MAP_STYLE_URL',
defaultValue: 'https://tiles.openfreemap.org/styles/liberty',
);
/// Base URL of an OSRM instance, without a trailing slash.
///
/// The default is the FOSSGIS community server. It is free and needs no key,
/// but it is donation-funded infrastructure with a fair-use policy — fine for
/// development, NOT appropriate for a production delivery fleet. Point this
/// at your own OSRM before shipping; see `deploy/osrm/README.md`.
static const String osrmBaseUrl = String.fromEnvironment(
'OSRM_BASE_URL',
defaultValue: 'https://routing.openstreetmap.de/routed-car',
);
/// True when routing points somewhere other than the shared community server.
static bool get usingOwnRoutingServer =>
!osrmBaseUrl.contains('routing.openstreetmap.de');
}

View File

@@ -0,0 +1,242 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
/// A marker bitmap plus a stable identity for it.
///
/// MapLibre registers images on the style by name, so every distinct bitmap
/// needs a key that is equal whenever the pixels are equal.
@immutable
class MarkerIcon {
const MarkerIcon(this.key, this.bytes);
final String key;
final Uint8List bytes;
@override
bool operator ==(Object other) => other is MarkerIcon && other.key == key;
@override
int get hashCode => key.hashCode;
}
/// Marker bitmaps drawn at runtime with a canvas.
///
/// MapLibre has no equivalent of Google's `BitmapDescriptor.defaultMarker*`,
/// so the pins the app used to get for free are drawn here instead. Everything
/// is generated in-process — no new image assets, no network fetch.
class MapMarkerIcons {
const MapMarkerIcons._();
static final Map<String, MarkerIcon> _cache = {};
/// Classic teardrop pin, anchored at its tip.
static Future<MarkerIcon> pin(Color color) =>
_memo('pin_${color.toARGB32()}', () => _drawPin(color));
/// Filled circle with a white ring — used for the rider's own position.
static Future<MarkerIcon> dot(Color color) =>
_memo('dot_${color.toARGB32()}', () => _drawDot(color));
/// Numbered step marker for the multi-stop route screen.
static Future<MarkerIcon> numberedCircle(
int number, {
required Color primaryColor,
bool isSkipped = false,
bool isNext = false,
}) => _memo(
'step_${number}_${primaryColor.toARGB32()}_${isSkipped}_$isNext',
() => _drawNumberedCircle(
number,
primaryColor: primaryColor,
isSkipped: isSkipped,
isNext: isNext,
),
);
static Future<MarkerIcon> _memo(
String key,
Future<Uint8List> Function() build,
) async {
final cached = _cache[key];
if (cached != null) return cached;
return _cache[key] = MarkerIcon(key, await build());
}
static Future<Uint8List> _toBytes(
ui.PictureRecorder recorder,
int width,
int height,
) async {
final image = await recorder.endRecording().toImage(width, height);
final data = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
return data!.buffer.asUint8List();
}
static Future<Uint8List> _drawPin(Color color) async {
const double width = 72;
const double height = 96;
const double radius = 27;
const center = Offset(width / 2, radius + 4);
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final body = Paint()
..color = color
..isAntiAlias = true;
final outline = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 4
..isAntiAlias = true;
// Tail: a curve from the circle's lower flanks down to the anchor point.
final tail = Path()
..moveTo(center.dx - radius + 4, center.dy + radius - 9)
..quadraticBezierTo(
center.dx - 6,
height - 14,
center.dx,
height - 4,
)
..quadraticBezierTo(
center.dx + 6,
height - 14,
center.dx + radius - 4,
center.dy + radius - 9,
)
..close();
canvas.drawPath(tail, body);
canvas.drawCircle(center, radius, body);
canvas.drawCircle(center, radius, outline);
canvas.drawCircle(center, 9, Paint()..color = Colors.white);
return _toBytes(recorder, width.toInt(), height.toInt());
}
static Future<Uint8List> _drawDot(Color color) async {
const double size = 48;
const center = Offset(size / 2, size / 2);
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawCircle(
center,
20,
Paint()
..color = color.withValues(alpha: 0.25)
..isAntiAlias = true,
);
canvas.drawCircle(
center,
12,
Paint()
..color = Colors.white
..isAntiAlias = true,
);
canvas.drawCircle(
center,
9,
Paint()
..color = color
..isAntiAlias = true,
);
return _toBytes(recorder, size.toInt(), size.toInt());
}
/// Preserves the exact look of the previous Google Maps step marker.
static Future<Uint8List> _drawNumberedCircle(
int number, {
required Color primaryColor,
required bool isSkipped,
required bool isNext,
}) async {
const double size = 70;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
const center = Offset(size / 2, size / 2);
final paint = Paint()..color = isSkipped ? Colors.orange : primaryColor;
canvas.drawCircle(center, 15, paint);
final border = Paint()
..color = isSkipped ? Colors.orange.shade900 : Colors.white
..style = PaintingStyle.stroke
..strokeWidth = isSkipped ? 4 : 3;
canvas.drawCircle(center, 15, border);
final textPainter = TextPainter(
text: TextSpan(
text: number.toString(),
style: const TextStyle(
fontSize: 19,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(
center.dx - textPainter.width / 2,
center.dy - textPainter.height / 2,
),
);
if (isNext) {
final signPaint = Paint()
..color = Colors.green
..style = PaintingStyle.fill;
final signRect = RRect.fromRectAndRadius(
Rect.fromCenter(center: const Offset(size / 2, 8), width: 45, height: 30),
const Radius.circular(4),
);
canvas.drawRRect(signRect, signPaint);
final signBorder = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawRRect(signRect, signBorder);
final nextTextPainter = TextPainter(
text: const TextSpan(
text: 'NEXT',
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
nextTextPainter.layout();
nextTextPainter.paint(
canvas,
Offset(size / 2 - nextTextPainter.width / 2, 4),
);
final arrowPath = Path()
..moveTo(size / 2, 18)
..lineTo(size / 2 - 4, 24)
..lineTo(size / 2 + 4, 24)
..close();
canvas.drawPath(
arrowPath,
Paint()
..color = Colors.white
..style = PaintingStyle.fill,
);
}
return _toBytes(recorder, size.toInt(), size.toInt());
}
}

View File

@@ -0,0 +1,374 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/material.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'map_config.dart';
import 'map_marker_icons.dart';
export 'package:maplibre_gl/maplibre_gl.dart'
show LatLng, LatLngBounds, CameraPosition, CameraUpdate, MyLocationTrackingMode;
/// Where a marker bitmap sits relative to its coordinate.
enum MarkerAnchor { center, bottom }
@immutable
class NearleMapMarker {
const NearleMapMarker({
required this.id,
required this.position,
required this.icon,
this.anchor = MarkerAnchor.bottom,
this.size = 1.0,
this.onTap,
});
final String id;
final LatLng position;
final MarkerIcon icon;
final MarkerAnchor anchor;
final double size;
final VoidCallback? onTap;
/// Identity for diffing. Deliberately excludes [onTap] — a new closure on
/// every rebuild must not force the marker to be torn down and re-added.
@override
bool operator ==(Object other) =>
other is NearleMapMarker &&
other.id == id &&
other.position.latitude == position.latitude &&
other.position.longitude == position.longitude &&
other.icon == icon &&
other.anchor == anchor &&
other.size == size;
@override
int get hashCode =>
Object.hash(id, position.latitude, position.longitude, icon, anchor, size);
}
@immutable
class NearleMapPolyline {
const NearleMapPolyline({
required this.id,
required this.points,
required this.color,
this.width = 5.0,
});
final String id;
final List<LatLng> points;
final Color color;
final double width;
@override
bool operator ==(Object other) =>
other is NearleMapPolyline &&
other.id == id &&
other.color == color &&
other.width == width &&
other.points.length == points.length &&
(points.isEmpty ||
(other.points.first.latitude == points.first.latitude &&
other.points.first.longitude == points.first.longitude &&
other.points.last.latitude == points.last.latitude &&
other.points.last.longitude == points.last.longitude));
@override
int get hashCode => Object.hash(id, color, width, points.length);
}
/// Thin, crash-tolerant facade over [MapLibreMapController].
class NearleMapController {
NearleMapController._(this._raw, this._isAlive);
final MapLibreMapController _raw;
final bool Function() _isAlive;
/// Escape hatch for anything this facade does not wrap.
MapLibreMapController get raw => _raw;
/// Frames [bounds] with uniform [padding] in logical pixels.
///
/// Degenerate bounds (a single point, or a perfectly horizontal/vertical
/// pair) make the native fit routine fail, so they are nudged outward first.
Future<void> fitBounds(LatLngBounds bounds, {double padding = 80}) async {
if (!_isAlive()) return;
const double minSpan = 0.0015; // ~165 m
var south = bounds.southwest.latitude;
var north = bounds.northeast.latitude;
var west = bounds.southwest.longitude;
var east = bounds.northeast.longitude;
if (north - south < minSpan) {
final mid = (north + south) / 2;
south = mid - minSpan / 2;
north = mid + minSpan / 2;
}
if (east - west < minSpan) {
final mid = (east + west) / 2;
west = mid - minSpan / 2;
east = mid + minSpan / 2;
}
await _guard(
() => _raw.animateCamera(
CameraUpdate.newLatLngBounds(
LatLngBounds(
southwest: LatLng(south, west),
northeast: LatLng(north, east),
),
left: padding,
top: padding,
right: padding,
bottom: padding,
),
),
);
}
Future<void> animateCamera(CameraUpdate update) =>
_guard(() => _raw.animateCamera(update));
Future<void> moveCamera(CameraUpdate update) =>
_guard(() => _raw.moveCamera(update));
Future<void> _guard(Future<void> Function() action) async {
if (!_isAlive()) return;
try {
await action();
} catch (e) {
debugPrint('[NEARLE_MAP] Camera command skipped: $e');
}
}
}
/// The app's single map surface, backed by MapLibre + OpenStreetMap vector
/// tiles. No API key is involved anywhere in this widget.
///
/// Markers and polylines are declarative: pass new lists and the widget diffs
/// them against what is on the map, updating only what actually changed. That
/// matters on the navigation screen, where the rider marker moves every few
/// metres and a clear-and-re-add would flicker.
class NearleMap extends StatefulWidget {
const NearleMap({
super.key,
required this.initialCameraPosition,
this.markers = const [],
this.polylines = const [],
this.myLocationEnabled = false,
this.trackingMode = MyLocationTrackingMode.none,
this.compassEnabled = false,
this.zoomGesturesEnabled = true,
this.scrollGesturesEnabled = true,
this.rotateGesturesEnabled = true,
this.tiltGesturesEnabled = true,
this.onMapReady,
});
final CameraPosition initialCameraPosition;
final List<NearleMapMarker> markers;
final List<NearleMapPolyline> polylines;
final bool myLocationEnabled;
final MyLocationTrackingMode trackingMode;
final bool compassEnabled;
final bool zoomGesturesEnabled;
final bool scrollGesturesEnabled;
final bool rotateGesturesEnabled;
final bool tiltGesturesEnabled;
final void Function(NearleMapController controller)? onMapReady;
@override
State<NearleMap> createState() => _NearleMapState();
}
class _NearleMapState extends State<NearleMap> {
MapLibreMapController? _controller;
NearleMapController? _facade;
bool _styleLoaded = false;
bool _disposed = false;
final Set<String> _registeredIcons = {};
final Map<String, Symbol> _symbols = {};
final Map<String, NearleMapMarker> _markerById = {};
final Map<String, Line> _lines = {};
final Map<String, NearleMapPolyline> _polylineById = {};
bool _syncing = false;
bool _syncQueued = false;
@override
void dispose() {
_disposed = true;
_controller?.onSymbolTapped.remove(_onSymbolTapped);
super.dispose();
}
bool _isAlive() => !_disposed && mounted && _styleLoaded;
Future<void> _onStyleLoaded() async {
final controller = _controller;
if (controller == null || _disposed) return;
_styleLoaded = true;
// Delivery pins overlap constantly in dense areas; without this MapLibre
// silently hides the collided ones.
try {
await controller.setSymbolIconAllowOverlap(true);
await controller.setSymbolIconIgnorePlacement(true);
} catch (e) {
debugPrint('[NEARLE_MAP] Overlap config skipped: $e');
}
await _sync();
final facade = _facade;
if (facade != null && !_disposed) widget.onMapReady?.call(facade);
}
@override
void didUpdateWidget(covariant NearleMap oldWidget) {
super.didUpdateWidget(oldWidget);
final markersChanged = !listEquals(oldWidget.markers, widget.markers);
final linesChanged = !listEquals(oldWidget.polylines, widget.polylines);
if (markersChanged || linesChanged) _sync();
}
/// Reconciles the widget's markers/polylines with what is on the map.
Future<void> _sync() async {
final controller = _controller;
if (controller == null || !_styleLoaded || _disposed) return;
// Native annotation calls are not re-entrant; coalesce overlapping syncs.
if (_syncing) {
_syncQueued = true;
return;
}
_syncing = true;
try {
await _syncPolylines(controller);
await _syncMarkers(controller);
} catch (e) {
debugPrint('[NEARLE_MAP] Annotation sync failed: $e');
} finally {
_syncing = false;
if (_syncQueued && !_disposed) {
_syncQueued = false;
unawaited(_sync());
}
}
}
Future<void> _syncPolylines(MapLibreMapController controller) async {
final desired = {for (final p in widget.polylines) p.id: p};
for (final id in _lines.keys.toList()) {
if (desired.containsKey(id)) continue;
final line = _lines.remove(id);
_polylineById.remove(id);
if (line != null) await controller.removeLine(line);
}
for (final polyline in widget.polylines) {
if (polyline.points.length < 2) continue;
final existing = _lines[polyline.id];
final options = LineOptions(
geometry: polyline.points,
lineColor: _hex(polyline.color),
lineWidth: polyline.width,
lineOpacity: 1.0,
lineJoin: 'round',
);
if (existing == null) {
_lines[polyline.id] = await controller.addLine(options);
} else if (_polylineById[polyline.id] != polyline) {
await controller.updateLine(existing, options);
}
_polylineById[polyline.id] = polyline;
}
}
Future<void> _syncMarkers(MapLibreMapController controller) async {
for (final marker in widget.markers) {
if (_registeredIcons.add(marker.icon.key)) {
try {
await controller.addImage(marker.icon.key, marker.icon.bytes);
} catch (e) {
_registeredIcons.remove(marker.icon.key);
debugPrint('[NEARLE_MAP] Icon registration failed: $e');
}
}
}
final desired = {for (final m in widget.markers) m.id: m};
for (final id in _symbols.keys.toList()) {
if (desired.containsKey(id)) continue;
final symbol = _symbols.remove(id);
_markerById.remove(id);
if (symbol != null) await controller.removeSymbol(symbol);
}
for (final marker in widget.markers) {
final options = SymbolOptions(
geometry: marker.position,
iconImage: marker.icon.key,
iconSize: marker.size,
iconAnchor: marker.anchor == MarkerAnchor.bottom ? 'bottom' : 'center',
);
final existing = _symbols[marker.id];
if (existing == null) {
_symbols[marker.id] = await controller.addSymbol(options, {
'nearleId': marker.id,
});
} else if (_markerById[marker.id] != marker) {
await controller.updateSymbol(existing, options);
}
_markerById[marker.id] = marker;
}
}
void _onSymbolTapped(Symbol symbol) {
final id = symbol.data?['nearleId'];
if (id is! String) return;
// Read the tap handler from the current widget, not the cached marker, so
// it always closes over fresh state.
for (final marker in widget.markers) {
if (marker.id == id) {
marker.onTap?.call();
return;
}
}
}
static String _hex(Color color) =>
'#${color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2)}';
@override
Widget build(BuildContext context) {
return MapLibreMap(
styleString: MapConfig.styleUrl,
initialCameraPosition: widget.initialCameraPosition,
myLocationEnabled: widget.myLocationEnabled,
myLocationTrackingMode: widget.trackingMode,
compassEnabled: widget.compassEnabled,
zoomGesturesEnabled: widget.zoomGesturesEnabled,
scrollGesturesEnabled: widget.scrollGesturesEnabled,
rotateGesturesEnabled: widget.rotateGesturesEnabled,
tiltGesturesEnabled: widget.tiltGesturesEnabled,
trackCameraPosition: true,
onMapCreated: (controller) {
_controller = controller;
_facade = NearleMapController._(controller, _isAlive);
controller.onSymbolTapped.add(_onSymbolTapped);
},
onStyleLoadedCallback: _onStyleLoaded,
);
}
}

View File

@@ -0,0 +1,332 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:maplibre_gl/maplibre_gl.dart';
import 'map_config.dart';
/// A road route returned by the routing backend.
class RouteResult {
const RouteResult({
required this.points,
required this.distanceMeters,
required this.durationSeconds,
});
/// Decoded geometry of the route, ready to draw as a polyline.
final List<LatLng> points;
/// Total road distance in metres.
final double distanceMeters;
/// Total travel time in seconds.
final double durationSeconds;
double get distanceKm => distanceMeters / 1000.0;
bool get isEmpty => points.isEmpty && distanceMeters == 0;
}
/// Road routing via OSRM — the Open Source Routing Machine.
///
/// Replaces the Google Directions API (and `flutter_polyline_points`). OSRM is
/// keyless and self-hostable, so there is no credential to ship and no
/// per-request billing.
///
/// Request shape:
/// GET {base}/route/v1/driving/{lon},{lat};{lon},{lat}?overview=full&geometries=polyline
///
/// Note OSRM takes coordinates as **lon,lat** — the reverse of most APIs.
class RoutingService {
const RoutingService._();
static const Duration _timeout = Duration(seconds: 10);
static const Duration _cacheTtl = Duration(minutes: 30);
static const int _maxCacheEntries = 120;
static final Map<String, _CacheEntry> _cache = {};
/// Computes a driving route. [waypoints] are visited in order between
/// [origin] and [destination] — OSRM routes through all of them in a single
/// request, so a multi-stop route costs one call, not one per leg.
///
/// Returns null when the service is unreachable or finds no route, so callers
/// can fall back to a straight line.
static Future<RouteResult?> getRoute({
required LatLng origin,
required LatLng destination,
List<LatLng> waypoints = const [],
String profile = 'driving',
}) async {
if (!_isValid(origin) || !_isValid(destination)) {
debugPrint('[ROUTING] Invalid coordinates, skipping request.');
return null;
}
final cacheKey = _cacheKey(origin, destination, waypoints, profile);
final cached = _cache[cacheKey];
if (cached != null && !cached.isExpired) return cached.result;
// OSRM wants lon,lat pairs joined by ';' in the path itself.
final coordinates = [
origin,
...waypoints,
destination,
].map((p) => '${p.longitude},${p.latitude}').join(';');
final uri = Uri.parse(
'${MapConfig.osrmBaseUrl}/route/v1/$profile/$coordinates',
).replace(
queryParameters: const {
'overview': 'full',
'geometries': 'polyline',
'alternatives': 'false',
'steps': 'false',
},
);
try {
final response = await http.get(uri).timeout(_timeout);
if (response.statusCode != 200) {
debugPrint('[ROUTING] HTTP ${response.statusCode}');
return null;
}
final result = _parse(json.decode(response.body));
if (result == null || result.isEmpty) {
debugPrint('[ROUTING] No route in response.');
return null;
}
_store(cacheKey, result);
return result;
} catch (e) {
debugPrint('[ROUTING] Request failed: $e');
return null;
}
}
/// Convenience wrapper for callers that only need road distance.
static Future<double?> getRouteDistanceKm({
required LatLng origin,
required LatLng destination,
}) async {
final route = await getRoute(origin: origin, destination: destination);
return route?.distanceKm;
}
// ---------------------------------------------------------------------
// Response parsing
// ---------------------------------------------------------------------
/// Parses an OSRM `route` response. Written tolerantly so a self-hosted
/// build, a proxy, or a drop-in alternative (Valhalla's OSRM-compatible
/// endpoint, GraphHopper's) does not require code changes: numeric fields are
/// accepted as plain numbers or as `{"value": n}` objects, and the geometry
/// is looked for under several key names.
static RouteResult? _parse(dynamic body) {
if (body is! Map) return null;
final code = body['code'];
if (code is String && code != 'Ok') {
debugPrint('[ROUTING] Service returned code: $code');
return null;
}
final routes = body['routes'];
if (routes is! List || routes.isEmpty) return null;
final route = routes.first;
if (route is! Map) return null;
var distance = _num(route['distance']);
var duration = _num(route['duration']);
// Fall back to summing legs when totals are absent.
if (distance == 0 || duration == 0) {
final legs = route['legs'];
if (legs is List) {
var legDistance = 0.0;
var legDuration = 0.0;
for (final leg in legs) {
if (leg is! Map) continue;
legDistance += _num(leg['distance']);
legDuration += _num(leg['duration']);
}
if (distance == 0) distance = legDistance;
if (duration == 0) duration = legDuration;
}
}
final points = _extractGeometry(route);
if (points.isEmpty && distance == 0) return null;
return RouteResult(
points: points,
distanceMeters: distance,
durationSeconds: duration,
);
}
static List<LatLng> _extractGeometry(Map route) {
for (final key in const ['geometry', 'overview_polyline', 'polyline']) {
final value = route[key];
if (value is String && value.isNotEmpty) {
final decoded = decodePolyline(value);
if (decoded.isNotEmpty) return decoded;
}
// Encoded polyline nested as {"points": "<encoded>"}.
if (value is Map && value['points'] is String) {
final decoded = decodePolyline(value['points'] as String);
if (decoded.isNotEmpty) return decoded;
}
// GeoJSON LineString, if the caller ever requests geometries=geojson.
if (value is Map && value['coordinates'] is List) {
final coords = value['coordinates'] as List;
final points = <LatLng>[];
for (final c in coords) {
if (c is List && c.length >= 2) {
points.add(LatLng(_num(c[1]), _num(c[0])));
}
}
if (points.isNotEmpty) return points;
}
}
// Last resort: stitch the per-step geometries together.
final points = <LatLng>[];
final legs = route['legs'];
if (legs is List) {
for (final leg in legs) {
if (leg is! Map) continue;
final steps = leg['steps'];
if (steps is! List) continue;
for (final step in steps) {
if (step is! Map) continue;
final geometry = step['geometry'] ?? step['polyline'];
final encoded = geometry is Map ? geometry['points'] : geometry;
if (encoded is String && encoded.isNotEmpty) {
points.addAll(decodePolyline(encoded));
}
}
}
}
return points;
}
static double _num(dynamic value) {
if (value == null) return 0.0;
if (value is num) return value.toDouble();
if (value is Map) return _num(value['value']);
return double.tryParse(value.toString()) ?? 0.0;
}
// ---------------------------------------------------------------------
// Polyline codec (Google's Encoded Polyline Algorithm)
//
// OSRM's default `geometries=polyline` uses precision 5. Its `polyline6`
// option uses precision 6 — pass `precision: 6` if you switch to it.
// ---------------------------------------------------------------------
static List<LatLng> decodePolyline(String encoded, {int precision = 5}) {
final points = <LatLng>[];
final factor = _pow10(precision);
var index = 0;
var lat = 0;
var lng = 0;
while (index < encoded.length) {
final dLat = _decodeValue(encoded, index);
if (dLat == null) break;
index = dLat.nextIndex;
lat += dLat.value;
final dLng = _decodeValue(encoded, index);
if (dLng == null) break;
index = dLng.nextIndex;
lng += dLng.value;
points.add(LatLng(lat / factor, lng / factor));
}
return points;
}
static _Delta? _decodeValue(String encoded, int index) {
var shift = 0;
var result = 0;
int byte;
do {
if (index >= encoded.length) return null;
byte = encoded.codeUnitAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
final value = (result & 1) != 0 ? ~(result >> 1) : (result >> 1);
return _Delta(value, index);
}
static double _pow10(int exponent) {
var value = 1.0;
for (var i = 0; i < exponent; i++) {
value *= 10;
}
return value;
}
// ---------------------------------------------------------------------
// Cache
// ---------------------------------------------------------------------
static bool _isValid(LatLng p) =>
p.latitude != 0 &&
p.longitude != 0 &&
p.latitude.abs() <= 90 &&
p.longitude.abs() <= 180;
static String _cacheKey(
LatLng origin,
LatLng destination,
List<LatLng> waypoints,
String profile,
) {
// ~11 m resolution: identical pickup/drop pairs reuse one request.
String round(LatLng p) =>
'${p.latitude.toStringAsFixed(4)},${p.longitude.toStringAsFixed(4)}';
return [
profile,
round(origin),
round(destination),
...waypoints.map(round),
].join('>');
}
static void _store(String key, RouteResult result) {
if (_cache.length >= _maxCacheEntries) {
final oldest = _cache.entries.reduce(
(a, b) => a.value.storedAt.isBefore(b.value.storedAt) ? a : b,
);
_cache.remove(oldest.key);
}
_cache[key] = _CacheEntry(result, DateTime.now());
}
@visibleForTesting
static void clearCache() => _cache.clear();
}
class _Delta {
const _Delta(this.value, this.nextIndex);
final int value;
final int nextIndex;
}
class _CacheEntry {
const _CacheEntry(this.result, this.storedAt);
final RouteResult result;
final DateTime storedAt;
bool get isExpired =>
DateTime.now().difference(storedAt) > RoutingService._cacheTtl;
}

View File

@@ -5,12 +5,13 @@ import 'dart:async';
import 'dart:convert';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_polyline_points/flutter_polyline_points.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:geolocator/geolocator.dart';
import 'package:get/get.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:lottie/lottie.dart' hide Marker;
import 'package:nearle/services/maps/map_marker_icons.dart';
import 'package:nearle/services/maps/nearle_map.dart';
import 'package:nearle/services/maps/routing_service.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
import 'package:slider_button_lite/feature/presentation/slider_button/slider.dart';

View File

@@ -16,24 +16,18 @@ class _DeliveryMapScreen extends StatefulWidget {
}
class _DeliveryMapScreenState extends State<_DeliveryMapScreen> {
GoogleMapController? _mapController;
NearleMapController? _mapController;
late final LatLng _pickupLocation;
late final LatLng _dropLocation;
final Set<Marker> _markers = {};
final Set<Polyline> _polylines = {};
late final PolylinePoints _polylinePoints;
bool _isLoadingRoute = true;
List<NearleMapMarker> _markers = const [];
List<NearleMapPolyline> _polylines = const [];
bool _isNavigating = false; // Prevent multiple clicks
static const String _googleApiKey = 'AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q';
@override
void initState() {
super.initState();
_polylinePoints = PolylinePoints(apiKey: _googleApiKey);
_resolveLocationsFromDelivery();
_setMarkers();
_isLoadingRoute = false;
_createRealRoute();
}
@@ -73,113 +67,70 @@ class _DeliveryMapScreenState extends State<_DeliveryMapScreen> {
_dropLocation = drop;
}
void _setMarkers() {
_markers.addAll([
Marker(
markerId: const MarkerId('pickup'),
position: _pickupLocation,
infoWindow: const InfoWindow(title: 'Pickup Location'),
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed),
),
Marker(
markerId: const MarkerId('drop'),
position: _dropLocation,
infoWindow: const InfoWindow(title: 'Drop Location'),
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen),
),
]);
Future<void> _setMarkers() async {
final pickupIcon = await MapMarkerIcons.pin(Colors.red);
final dropIcon = await MapMarkerIcons.pin(Colors.green);
if (!mounted) return;
setState(() {
_markers = [
NearleMapMarker(
id: 'pickup',
position: _pickupLocation,
icon: pickupIcon,
),
NearleMapMarker(id: 'drop', position: _dropLocation, icon: dropIcon),
];
});
}
Future<void> _createRealRoute() async {
try {
final request = PolylineRequest(
origin: PointLatLng(
_pickupLocation.latitude,
_pickupLocation.longitude,
),
destination: PointLatLng(
_dropLocation.latitude,
_dropLocation.longitude,
),
mode: TravelMode.driving,
);
final route = await RoutingService.getRoute(
origin: _pickupLocation,
destination: _dropLocation,
);
final result = await _polylinePoints.getRouteBetweenCoordinates(
request: request,
);
if (result.points.isNotEmpty) {
final routePoints = result.points
.map((e) => LatLng(e.latitude, e.longitude))
.toList();
if (mounted) {
setState(() {
_polylines.add(
Polyline(
polylineId: const PolylineId('real_route'),
color: ColorConstants.primaryColor,
width: 6,
points: routePoints,
),
);
_isLoadingRoute = false;
});
_fitMapToRoute();
}
} else {
debugPrint('[MAP_PREVIEW] No route found');
if (mounted) {
setState(() => _isLoadingRoute = false);
}
}
} catch (e) {
debugPrint('[MAP_PREVIEW] Error creating route: $e');
if (mounted) {
setState(() => _isLoadingRoute = false);
}
}
}
Future<void> _fitMapToRoute() async {
if (!mounted) return;
if (_mapController == null) return;
// Check if controller is still alive (important!)
try {
await _mapController!.getVisibleRegion();
} catch (e) {
debugPrint("❌ Map controller is dead. Skip animateCamera.");
return;
}
final bounds = LatLngBounds(
southwest: LatLng(
math.min(_pickupLocation.latitude, _dropLocation.latitude),
math.min(_pickupLocation.longitude, _dropLocation.longitude),
),
northeast: LatLng(
math.max(_pickupLocation.latitude, _dropLocation.latitude),
math.max(_pickupLocation.longitude, _dropLocation.longitude),
),
);
// Try animate safely
for (int i = 0; i < 10; i++) {
if (!mounted) return;
try {
await _mapController!.animateCamera(
CameraUpdate.newLatLngBounds(bounds, 80),
);
return;
} catch (e) {
await Future.delayed(const Duration(milliseconds: 150));
}
// Fall back to a straight line so the screen still shows the connection
// when routing is unavailable.
final points = (route != null && route.points.isNotEmpty)
? route.points
: <LatLng>[_pickupLocation, _dropLocation];
if (route == null) debugPrint('[MAP_PREVIEW] No route found');
setState(() {
_polylines = [
NearleMapPolyline(
id: 'real_route',
points: points,
color: ColorConstants.primaryColor,
width: 6,
),
];
});
_fitMapToRoute();
}
debugPrint("❌ animateCamera failed after retries (map probably disposed)");
}
Future<void> _fitMapToRoute() async {
final controller = _mapController;
if (!mounted || controller == null) return;
await controller.fitBounds(
LatLngBounds(
southwest: LatLng(
math.min(_pickupLocation.latitude, _dropLocation.latitude),
math.min(_pickupLocation.longitude, _dropLocation.longitude),
),
northeast: LatLng(
math.max(_pickupLocation.latitude, _dropLocation.latitude),
math.max(_pickupLocation.longitude, _dropLocation.longitude),
),
),
padding: 80,
);
}
@@ -188,27 +139,20 @@ Future<void> _fitMapToRoute() async {
return Scaffold(
body: Stack(
children: [
SizedBox.expand(
child: GoogleMap(
initialCameraPosition: CameraPosition(
target: _pickupLocation,
zoom: 14.5,
),
onMapCreated: (controller) {
_mapController = controller;
Future.delayed(const Duration(milliseconds: 500), () {
if (!_isLoadingRoute && _polylines.isNotEmpty) {
_fitMapToRoute();
}
});
},
markers: _markers,
polylines: _polylines,
zoomControlsEnabled: false,
myLocationButtonEnabled: false,
),
),
SizedBox.expand(
child: NearleMap(
initialCameraPosition: CameraPosition(
target: _pickupLocation,
zoom: 14.5,
),
markers: _markers,
polylines: _polylines,
onMapReady: (controller) {
_mapController = controller;
_fitMapToRoute();
},
),
),
Positioned(
top: 50,
left: 16,

View File

@@ -24,15 +24,11 @@ class MultiCustomerMapScreen extends StatefulWidget {
}
class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen> {
GoogleMapController? mapController;
Set<Marker> markers = {};
Set<Polyline> polylines = {};
NearleMapController? mapController;
List<NearleMapMarker> markers = const [];
List<NearleMapPolyline> polylines = const [];
LatLng? currentLocation;
bool _isLoading = true;
bool _mapReady = false;
final PolylinePoints _polylinePoints = PolylinePoints(
apiKey: 'AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q',
);
final Map<String, Map<String, dynamic>> _deliveryMap = {};
@override
@@ -199,7 +195,7 @@ class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen> {
}
Future<void> _createDeliveryMarkers() async {
Set<Marker> tempMarkers = {};
final List<NearleMapMarker> tempMarkers = [];
List<_StepPoint> stepPoints = [];
_deliveryMap.clear();
@@ -231,8 +227,6 @@ class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen> {
);
if (lat == 0 || lon == 0) continue;
final customerName = (delivery['deliverycustomer'] ?? 'Customer ${i + 1}')
.toString();
final orderId = (delivery['orderid'] ?? '').toString();
final status = (delivery['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
@@ -242,23 +236,19 @@ class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen> {
// Store delivery data for dialog
_deliveryMap['delivery_$orderId'] = delivery;
final icon = await _createCircularMarkerBitmap(
final icon = await MapMarkerIcons.numberedCircle(
displayStep,
primaryColor: ColorConstants.primaryColor,
isSkipped: isSkipped,
isNext: isNext,
);
tempMarkers.add(
Marker(
markerId: MarkerId('delivery_$orderId'),
NearleMapMarker(
id: 'delivery_$orderId',
position: LatLng(lat, lon),
icon: icon,
infoWindow: InfoWindow(
title: isSkipped
? 'Step $displayStep: $customerName (SKIPPED)'
: 'Step $displayStep: $customerName',
snippet: 'Order #$orderId',
),
anchor: MarkerAnchor.center,
onTap: () {
_showCustomerDetailsSheet(delivery, displayStep);
},
@@ -270,13 +260,11 @@ class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen> {
if (currentLocation != null) {
tempMarkers.add(
Marker(
markerId: const MarkerId('current_location'),
NearleMapMarker(
id: 'current_location',
position: currentLocation!,
icon: BitmapDescriptor.defaultMarkerWithHue(
BitmapDescriptor.hueAzure,
),
infoWindow: const InfoWindow(title: 'You are here'),
icon: await MapMarkerIcons.dot(Colors.blueAccent),
anchor: MarkerAnchor.center,
),
);
stepPoints.insert(0, _StepPoint(0, currentLocation!));
@@ -284,67 +272,7 @@ class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen> {
stepPoints.sort((a, b) => a.step.compareTo(b.step));
Set<Polyline> newPolylines = {};
int segmentIndex = 0;
for (int i = 0; i < stepPoints.length - 1; i++) {
final start = stepPoints[i].position;
final end = stepPoints[i + 1].position;
try {
// ignore: deprecated_member_use
final request = PolylineRequest(
origin: PointLatLng(start.latitude, start.longitude),
destination: PointLatLng(end.latitude, end.longitude),
mode: TravelMode.driving,
);
final result = await _polylinePoints.getRouteBetweenCoordinates(
request: request,
);
if (result.points.isNotEmpty) {
final routePoints = result.points
.map((p) => LatLng(p.latitude, p.longitude))
.toList();
newPolylines.add(
Polyline(
polylineId: PolylineId('segment_$segmentIndex'),
points: routePoints,
width: 6,
color: Colors.blue,
startCap: Cap.roundCap,
endCap: Cap.roundCap,
jointType: JointType.round,
geodesic: true,
),
);
segmentIndex++;
} else {
newPolylines.add(
Polyline(
polylineId: PolylineId('segment_fallback_$segmentIndex'),
points: [start, end],
width: 4,
color: Colors.blue.shade200,
),
);
segmentIndex++;
}
} catch (e) {
debugPrint('[CUSTOMER_MAP] Directions error for segment $i: $e');
newPolylines.add(
Polyline(
polylineId: PolylineId('segment_error_$segmentIndex'),
points: [start, end],
width: 4,
color: Colors.blue.shade200,
),
);
segmentIndex++;
}
}
final newPolylines = await _buildRoutePolylines(stepPoints);
if (mounted) {
setState(() {
@@ -357,8 +285,50 @@ class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen> {
_fitBoundsToMarkersAndPolylines();
}
/// Builds the route through every stop in one Directions call.
///
/// This used to issue one request per consecutive pair of stops, so opening
/// this screen with N stops cost N billable requests. OSRM takes every stop
/// as a coordinate in one request, so the whole route is a single call.
Future<List<NearleMapPolyline>> _buildRoutePolylines(
List<_StepPoint> stepPoints,
) async {
if (stepPoints.length < 2) return const [];
final route = await RoutingService.getRoute(
origin: stepPoints.first.position,
destination: stepPoints.last.position,
waypoints: stepPoints
.sublist(1, stepPoints.length - 1)
.map((s) => s.position)
.toList(),
);
if (route != null && route.points.isNotEmpty) {
return [
NearleMapPolyline(
id: 'route',
points: route.points,
width: 6,
color: Colors.blue,
),
];
}
debugPrint('[CUSTOMER_MAP] Routing unavailable, drawing direct segments');
return [
NearleMapPolyline(
id: 'route_fallback',
points: stepPoints.map((s) => s.position).toList(),
width: 4,
color: Colors.blue.shade200,
),
];
}
Future<void> _fitBoundsToMarkersAndPolylines() async {
if (mapController == null) return;
final controller = mapController;
if (controller == null) return;
double minLat = double.infinity;
double maxLat = -double.infinity;
@@ -367,8 +337,7 @@ class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen> {
bool hasPoint = false;
for (final m in markers) {
final pos = m.position;
void include(LatLng pos) {
minLat = math.min(minLat, pos.latitude);
maxLat = math.max(maxLat, pos.latitude);
minLng = math.min(minLng, pos.longitude);
@@ -376,137 +345,24 @@ class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen> {
hasPoint = true;
}
for (final m in markers) {
include(m.position);
}
for (final poly in polylines) {
for (final pos in poly.points) {
minLat = math.min(minLat, pos.latitude);
maxLat = math.max(maxLat, pos.latitude);
minLng = math.min(minLng, pos.longitude);
maxLng = math.max(maxLng, pos.longitude);
hasPoint = true;
include(pos);
}
}
if (!hasPoint) return;
final bounds = LatLngBounds(
southwest: LatLng(minLat, minLng),
northeast: LatLng(maxLat, maxLng),
);
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
await mapController!.animateCamera(
CameraUpdate.newLatLngBounds(bounds, 80),
);
} catch (e) {
Future.delayed(const Duration(milliseconds: 300), () async {
try {
await mapController!.animateCamera(
CameraUpdate.newLatLngBounds(bounds, 80),
);
} catch (e) {
debugPrint('[CUSTOMER_MAP] Retry failed: $e');
}
});
}
});
}
Future<BitmapDescriptor> _createCircularMarkerBitmap(
int number, {
bool isSkipped = false,
bool isNext = false,
}) async {
const double size = 70;
final pictureRecorder = ui.PictureRecorder();
final canvas = Canvas(pictureRecorder);
final center = Offset(size / 2, size / 2);
final paint = Paint()
..color = isSkipped ? Colors.orange : ColorConstants.primaryColor;
canvas.drawCircle(center, 15, paint);
final border = Paint()
..color = isSkipped ? Colors.orange.shade900 : Colors.white
..style = PaintingStyle.stroke
..strokeWidth = isSkipped ? 4 : 3;
canvas.drawCircle(center, 15, border);
final textPainter = TextPainter(
text: TextSpan(
text: number.toString(),
style: const TextStyle(
fontSize: 19,
color: Colors.white,
fontWeight: FontWeight.bold,
),
await controller.fitBounds(
LatLngBounds(
southwest: LatLng(minLat, minLng),
northeast: LatLng(maxLat, maxLng),
),
textDirection: TextDirection.ltr,
padding: 80,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(
center.dx - textPainter.width / 2,
center.dy - textPainter.height / 2,
),
);
// Draw "NEXT" indicator (road sign with down arrow) on top
if (isNext) {
// Draw road sign background (rectangle)
final signPaint = Paint()
..color = Colors.green
..style = PaintingStyle.fill;
final signRect = RRect.fromRectAndRadius(
Rect.fromCenter(center: Offset(size / 2, 8), width: 45, height: 30),
const Radius.circular(4),
);
canvas.drawRRect(signRect, signPaint);
// Draw border
final signBorder = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawRRect(signRect, signBorder);
// Draw "NEXT" text
final nextTextPainter = TextPainter(
text: const TextSpan(
text: 'NEXT',
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
nextTextPainter.layout();
nextTextPainter.paint(
canvas,
Offset(size / 2 - nextTextPainter.width / 2, 4),
);
// Draw down arrow
final arrowPath = Path();
arrowPath.moveTo(size / 2, 18);
arrowPath.lineTo(size / 2 - 4, 24);
arrowPath.lineTo(size / 2 + 4, 24);
arrowPath.close();
final arrowPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
canvas.drawPath(arrowPath, arrowPaint);
}
final img = await pictureRecorder.endRecording().toImage(
size.toInt(),
size.toInt(),
);
final data = await img.toByteData(format: ui.ImageByteFormat.png);
return BitmapDescriptor.bytes(data!.buffer.asUint8List());
}
void _showCustomerDetailsSheet(
@@ -872,21 +728,14 @@ class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen> {
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: GoogleMap(
: NearleMap(
initialCameraPosition: initialCameraPosition,
markers: markers,
polylines: polylines,
myLocationEnabled: true,
myLocationButtonEnabled: true,
onMapCreated: (controller) {
onMapReady: (controller) {
mapController = controller;
_mapReady = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 150), () {
if (_mapReady) _fitBoundsToMarkersAndPolylines();
});
});
_fitBoundsToMarkersAndPolylines();
},
),
);

View File

@@ -19,12 +19,15 @@ class _RiderNavigationScreen extends StatefulWidget {
class _RiderNavigationScreenState extends State<_RiderNavigationScreen>
with WidgetsBindingObserver {
GoogleMapController? mapController;
NearleMapController? mapController;
bool _mapReady = false;
bool _isDisposed = false;
Position? _currentPosition;
StreamSubscription<Position>? _positionStream;
final Set<Marker> _markers = {};
List<NearleMapMarker> _markers = const [];
MarkerIcon? _riderIcon;
MarkerIcon? _dropIcon;
LatLng? _lastRiderPosition;
late final LatLng dropLocation;
bool _isInitializing = true;
bool _hasOpenedGoogleMaps = false;
@@ -46,6 +49,7 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen>
super.initState();
WidgetsBinding.instance.addObserver(this);
_resolveDropFromDelivery();
_loadMarkerIcons();
_initializeMap();
_initEtaForNav();
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -214,8 +218,8 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen>
if (lastPos != null && mounted) {
setState(() {
_currentPosition = lastPos;
_addMarkers(LatLng(lastPos.latitude, lastPos.longitude));
});
_addMarkers(LatLng(lastPos.latitude, lastPos.longitude));
_startLiveTracking();
}
});
@@ -338,8 +342,8 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen>
if (mounted) {
setState(() {
_currentPosition = position;
_addMarkers(LatLng(position.latitude, position.longitude));
});
_addMarkers(LatLng(position.latitude, position.longitude));
final distance = Geolocator.distanceBetween(
position.latitude,
@@ -362,27 +366,36 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen>
});
}
/// Marker bitmaps are drawn on a canvas, so they arrive asynchronously.
/// Once they land, rebuild whatever markers are already pending.
Future<void> _loadMarkerIcons() async {
final rider = await MapMarkerIcons.dot(Colors.blueAccent);
final drop = await MapMarkerIcons.pin(Colors.red);
if (!mounted || _isDisposed) return;
setState(() {
_riderIcon = rider;
_dropIcon = drop;
_addMarkers(_lastRiderPosition ?? dropLocation);
});
}
/// Mutates [_markers] only — callers own the [setState] so this can be
/// invoked from inside an existing one without nesting.
void _addMarkers(LatLng riderPos) {
_markers
..clear()
..add(
Marker(
markerId: const MarkerId('rider'),
position: riderPos,
icon: BitmapDescriptor.defaultMarkerWithHue(
BitmapDescriptor.hueAzure,
),
infoWindow: const InfoWindow(title: 'You (Rider)'),
),
)
..add(
Marker(
markerId: const MarkerId('drop'),
position: dropLocation,
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed),
infoWindow: const InfoWindow(title: 'Drop Location'),
),
);
_lastRiderPosition = riderPos;
final riderIcon = _riderIcon;
final dropIcon = _dropIcon;
if (riderIcon == null || dropIcon == null) return;
_markers = [
NearleMapMarker(
id: 'rider',
position: riderPos,
icon: riderIcon,
anchor: MarkerAnchor.center,
),
NearleMapMarker(id: 'drop', position: dropLocation, icon: dropIcon),
];
}
Future<void> _attemptAutoOpenNavigation({bool fromTimer = false}) async {
@@ -511,9 +524,8 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen>
return Scaffold(
body: Stack(
children: [
GoogleMap(
mapType: MapType.normal,
onMapCreated: (controller) {
NearleMap(
onMapReady: (controller) {
mapController = controller;
_mapReady = true;
},
@@ -527,10 +539,7 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen>
),
markers: _markers,
myLocationEnabled: true,
myLocationButtonEnabled: false,
trafficEnabled: true,
compassEnabled: true,
buildingsEnabled: true,
tiltGesturesEnabled: true,
),
if (!_hasOpenedGoogleMaps)
@@ -994,11 +1003,7 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen>
}
void _safeAnimateCamera(CameraUpdate update) {
if (!_mapReady || _isDisposed || mapController == null) return;
try {
mapController?.animateCamera(update);
} catch (e) {
debugPrint('[NAVIGATION] animateCamera skipped: $e');
}
if (!_mapReady || _isDisposed) return;
mapController?.animateCamera(update);
}
}

View File

@@ -2,7 +2,6 @@
import 'package:flutter/material.dart';
import 'dart:convert';
import 'package:flutter_polyline_points/flutter_polyline_points.dart';
import 'dart:async';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
@@ -22,8 +21,10 @@ import 'package:nearle/controllers/delivery.dart';
import 'package:nearle/controllers/deliveries_controller.dart';
import 'package:geolocator/geolocator.dart';
import 'package:slide_to_submit_button/slide_to_submit_button.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:geocoding/geocoding.dart';
import 'package:nearle/services/maps/map_marker_icons.dart';
import 'package:nearle/services/maps/nearle_map.dart';
import 'package:nearle/services/maps/routing_service.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:floating/floating.dart';
import 'dart:math' as math;
@@ -2624,7 +2625,6 @@ void _showmapDetailsSheet(
double pickupLat = parseD(pickupLatStr);
double pickupLng = parseD(pickupLngStr);
bool hasPickup = pickupLat != 0 && pickupLng != 0;
final Completer<GoogleMapController> mapController = Completer();
double riderLat = 0.0;
double riderLng = 0.0;
@@ -2661,36 +2661,46 @@ void _showmapDetailsSheet(
final bool hasAny = hasRiderLocation && hasPickup;
final List<LatLng> polylineCoords = [];
final Set<Polyline> polylines = {};
const String googleAPIKey = "AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q";
final List<NearleMapPolyline> polylines = [];
if (hasRiderLocation && hasPickup) {
final PolylinePoints polylinePoints = PolylinePoints(apiKey: googleAPIKey);
final PolylineResult result = await polylinePoints
.getRouteBetweenCoordinates(
request: PolylineRequest(
origin: PointLatLng(riderLat, riderLng),
destination: PointLatLng(pickupLat, pickupLng),
mode: TravelMode.driving,
),
);
if (result.points.isNotEmpty) {
for (var point in result.points) {
polylineCoords.add(LatLng(point.latitude, point.longitude));
}
final route = await RoutingService.getRoute(
origin: LatLng(riderLat, riderLng),
destination: LatLng(pickupLat, pickupLng),
);
if (route != null && route.points.isNotEmpty) {
polylines.add(
Polyline(
polylineId: const PolylineId("route"),
NearleMapPolyline(
id: 'route',
points: route.points,
color: Colors.blueAccent,
width: 5,
points: polylineCoords,
),
);
}
}
final List<NearleMapMarker> mapMarkers = [];
if (hasRiderLocation) {
mapMarkers.add(
NearleMapMarker(
id: 'rider',
position: LatLng(riderLat, riderLng),
icon: await MapMarkerIcons.dot(Colors.blueAccent),
anchor: MarkerAnchor.center,
),
);
}
if (hasPickup) {
mapMarkers.add(
NearleMapMarker(
id: 'pickup',
position: LatLng(pickupLat, pickupLng),
icon: await MapMarkerIcons.pin(Colors.red),
),
);
}
// ⭐ STEP 3 — Close loader
Navigator.pop(context);
@@ -2848,7 +2858,7 @@ void _showmapDetailsSheet(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade200),
),
child: GoogleMap(
child: NearleMap(
initialCameraPosition: CameraPosition(
target: hasRiderLocation && hasPickup
? LatLng(
@@ -2862,52 +2872,22 @@ void _showmapDetailsSheet(
: const LatLng(0, 0),
zoom: hasRiderLocation && hasPickup ? 13 : 15,
),
markers: {
if (hasRiderLocation)
Marker(
markerId: const MarkerId("rider"),
position: LatLng(riderLat, riderLng),
infoWindow: const InfoWindow(
title: "Your Location",
),
icon: BitmapDescriptor.defaultMarkerWithHue(
BitmapDescriptor.hueAzure,
),
),
if (hasPickup)
Marker(
markerId: const MarkerId("pickup"),
position: LatLng(pickupLat, pickupLng),
infoWindow: const InfoWindow(
title: "Pickup Location",
),
icon: BitmapDescriptor.defaultMarkerWithHue(
BitmapDescriptor.hueRed,
),
),
},
markers: mapMarkers,
polylines: polylines,
zoomGesturesEnabled: true,
scrollGesturesEnabled: true,
tiltGesturesEnabled: true,
rotateGesturesEnabled: true,
zoomControlsEnabled: false,
myLocationButtonEnabled: false,
onMapCreated: (GoogleMapController controller) async {
mapController.complete(controller);
onMapReady: (controller) async {
if (hasRiderLocation && hasPickup) {
final bounds = LatLngBounds(
southwest: LatLng(
riderLat < pickupLat ? riderLat : pickupLat,
riderLng < pickupLng ? riderLng : pickupLng,
await controller.fitBounds(
LatLngBounds(
southwest: LatLng(
riderLat < pickupLat ? riderLat : pickupLat,
riderLng < pickupLng ? riderLng : pickupLng,
),
northeast: LatLng(
riderLat > pickupLat ? riderLat : pickupLat,
riderLng > pickupLng ? riderLng : pickupLng,
),
),
northeast: LatLng(
riderLat > pickupLat ? riderLat : pickupLat,
riderLng > pickupLng ? riderLng : pickupLng,
),
);
await controller.animateCamera(
CameraUpdate.newLatLngBounds(bounds, 80),
padding: 80,
);
}
},

View File

@@ -45,7 +45,7 @@ class HelpCenter extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
"We make sure your delivery experience is smooth and clear. Whether you’re on your first trip or your hundredth, we’ve got your back. Browse through frequently asked questions or reach out directly if you need further help.",
"We make sure your delivery experience is smooth and clear. Whether you're on your first trip or your hundredth, we've got your back. Browse through frequently asked questions or reach out directly if you need further help.",
style: TextStyle(fontSize: 18,color: Colors.grey.shade700, height: 1.4, fontFamily: FontConstants.fontFamily),
),
const SizedBox(height: 16),