mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: MeshCore SAR - Flutter BLE mesh radio companion app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
175
lib/widgets/map/location_pointer.dart
Normal file
175
lib/widgets/map/location_pointer.dart
Normal file
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A navigation arrow pointer that indicates the user's location and direction of travel.
|
||||
///
|
||||
/// The pointer consists of:
|
||||
/// - An outer semi-transparent circle representing GPS accuracy
|
||||
/// - An inner triangular arrow pointing in the direction of travel/heading
|
||||
/// - Optional rotation based on compass or GPS heading
|
||||
class LocationPointer extends StatelessWidget {
|
||||
/// The heading in degrees (0-360, where 0 = North, 90 = East)
|
||||
/// If null or -1, the pointer will not rotate
|
||||
final double? heading;
|
||||
|
||||
/// The primary color for the pointer
|
||||
final Color color;
|
||||
|
||||
/// The size of the entire widget
|
||||
final double size;
|
||||
|
||||
const LocationPointer({
|
||||
super.key,
|
||||
this.heading,
|
||||
required this.color,
|
||||
this.size = 40.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Determine if we have valid heading data
|
||||
final hasValidHeading = heading != null && heading! >= 0;
|
||||
|
||||
// Calculate rotation angle (convert heading to radians)
|
||||
final rotationAngle = hasValidHeading ? (heading! * 3.14159 / 180.0) : 0.0;
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Outer accuracy circle (very subtle, uses theme color)
|
||||
Container(
|
||||
width: size * 0.6,
|
||||
height: size * 0.6,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
// Inner rotatable arrow pointer (much larger - 90% of size)
|
||||
Transform.rotate(
|
||||
angle: rotationAngle,
|
||||
child: CustomPaint(
|
||||
size: Size(size * 0.9, size * 0.9),
|
||||
painter: _NavigationPointerPainter(
|
||||
color: color,
|
||||
hasValidHeading: hasValidHeading,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom painter that draws a navigation arrow pointer
|
||||
class _NavigationPointerPainter extends CustomPainter {
|
||||
final Color color;
|
||||
final bool hasValidHeading;
|
||||
|
||||
_NavigationPointerPainter({
|
||||
required this.color,
|
||||
required this.hasValidHeading,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final width = size.width;
|
||||
final height = size.height;
|
||||
|
||||
if (hasValidHeading) {
|
||||
// Create navigation arrow with V-shaped cutout at bottom
|
||||
final arrowPath = Path();
|
||||
|
||||
// Top point (sharp tip)
|
||||
arrowPath.moveTo(center.dx, height * 0.08);
|
||||
|
||||
// Right side down to bottom right
|
||||
arrowPath.lineTo(center.dx + width * 0.42, height * 0.92);
|
||||
|
||||
// V-cutout at bottom - right side to center
|
||||
arrowPath.lineTo(center.dx, height * 0.70);
|
||||
|
||||
// V-cutout - center to left side
|
||||
arrowPath.lineTo(center.dx - width * 0.42, height * 0.92);
|
||||
|
||||
// Left side back up to top
|
||||
arrowPath.lineTo(center.dx, height * 0.08);
|
||||
|
||||
arrowPath.close();
|
||||
|
||||
// Draw shadow for depth
|
||||
final shadowPaint = Paint()
|
||||
..color = Colors.black.withValues(alpha: 0.25)
|
||||
..style = PaintingStyle.fill
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(2, 2);
|
||||
canvas.drawPath(arrowPath, shadowPaint);
|
||||
canvas.restore();
|
||||
|
||||
// Left side (lighter - 70% opacity of theme color)
|
||||
final leftSidePath = Path();
|
||||
leftSidePath.moveTo(center.dx, height * 0.08);
|
||||
leftSidePath.lineTo(center.dx - width * 0.42, height * 0.92);
|
||||
leftSidePath.lineTo(center.dx, height * 0.70);
|
||||
leftSidePath.close();
|
||||
|
||||
final leftPaint = Paint()
|
||||
..color = color.withValues(alpha: 0.7)
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawPath(leftSidePath, leftPaint);
|
||||
|
||||
// Right side (darker - full theme color)
|
||||
final rightSidePath = Path();
|
||||
rightSidePath.moveTo(center.dx, height * 0.08);
|
||||
rightSidePath.lineTo(center.dx, height * 0.70);
|
||||
rightSidePath.lineTo(center.dx + width * 0.42, height * 0.92);
|
||||
rightSidePath.close();
|
||||
|
||||
final rightPaint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawPath(rightSidePath, rightPaint);
|
||||
|
||||
// Optional: Draw white border for contrast
|
||||
final borderPaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.0
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
canvas.drawPath(arrowPath, borderPaint);
|
||||
|
||||
} else {
|
||||
// No heading available - draw a circle with white border (uses theme color)
|
||||
final circlePaint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
canvas.drawCircle(center, width * 0.4, circlePaint);
|
||||
|
||||
// White border
|
||||
final borderPaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.5;
|
||||
canvas.drawCircle(center, width * 0.4, borderPaint);
|
||||
|
||||
// Center white dot
|
||||
final centerDot = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawCircle(center, width * 0.15, centerDot);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_NavigationPointerPainter oldDelegate) {
|
||||
return oldDelegate.color != color ||
|
||||
oldDelegate.hasValidHeading != hasValidHeading;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user