243 lines
6.5 KiB
Dart
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());
|
|
}
|
|
}
|