Files
Xpress-rider/lib/services/maps/map_marker_icons.dart
2026-08-12 10:50:45 +05:30

243 lines
6.5 KiB
Dart

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());
}
}