fix(social): use recorded telemetry for feed charts and verify GPS badge (#51)
This commit is contained in:
parent
07bd567cdb
commit
265ee83e03
|
|
@ -1502,6 +1502,7 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRouteTrackModule(BuildContext context, SocialFeedItem item) {
|
Widget _buildRouteTrackModule(BuildContext context, SocialFeedItem item) {
|
||||||
|
final hasValidTrack = item.hasRecordedRoute;
|
||||||
return Container(
|
return Container(
|
||||||
height: 160,
|
height: 160,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -1517,13 +1518,54 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: _FeedRoutePainter(
|
painter: _FeedRoutePainter(
|
||||||
points: item.routePoints,
|
points: hasValidTrack ? item.routePoints : const [],
|
||||||
maxLean: item.maxLeanAngleDeg,
|
maxLean: item.maxLeanAngleDeg,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
if (!hasValidTrack)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
key: ValueKey('feed_route_no_gps_state_${item.id}'),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF101419).withValues(alpha: 0.88),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0xFF2A323D),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: const [
|
||||||
|
Icon(
|
||||||
|
Icons.gps_off_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: Color(0xFF94A3B8),
|
||||||
|
),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'NO GPS TRACK RECORDED',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: Color(0xFFCBD5E1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Elevation Pill Overlay
|
// Elevation Pill Overlay
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
|
|
@ -1560,8 +1602,8 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// GPS Verified Badge
|
// GPS Verified Badge (strictly requires verified GPS trace criteria)
|
||||||
if (item.isGpsVerified) ...[
|
if (item.hasVerifiedGps) ...[
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 10,
|
right: 10,
|
||||||
|
|
@ -1690,7 +1732,7 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (item.isGpsVerified)
|
if (item.hasVerifiedGps)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 8, vertical: 4),
|
horizontal: 8, vertical: 4),
|
||||||
|
|
@ -1698,27 +1740,27 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
|
||||||
color: const Color(0xFF101419).withValues(alpha: 0.85),
|
color: const Color(0xFF101419).withValues(alpha: 0.85),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: const [
|
children: const [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.verified_rounded,
|
Icons.verified_rounded,
|
||||||
size: 13,
|
size: 13,
|
||||||
color: AppColors.electricCyan,
|
|
||||||
),
|
|
||||||
SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'GPS VERIFIED',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: AppColors.electricCyan,
|
color: AppColors.electricCyan,
|
||||||
),
|
),
|
||||||
),
|
SizedBox(width: 4),
|
||||||
],
|
Text(
|
||||||
|
'GPS VERIFIED',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.electricCyan,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1730,6 +1772,8 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Custom vector route painter for the community social feed route preview.
|
/// Custom vector route painter for the community social feed route preview.
|
||||||
|
/// Plots recorded [points] normalized to canvas bounds. Does not fabricate
|
||||||
|
/// synthetic curves when fewer than 2 points exist.
|
||||||
class _FeedRoutePainter extends CustomPainter {
|
class _FeedRoutePainter extends CustomPainter {
|
||||||
final List<AppLatLng> points;
|
final List<AppLatLng> points;
|
||||||
final double maxLean;
|
final double maxLean;
|
||||||
|
|
@ -1757,36 +1801,56 @@ class _FeedRoutePainter extends CustomPainter {
|
||||||
canvas.drawLine(Offset(0, y), Offset(w, y), gridPaint);
|
canvas.drawLine(Offset(0, y), Offset(w, y), gridPaint);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. High-contrast curved canyon path
|
if (points.length < 2) {
|
||||||
final path = Path();
|
return;
|
||||||
final p0 = Offset(w * 0.1, h * 0.75);
|
}
|
||||||
final p1 = Offset(w * 0.35, h * 0.25);
|
|
||||||
final p2 = Offset(w * 0.55, h * 0.7);
|
|
||||||
final p3 = Offset(w * 0.88, h * 0.3);
|
|
||||||
|
|
||||||
path.moveTo(p0.dx, p0.dy);
|
// 2. Normalize recorded GPS points into canvas coordinates
|
||||||
path.cubicTo(
|
double minLat = points.first.latitude;
|
||||||
w * 0.2, h * 0.75,
|
double maxLat = points.first.latitude;
|
||||||
w * 0.25, h * 0.25,
|
double minLng = points.first.longitude;
|
||||||
p1.dx, p1.dy,
|
double maxLng = points.first.longitude;
|
||||||
);
|
|
||||||
path.cubicTo(
|
for (final pt in points) {
|
||||||
w * 0.42, h * 0.25,
|
if (pt.latitude < minLat) minLat = pt.latitude;
|
||||||
w * 0.45, h * 0.7,
|
if (pt.latitude > maxLat) maxLat = pt.latitude;
|
||||||
p2.dx, p2.dy,
|
if (pt.longitude < minLng) minLng = pt.longitude;
|
||||||
);
|
if (pt.longitude > maxLng) maxLng = pt.longitude;
|
||||||
path.cubicTo(
|
}
|
||||||
w * 0.65, h * 0.7,
|
|
||||||
w * 0.72, h * 0.28,
|
const padX = 28.0;
|
||||||
p3.dx, p3.dy,
|
const padY = 26.0;
|
||||||
);
|
final usableW = (w - padX * 2).clamp(1.0, w);
|
||||||
|
final usableH = (h - padY * 2).clamp(1.0, h);
|
||||||
|
final latSpan = (maxLat - minLat).abs();
|
||||||
|
final lngSpan = (maxLng - minLng).abs();
|
||||||
|
|
||||||
|
final mappedPoints = <Offset>[];
|
||||||
|
for (var i = 0; i < points.length; i++) {
|
||||||
|
final pt = points[i];
|
||||||
|
final normX = lngSpan < 1e-6
|
||||||
|
? (i / (points.length - 1))
|
||||||
|
: (pt.longitude - minLng) / lngSpan;
|
||||||
|
final normY = latSpan < 1e-6
|
||||||
|
? 0.5
|
||||||
|
: 1.0 - ((pt.latitude - minLat) / latSpan);
|
||||||
|
mappedPoints.add(
|
||||||
|
Offset(padX + normX * usableW, padY + normY * usableH),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final path = Path()..moveTo(mappedPoints.first.dx, mappedPoints.first.dy);
|
||||||
|
for (var i = 1; i < mappedPoints.length; i++) {
|
||||||
|
path.lineTo(mappedPoints[i].dx, mappedPoints[i].dy);
|
||||||
|
}
|
||||||
|
|
||||||
// Cyan glow blur
|
// Cyan glow blur
|
||||||
final glowPaint = Paint()
|
final glowPaint = Paint()
|
||||||
..color = AppColors.electricCyan.withValues(alpha: 0.25)
|
..color = AppColors.electricCyan.withValues(alpha: 0.25)
|
||||||
..strokeWidth = 8
|
..strokeWidth = 8
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round
|
||||||
|
..strokeJoin = StrokeJoin.round;
|
||||||
canvas.drawPath(path, glowPaint);
|
canvas.drawPath(path, glowPaint);
|
||||||
|
|
||||||
// Active trail
|
// Active trail
|
||||||
|
|
@ -1794,70 +1858,51 @@ class _FeedRoutePainter extends CustomPainter {
|
||||||
..color = const Color(0xFFC3F5FF)
|
..color = const Color(0xFFC3F5FF)
|
||||||
..strokeWidth = 3.5
|
..strokeWidth = 3.5
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round
|
||||||
|
..strokeJoin = StrokeJoin.round;
|
||||||
canvas.drawPath(path, trailPaint);
|
canvas.drawPath(path, trailPaint);
|
||||||
|
|
||||||
// High G-Force Apex Segment Highlight (Amber Sector)
|
final startOffset = mappedPoints.first;
|
||||||
final amberPath = Path();
|
final finishOffset = mappedPoints.last;
|
||||||
amberPath.moveTo(p1.dx, p1.dy);
|
final apexOffset = mappedPoints[mappedPoints.length ~/ 2];
|
||||||
amberPath.cubicTo(
|
|
||||||
w * 0.42, h * 0.25,
|
|
||||||
w * 0.45, h * 0.7,
|
|
||||||
p2.dx, p2.dy,
|
|
||||||
);
|
|
||||||
final amberPaint = Paint()
|
|
||||||
..color = const Color(0xFFF59E0B)
|
|
||||||
..strokeWidth = 4.5
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeCap = StrokeCap.round;
|
|
||||||
canvas.drawPath(amberPath, amberPaint);
|
|
||||||
|
|
||||||
// Start marker with dashed ring
|
// Start marker with ring
|
||||||
final startPaint = Paint()..color = AppColors.electricCyan;
|
final startPaint = Paint()..color = AppColors.electricCyan;
|
||||||
canvas.drawCircle(p0, 4.5, startPaint);
|
canvas.drawCircle(startOffset, 4.5, startPaint);
|
||||||
final ringPaint = Paint()
|
final ringPaint = Paint()
|
||||||
..color = AppColors.electricCyan.withValues(alpha: 0.6)
|
..color = AppColors.electricCyan.withValues(alpha: 0.6)
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 1.5;
|
..strokeWidth = 1.5;
|
||||||
canvas.drawCircle(p0, 8.0, ringPaint);
|
canvas.drawCircle(startOffset, 8.0, ringPaint);
|
||||||
|
|
||||||
// Apex markers with badges
|
// Apex marker (only if maxLean > 0)
|
||||||
final apexPaint = Paint()..color = AppColors.electricCyan;
|
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
||||||
canvas.drawCircle(p1, 4.0, apexPaint);
|
if (maxLean > 0) {
|
||||||
|
final apexPaint = Paint()..color = AppColors.electricCyan;
|
||||||
|
canvas.drawCircle(apexOffset, 4.0, apexPaint);
|
||||||
|
|
||||||
final amberDotPaint = Paint()..color = const Color(0xFFF59E0B);
|
textPainter.text = TextSpan(
|
||||||
canvas.drawCircle(p2, 4.0, amberDotPaint);
|
text: '${maxLean.toStringAsFixed(0)}° APEX',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Color(0xFFC3F5FF),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
textPainter.layout();
|
||||||
|
textPainter.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(
|
||||||
|
(apexOffset.dx - textPainter.width / 2).clamp(4.0, w - textPainter.width - 4.0),
|
||||||
|
(apexOffset.dy - 16).clamp(4.0, h - textPainter.height - 4.0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Finish marker
|
// Finish marker
|
||||||
final finishPaint = Paint()..color = const Color(0xFFBAC9CC);
|
final finishPaint = Paint()..color = const Color(0xFFBAC9CC);
|
||||||
canvas.drawCircle(p3, 4.0, finishPaint);
|
canvas.drawCircle(finishOffset, 4.0, finishPaint);
|
||||||
|
|
||||||
// Text labels for APEX and FINISH
|
|
||||||
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
|
||||||
|
|
||||||
textPainter.text = TextSpan(
|
|
||||||
text: '${maxLean.toStringAsFixed(0)}° APEX',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: Color(0xFFC3F5FF),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
textPainter.layout();
|
|
||||||
textPainter.paint(canvas, Offset(p1.dx - textPainter.width / 2, p1.dy - 16));
|
|
||||||
|
|
||||||
textPainter.text = const TextSpan(
|
|
||||||
text: '48° SWEEP',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: Color(0xFFF59E0B),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
textPainter.layout();
|
|
||||||
textPainter.paint(canvas, Offset(p2.dx - textPainter.width / 2, p2.dy + 8));
|
|
||||||
|
|
||||||
textPainter.text = const TextSpan(
|
textPainter.text = const TextSpan(
|
||||||
text: 'FINISH',
|
text: 'FINISH',
|
||||||
|
|
@ -1869,7 +1914,13 @@ class _FeedRoutePainter extends CustomPainter {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
textPainter.layout();
|
textPainter.layout();
|
||||||
textPainter.paint(canvas, Offset(p3.dx - textPainter.width / 2, p3.dy + 8));
|
textPainter.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(
|
||||||
|
(finishOffset.dx - textPainter.width / 2).clamp(4.0, w - textPainter.width - 4.0),
|
||||||
|
(finishOffset.dy + 8).clamp(4.0, h - textPainter.height - 4.0),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -446,6 +446,7 @@ class _FeedTelemetryInspectorModalState
|
||||||
SocialFeedItem item,
|
SocialFeedItem item,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
) {
|
) {
|
||||||
|
final hasRecorded = item.hasRecordedTelemetry;
|
||||||
return Container(
|
return Container(
|
||||||
height: 220,
|
height: 220,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -455,33 +456,90 @@ class _FeedTelemetryInspectorModalState
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: GestureDetector(
|
child: Stack(
|
||||||
onHorizontalDragUpdate: (details) {
|
fit: StackFit.expand,
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
children: [
|
||||||
if (box != null) {
|
GestureDetector(
|
||||||
final localX = details.localPosition.dx;
|
onHorizontalDragUpdate: hasRecorded
|
||||||
setState(() {
|
? (details) {
|
||||||
_scrubFraction = (localX / 340).clamp(0.0, 1.0);
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
});
|
if (box != null) {
|
||||||
}
|
final localX = details.localPosition.dx;
|
||||||
},
|
setState(() {
|
||||||
onTapDown: (details) {
|
_scrubFraction = (localX / 340).clamp(0.0, 1.0);
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
});
|
||||||
if (box != null) {
|
}
|
||||||
final localX = details.localPosition.dx;
|
}
|
||||||
setState(() {
|
: null,
|
||||||
_scrubFraction = (localX / 340).clamp(0.0, 1.0);
|
onTapDown: hasRecorded
|
||||||
});
|
? (details) {
|
||||||
}
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
},
|
if (box != null) {
|
||||||
child: CustomPaint(
|
final localX = details.localPosition.dx;
|
||||||
size: const Size(double.infinity, 220),
|
setState(() {
|
||||||
painter: _TelemetryWaveformPainter(
|
_scrubFraction = (localX / 340).clamp(0.0, 1.0);
|
||||||
tab: _selectedTab,
|
});
|
||||||
item: item,
|
}
|
||||||
scrubFraction: _scrubFraction,
|
}
|
||||||
|
: null,
|
||||||
|
child: CustomPaint(
|
||||||
|
size: const Size(double.infinity, 220),
|
||||||
|
painter: _TelemetryWaveformPainter(
|
||||||
|
tab: _selectedTab,
|
||||||
|
item: item,
|
||||||
|
scrubFraction: _scrubFraction,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (!hasRecorded)
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
key: const ValueKey('inspector_no_telemetry_state'),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF141A22).withValues(alpha: 0.92),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFF2A3647)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: const [
|
||||||
|
Icon(
|
||||||
|
Icons.sensors_off_rounded,
|
||||||
|
size: 24,
|
||||||
|
color: Color(0xFF8B98A5),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'NO RECORDED TELEMETRY SAMPLES',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Detailed multi-trace waveforms require recorded ride telemetry points.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontBody,
|
||||||
|
fontSize: 11,
|
||||||
|
color: Color(0xFF8B98A5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -491,10 +549,11 @@ class _FeedTelemetryInspectorModalState
|
||||||
SocialFeedItem item,
|
SocialFeedItem item,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
) {
|
) {
|
||||||
|
final hasRecorded = item.hasRecordedTelemetry;
|
||||||
final scrubDist = (item.distanceMiles * _scrubFraction).toStringAsFixed(1);
|
final scrubDist = (item.distanceMiles * _scrubFraction).toStringAsFixed(1);
|
||||||
final scrubPct = (_scrubFraction * 100).toInt();
|
final scrubPct = (_scrubFraction * 100).toInt();
|
||||||
|
|
||||||
// Instantaneous interpolated readings based on scrub point
|
// Instantaneous interpolated readings from recorded telemetry samples
|
||||||
final (speedVal, leanVal, gVal) = _calculateScrubValues(item, _scrubFraction);
|
final (speedVal, leanVal, gVal) = _calculateScrubValues(item, _scrubFraction);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
|
@ -520,7 +579,9 @@ class _FeedTelemetryInspectorModalState
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
'POINT: $scrubDist MI ($scrubPct%)',
|
hasRecorded
|
||||||
|
? 'POINT: $scrubDist MI ($scrubPct%)'
|
||||||
|
: 'TELEMETRY TRACE UNAVAILABLE',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
|
|
@ -535,9 +596,9 @@ class _FeedTelemetryInspectorModalState
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text(
|
Text(
|
||||||
'DRAG TO SCRUB',
|
hasRecorded ? 'DRAG TO SCRUB' : 'SUMMARY ONLY',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
fontSize: 8.5,
|
fontSize: 8.5,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|
@ -550,11 +611,11 @@ class _FeedTelemetryInspectorModalState
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
_buildScrubStat('SPEED', '$speedVal MPH'),
|
_buildScrubStat('SPEED', hasRecorded ? '$speedVal MPH' : '-- MPH'),
|
||||||
Container(width: 1, height: 24, color: const Color(0xFF222B38)),
|
Container(width: 1, height: 24, color: const Color(0xFF222B38)),
|
||||||
_buildScrubStat('LEAN', '$leanVal°'),
|
_buildScrubStat('LEAN', hasRecorded ? '$leanVal°' : '--°'),
|
||||||
Container(width: 1, height: 24, color: const Color(0xFF222B38)),
|
Container(width: 1, height: 24, color: const Color(0xFF222B38)),
|
||||||
_buildScrubStat('LAT G', '${gVal}G'),
|
_buildScrubStat('LAT G', hasRecorded ? '${gVal}G' : '--G'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -563,17 +624,28 @@ class _FeedTelemetryInspectorModalState
|
||||||
}
|
}
|
||||||
|
|
||||||
(int, int, String) _calculateScrubValues(SocialFeedItem item, double frac) {
|
(int, int, String) _calculateScrubValues(SocialFeedItem item, double frac) {
|
||||||
// Generate realistic wave curves based on session metrics
|
if (!item.hasRecordedTelemetry || item.telemetrySamples.isEmpty) {
|
||||||
final baseSpeed = 48.0;
|
return (0, 0, '0.00');
|
||||||
final speedVariation = 26.0 * math.sin(frac * math.pi * 5);
|
}
|
||||||
final speed = (baseSpeed + speedVariation).clamp(20, 115).toInt();
|
|
||||||
|
|
||||||
final leanVariation = item.maxLeanAngleDeg * math.sin(frac * math.pi * 7).abs();
|
final samples = item.telemetrySamples;
|
||||||
final lean = leanVariation.clamp(0, item.maxLeanAngleDeg).toInt();
|
final clamped = frac.clamp(0.0, 1.0);
|
||||||
|
final exactIdx = clamped * (samples.length - 1);
|
||||||
|
final lowerIdx = exactIdx.floor().clamp(0, samples.length - 1);
|
||||||
|
final upperIdx = exactIdx.ceil().clamp(0, samples.length - 1);
|
||||||
|
final t = exactIdx - lowerIdx;
|
||||||
|
|
||||||
final gForce = (0.2 + (lean / 60.0) * 0.9).toStringAsFixed(2);
|
final a = samples[lowerIdx];
|
||||||
|
final b = samples[upperIdx];
|
||||||
|
|
||||||
return (speed, lean, gForce);
|
final speed = (a.speedMph + (b.speedMph - a.speedMph) * t).round();
|
||||||
|
final lean = (a.leanAngleDeg.abs() +
|
||||||
|
(b.leanAngleDeg.abs() - a.leanAngleDeg.abs()) * t)
|
||||||
|
.round();
|
||||||
|
final latG = (a.lateralG.abs() + (b.lateralG.abs() - a.lateralG.abs()) * t)
|
||||||
|
.toStringAsFixed(2);
|
||||||
|
|
||||||
|
return (speed, lean, latG);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildScrubStat(String label, String value) {
|
Widget _buildScrubStat(String label, String value) {
|
||||||
|
|
@ -640,7 +712,8 @@ class _FeedTelemetryInspectorModalState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Custom painter rendering multi-trace telemetry waveforms and apex markers.
|
/// Custom painter rendering recorded multi-trace telemetry waveforms and apex markers.
|
||||||
|
/// Never fabricates synthetic curves when recorded telemetry samples are absent.
|
||||||
class _TelemetryWaveformPainter extends CustomPainter {
|
class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
final _InspectorTraceTab tab;
|
final _InspectorTraceTab tab;
|
||||||
final SocialFeedItem item;
|
final SocialFeedItem item;
|
||||||
|
|
@ -671,6 +744,10 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
canvas.drawLine(Offset(x, 0), Offset(x, h), gridPaint);
|
canvas.drawLine(Offset(x, 0), Offset(x, h), gridPaint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!item.hasRecordedTelemetry || item.telemetrySamples.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case _InspectorTraceTab.leanApex:
|
case _InspectorTraceTab.leanApex:
|
||||||
_paintLeanWaveform(canvas, size);
|
_paintLeanWaveform(canvas, size);
|
||||||
|
|
@ -701,6 +778,7 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
final w = size.width;
|
final w = size.width;
|
||||||
final h = size.height;
|
final h = size.height;
|
||||||
final midY = h * 0.52;
|
final midY = h * 0.52;
|
||||||
|
final samples = item.telemetrySamples;
|
||||||
|
|
||||||
// Baseline (0° upright)
|
// Baseline (0° upright)
|
||||||
final baselinePaint = Paint()
|
final baselinePaint = Paint()
|
||||||
|
|
@ -710,24 +788,27 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
canvas.drawLine(Offset(0, midY), Offset(w, midY), baselinePaint);
|
canvas.drawLine(Offset(0, midY), Offset(w, midY), baselinePaint);
|
||||||
|
|
||||||
final path = Path();
|
final path = Path();
|
||||||
final points = <Offset>[];
|
var peakIndex = 0;
|
||||||
const steps = 100;
|
var peakAbsLean = 0.0;
|
||||||
final maxLean = item.maxLeanAngleDeg;
|
var peakPoint = Offset(0, midY);
|
||||||
|
|
||||||
for (var i = 0; i <= steps; i++) {
|
for (var i = 0; i < samples.length; i++) {
|
||||||
final t = i / steps;
|
final s = samples[i];
|
||||||
|
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
|
||||||
final x = t * w;
|
final x = t * w;
|
||||||
// Multi-sine wave generating cornering sections
|
final leanAngle = s.leanAngleDeg.clamp(-65.0, 65.0);
|
||||||
final wave = math.sin(t * math.pi * 6) * 0.7 +
|
final y = midY - (leanAngle / 65.0) * (h * 0.38);
|
||||||
math.sin(t * math.pi * 12) * 0.3;
|
final pt = Offset(x, y);
|
||||||
final leanAngle = wave * maxLean;
|
|
||||||
final y = midY - (leanAngle / 60.0) * (h * 0.38);
|
|
||||||
points.add(Offset(x, y));
|
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
path.moveTo(x, y);
|
path.moveTo(x, y);
|
||||||
} else {
|
} else {
|
||||||
path.lineTo(x, y);
|
path.lineTo(x, y);
|
||||||
}
|
}
|
||||||
|
if (s.leanAngleDeg.abs() >= peakAbsLean) {
|
||||||
|
peakAbsLean = s.leanAngleDeg.abs();
|
||||||
|
peakIndex = i;
|
||||||
|
peakPoint = pt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw Gradient Area under Lean Curve
|
// Draw Gradient Area under Lean Curve
|
||||||
|
|
@ -754,49 +835,59 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
..style = PaintingStyle.stroke;
|
..style = PaintingStyle.stroke;
|
||||||
canvas.drawPath(path, linePaint);
|
canvas.drawPath(path, linePaint);
|
||||||
|
|
||||||
// Draw Apex Markers (Peak Corners)
|
if (peakIndex >= 0 && peakAbsLean > 0) {
|
||||||
final apexMarkers = [
|
const markerColor = Color(0xFF00E676);
|
||||||
(0.26, '${item.maxLeanAngleDeg.toStringAsFixed(0)}° APEX', const Color(0xFF00E676)),
|
final dotPaint = Paint()..color = markerColor;
|
||||||
(0.68, '${(item.maxLeanAngleDeg * 0.94).toStringAsFixed(0)}° SWEEP', const Color(0xFFFFB300)),
|
canvas.drawCircle(peakPoint, 5.0, dotPaint);
|
||||||
];
|
|
||||||
|
|
||||||
for (final marker in apexMarkers) {
|
|
||||||
final mx = marker.$1 * w;
|
|
||||||
final wave = math.sin(marker.$1 * math.pi * 6) * 0.7 +
|
|
||||||
math.sin(marker.$1 * math.pi * 12) * 0.3;
|
|
||||||
final my = midY - (wave * maxLean / 60.0) * (h * 0.38);
|
|
||||||
|
|
||||||
final dotPaint = Paint()..color = marker.$3;
|
|
||||||
canvas.drawCircle(Offset(mx, my), 5.0, dotPaint);
|
|
||||||
|
|
||||||
final ringPaint = Paint()
|
final ringPaint = Paint()
|
||||||
..color = marker.$3.withValues(alpha: 0.4)
|
..color = markerColor.withValues(alpha: 0.4)
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 3;
|
..strokeWidth = 3;
|
||||||
canvas.drawCircle(Offset(mx, my), 8.0, ringPaint);
|
canvas.drawCircle(peakPoint, 8.0, ringPaint);
|
||||||
|
|
||||||
// Label Pill
|
_drawTextBadge(
|
||||||
_drawTextBadge(canvas, marker.$2, Offset(mx - 24, my - 24), marker.$3);
|
canvas,
|
||||||
|
'${peakAbsLean.toStringAsFixed(0)}° APEX',
|
||||||
|
Offset(
|
||||||
|
(peakPoint.dx - 24).clamp(6.0, w - 70.0),
|
||||||
|
(peakPoint.dy - 24).clamp(6.0, h - 24.0),
|
||||||
|
),
|
||||||
|
markerColor,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _paintSpeedWaveform(Canvas canvas, Size size) {
|
void _paintSpeedWaveform(Canvas canvas, Size size) {
|
||||||
final w = size.width;
|
final w = size.width;
|
||||||
final h = size.height;
|
final h = size.height;
|
||||||
|
final samples = item.telemetrySamples;
|
||||||
|
|
||||||
|
var maxSpeed = 1.0;
|
||||||
|
for (final s in samples) {
|
||||||
|
if (s.speedMph > maxSpeed) maxSpeed = s.speedMph;
|
||||||
|
}
|
||||||
|
final speedScale = math.max(maxSpeed * 1.15, 30.0);
|
||||||
|
|
||||||
final path = Path();
|
final path = Path();
|
||||||
const steps = 100;
|
var peakSpeed = 0.0;
|
||||||
|
var peakPoint = Offset(w * 0.5, h * 0.5);
|
||||||
|
|
||||||
for (var i = 0; i <= steps; i++) {
|
for (var i = 0; i < samples.length; i++) {
|
||||||
final t = i / steps;
|
final s = samples[i];
|
||||||
|
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
|
||||||
final x = t * w;
|
final x = t * w;
|
||||||
final speed = 40.0 + 35.0 * math.sin(t * math.pi * 4).abs() + 15.0 * math.cos(t * math.pi * 8);
|
final y = (h * 0.90 - (s.speedMph / speedScale) * (h * 0.75))
|
||||||
final y = h * 0.90 - (speed / 120.0) * (h * 0.75);
|
.clamp(h * 0.1, h * 0.90);
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
path.moveTo(x, y);
|
path.moveTo(x, y);
|
||||||
} else {
|
} else {
|
||||||
path.lineTo(x, y);
|
path.lineTo(x, y);
|
||||||
}
|
}
|
||||||
|
if (s.speedMph >= peakSpeed) {
|
||||||
|
peakSpeed = s.speedMph;
|
||||||
|
peakPoint = Offset(x, y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final fillPath = Path.from(path)
|
final fillPath = Path.from(path)
|
||||||
|
|
@ -821,26 +912,49 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
..style = PaintingStyle.stroke;
|
..style = PaintingStyle.stroke;
|
||||||
canvas.drawPath(path, linePaint);
|
canvas.drawPath(path, linePaint);
|
||||||
|
|
||||||
_drawTextBadge(canvas, 'TOP SPEED', Offset(w * 0.5 - 28, h * 0.16), const Color(0xFFFFB300));
|
_drawTextBadge(
|
||||||
|
canvas,
|
||||||
|
'${peakSpeed.round()} MPH TOP SPEED',
|
||||||
|
Offset(
|
||||||
|
(peakPoint.dx - 36).clamp(6.0, w - 100.0),
|
||||||
|
(peakPoint.dy - 22).clamp(6.0, h - 24.0),
|
||||||
|
),
|
||||||
|
const Color(0xFFFFB300),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _paintElevationWaveform(Canvas canvas, Size size) {
|
void _paintElevationWaveform(Canvas canvas, Size size) {
|
||||||
final w = size.width;
|
final w = size.width;
|
||||||
final h = size.height;
|
final h = size.height;
|
||||||
|
final samples = item.telemetrySamples;
|
||||||
|
|
||||||
|
var minAlt = samples.first.altitudeFt;
|
||||||
|
var maxAlt = samples.first.altitudeFt;
|
||||||
|
for (final s in samples) {
|
||||||
|
if (s.altitudeFt < minAlt) minAlt = s.altitudeFt;
|
||||||
|
if (s.altitudeFt > maxAlt) maxAlt = s.altitudeFt;
|
||||||
|
}
|
||||||
|
final altSpan = (maxAlt - minAlt).abs();
|
||||||
|
|
||||||
final path = Path();
|
final path = Path();
|
||||||
const steps = 100;
|
var crestPoint = Offset(w * 0.5, h * 0.25);
|
||||||
|
var highestAlt = minAlt;
|
||||||
|
|
||||||
for (var i = 0; i <= steps; i++) {
|
for (var i = 0; i < samples.length; i++) {
|
||||||
final t = i / steps;
|
final s = samples[i];
|
||||||
|
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
|
||||||
final x = t * w;
|
final x = t * w;
|
||||||
final elevationNorm = 0.2 + 0.65 * (math.sin(t * math.pi * 2.2) * 0.5 + 0.5);
|
final norm = altSpan < 1.0 ? 0.5 : (s.altitudeFt - minAlt) / altSpan;
|
||||||
final y = h * 0.92 - elevationNorm * (h * 0.75);
|
final y = h * 0.92 - (0.15 + norm * 0.70) * (h * 0.75);
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
path.moveTo(x, y);
|
path.moveTo(x, y);
|
||||||
} else {
|
} else {
|
||||||
path.lineTo(x, y);
|
path.lineTo(x, y);
|
||||||
}
|
}
|
||||||
|
if (s.altitudeFt >= highestAlt) {
|
||||||
|
highestAlt = s.altitudeFt;
|
||||||
|
crestPoint = Offset(x, y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final fillPath = Path.from(path)
|
final fillPath = Path.from(path)
|
||||||
|
|
@ -868,7 +982,10 @@ class _TelemetryWaveformPainter extends CustomPainter {
|
||||||
_drawTextBadge(
|
_drawTextBadge(
|
||||||
canvas,
|
canvas,
|
||||||
'+${item.elevationFt} FT CREST',
|
'+${item.elevationFt} FT CREST',
|
||||||
Offset(w * 0.68 - 36, h * 0.18),
|
Offset(
|
||||||
|
(crestPoint.dx - 36).clamp(6.0, w - 95.0),
|
||||||
|
(crestPoint.dy - 22).clamp(6.0, h - 24.0),
|
||||||
|
),
|
||||||
const Color(0xFF448AFF),
|
const Color(0xFF448AFF),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -279,13 +279,13 @@ class SocialFeedItem {
|
||||||
|
|
||||||
if (validPoints.length < 2) return false;
|
if (validPoints.length < 2) return false;
|
||||||
|
|
||||||
// Require at least two distinct GPS coordinates along the track.
|
// Require at least two distinct GPS coordinates with meaningful displacement (~50m+).
|
||||||
final first = validPoints.first;
|
final first = validPoints.first;
|
||||||
final hasDistinctPoint = validPoints.skip(1).any(
|
final hasDistinctPoint = validPoints.skip(1).any(
|
||||||
(p) =>
|
(p) =>
|
||||||
(p.latitude - first.latitude).abs() +
|
(p.latitude - first.latitude).abs() +
|
||||||
(p.longitude - first.longitude).abs() >
|
(p.longitude - first.longitude).abs() >
|
||||||
0.00001,
|
0.0005,
|
||||||
);
|
);
|
||||||
return hasDistinctPoint;
|
return hasDistinctPoint;
|
||||||
}
|
}
|
||||||
|
|
@ -301,15 +301,16 @@ class SocialFeedItem {
|
||||||
routePoints: routePoints,
|
routePoints: routePoints,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// True when the post contains recorded route coordinates that can be plotted.
|
/// True when the post is a non-demo ride containing recorded route coordinates that can be plotted.
|
||||||
bool get hasRecordedRoute =>
|
bool get hasRecordedRoute =>
|
||||||
|
!isDemo &&
|
||||||
routePoints
|
routePoints
|
||||||
.where((p) =>
|
.where((p) =>
|
||||||
p.latitude.isFinite &&
|
p.latitude.isFinite &&
|
||||||
p.longitude.isFinite &&
|
p.longitude.isFinite &&
|
||||||
(p.latitude != 0.0 || p.longitude != 0.0))
|
(p.latitude != 0.0 || p.longitude != 0.0))
|
||||||
.length >=
|
.length >=
|
||||||
2;
|
2;
|
||||||
|
|
||||||
/// True when the post contains recorded telemetry waveform samples.
|
/// True when the post contains recorded telemetry waveform samples.
|
||||||
bool get hasRecordedTelemetry => !isDemo && telemetrySamples.length >= 2;
|
bool get hasRecordedTelemetry => !isDemo && telemetrySamples.length >= 2;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
|
||||||
import 'package:motorcycle_dash/database/database.dart';
|
import 'package:motorcycle_dash/database/database.dart';
|
||||||
import 'package:motorcycle_dash/features/social/feed/social_feed_screen.dart';
|
import 'package:motorcycle_dash/features/social/feed/social_feed_screen.dart';
|
||||||
import 'package:motorcycle_dash/features/social/feed/widgets/feed_comments_bottom_sheet.dart';
|
import 'package:motorcycle_dash/features/social/feed/widgets/feed_comments_bottom_sheet.dart';
|
||||||
|
import 'package:motorcycle_dash/features/social/feed/widgets/feed_telemetry_inspector_modal.dart';
|
||||||
import 'package:motorcycle_dash/features/social/feed/widgets/share_session_modal.dart';
|
import 'package:motorcycle_dash/features/social/feed/widgets/share_session_modal.dart';
|
||||||
import 'package:motorcycle_dash/features/social/social_hub_screen.dart';
|
import 'package:motorcycle_dash/features/social/social_hub_screen.dart';
|
||||||
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
|
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
|
||||||
|
|
@ -537,4 +538,234 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('BUG-004: Published Ride Real Telemetry & GPS Verified Badge Tests', () {
|
||||||
|
test(
|
||||||
|
'SocialFeedItem.verifyGpsCriteria and hasVerifiedGps reject demo posts, forged JSON, and zero-displacement routes',
|
||||||
|
() {
|
||||||
|
// 1. Demo post with valid routePoints still must NOT be GPS verified
|
||||||
|
final demoPost = SocialFeedItem(
|
||||||
|
id: 'demo_1',
|
||||||
|
authorUid: 'uid_1',
|
||||||
|
authorDisplayName: 'Demo Rider',
|
||||||
|
authorHandle: 'demo',
|
||||||
|
locationName: 'Skyline',
|
||||||
|
timestamp: DateTime(2026, 10, 12),
|
||||||
|
rideTitle: 'Demo Ride',
|
||||||
|
bikeModel: 'R1',
|
||||||
|
distanceMiles: 40.0,
|
||||||
|
durationSeconds: 2400,
|
||||||
|
maxLeanAngleDeg: 45.0,
|
||||||
|
isDemo: true,
|
||||||
|
isGpsVerified: true,
|
||||||
|
routePoints: const [
|
||||||
|
AppLatLng(37.3861, -122.2641),
|
||||||
|
AppLatLng(37.2500, -122.1800),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(demoPost.hasVerifiedGps, isFalse);
|
||||||
|
|
||||||
|
// 2. Non-demo post with < 2 points or zero displacement (< 100m) must NOT be GPS verified
|
||||||
|
final stationaryPost = SocialFeedItem(
|
||||||
|
id: 'stat_1',
|
||||||
|
authorUid: 'uid_1',
|
||||||
|
authorDisplayName: 'Rider',
|
||||||
|
authorHandle: 'rider',
|
||||||
|
locationName: 'Garage',
|
||||||
|
timestamp: DateTime(2026, 10, 12),
|
||||||
|
rideTitle: 'Garage Idle',
|
||||||
|
bikeModel: 'R1',
|
||||||
|
distanceMiles: 5.0,
|
||||||
|
durationSeconds: 600,
|
||||||
|
maxLeanAngleDeg: 10.0,
|
||||||
|
isDemo: false,
|
||||||
|
isGpsVerified: true,
|
||||||
|
routePoints: const [
|
||||||
|
AppLatLng(37.38610, -122.26410),
|
||||||
|
AppLatLng(37.38611, -122.26411),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(stationaryPost.hasVerifiedGps, isFalse);
|
||||||
|
|
||||||
|
// 3. Untrusted Firestore JSON claiming isGpsVerified: true with empty routePoints is rejected
|
||||||
|
final forgedFromJson = SocialFeedItem.fromJson({
|
||||||
|
'id': 'forged_1',
|
||||||
|
'authorUid': 'uid_forger',
|
||||||
|
'authorDisplayName': 'Forger',
|
||||||
|
'authorHandle': 'forger',
|
||||||
|
'locationName': 'Nowhere',
|
||||||
|
'timestamp': '2026-10-12T10:00:00.000Z',
|
||||||
|
'rideTitle': 'Fake Verified Run',
|
||||||
|
'bikeModel': 'H2R',
|
||||||
|
'distanceMiles': 100.0,
|
||||||
|
'durationSeconds': 3600,
|
||||||
|
'maxLeanAngleDeg': 60.0,
|
||||||
|
'isDemo': false,
|
||||||
|
'isGpsVerified': true,
|
||||||
|
'routePoints': <Map<String, dynamic>>[],
|
||||||
|
});
|
||||||
|
expect(forgedFromJson.isGpsVerified, isFalse);
|
||||||
|
expect(forgedFromJson.hasVerifiedGps, isFalse);
|
||||||
|
|
||||||
|
// 4. Real non-demo ride with >= 2 distinct GPS coordinates (>= 100m), positive distance & duration is verified
|
||||||
|
final validPost = SocialFeedItem(
|
||||||
|
id: 'valid_1',
|
||||||
|
authorUid: 'uid_real',
|
||||||
|
authorDisplayName: 'Real Rider',
|
||||||
|
authorHandle: 'realrider',
|
||||||
|
locationName: 'Highway 9',
|
||||||
|
timestamp: DateTime(2026, 10, 12),
|
||||||
|
rideTitle: 'Real Canyon Run',
|
||||||
|
bikeModel: 'Street Triple RS',
|
||||||
|
distanceMiles: 24.5,
|
||||||
|
durationSeconds: 1800,
|
||||||
|
maxLeanAngleDeg: 44.0,
|
||||||
|
isDemo: false,
|
||||||
|
isGpsVerified: true,
|
||||||
|
routePoints: const [
|
||||||
|
AppLatLng(37.2500, -122.1200),
|
||||||
|
AppLatLng(37.2900, -122.1600),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(validPost.hasVerifiedGps, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Unverified/demo feed posts hide GPS VERIFIED badge and show explicit empty telemetry state in inspector',
|
||||||
|
(tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(400, 1000));
|
||||||
|
|
||||||
|
final service = SocialFeedService();
|
||||||
|
addTearDown(service.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_createTestApp(
|
||||||
|
overrides: [
|
||||||
|
socialFeedServiceProvider.overrideWithValue(service),
|
||||||
|
],
|
||||||
|
child: const SocialFeedScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
// Default feed items are demo items (isDemo: true), so GPS VERIFIED badge must NOT appear
|
||||||
|
expect(find.text('GPS VERIFIED'), findsNothing);
|
||||||
|
expect(find.text('NO GPS TRACK RECORDED'), findsWidgets);
|
||||||
|
|
||||||
|
// Open telemetry inspector on unverified/demo post
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -100));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('INSPECT TELEMETRY GRAPH').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Must show explicit no-telemetry-samples state instead of synthetic sine wave
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('inspector_no_telemetry_state')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text('NO RECORDED TELEMETRY SAMPLES'), findsOneWidget);
|
||||||
|
expect(find.text('TELEMETRY TRACE UNAVAILABLE'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Verified ride with recorded telemetrySamples renders GPS VERIFIED badge and real interpolated scrubber values',
|
||||||
|
(tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(400, 1000));
|
||||||
|
|
||||||
|
final verifiedItem = SocialFeedItem(
|
||||||
|
id: 'verified_post_99',
|
||||||
|
rideId: 99,
|
||||||
|
authorUid: 'uid_verified',
|
||||||
|
authorDisplayName: 'Elena Rostova',
|
||||||
|
authorHandle: 'ElenaApex',
|
||||||
|
locationName: 'Angeles Crest Hwy',
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
rideTitle: 'Verified Morning Telemetry Run',
|
||||||
|
bikeModel: '2024 Aprilia Tuono V4',
|
||||||
|
distanceMiles: 20.0,
|
||||||
|
durationSeconds: 1500,
|
||||||
|
maxLeanAngleDeg: 46.0,
|
||||||
|
maxLeanSide: 'LEFT',
|
||||||
|
elevationFt: 1800,
|
||||||
|
isDemo: false,
|
||||||
|
isGpsVerified: true,
|
||||||
|
routePoints: const [
|
||||||
|
AppLatLng(34.2000, -118.1500),
|
||||||
|
AppLatLng(34.2400, -118.1100),
|
||||||
|
AppLatLng(34.2800, -118.0700),
|
||||||
|
],
|
||||||
|
telemetrySamples: const [
|
||||||
|
FeedTelemetrySample(
|
||||||
|
progress: 0.0,
|
||||||
|
speedMph: 35.0,
|
||||||
|
leanAngleDeg: 10.0,
|
||||||
|
lateralG: 0.18,
|
||||||
|
altitudeFt: 1200.0,
|
||||||
|
latitude: 34.2000,
|
||||||
|
longitude: -118.1500,
|
||||||
|
),
|
||||||
|
FeedTelemetrySample(
|
||||||
|
progress: 0.5,
|
||||||
|
speedMph: 63.0,
|
||||||
|
leanAngleDeg: -46.0,
|
||||||
|
lateralG: -0.84,
|
||||||
|
altitudeFt: 2400.0,
|
||||||
|
latitude: 34.2400,
|
||||||
|
longitude: -118.1100,
|
||||||
|
),
|
||||||
|
FeedTelemetrySample(
|
||||||
|
progress: 1.0,
|
||||||
|
speedMph: 42.0,
|
||||||
|
leanAngleDeg: 15.0,
|
||||||
|
lateralG: 0.25,
|
||||||
|
altitudeFt: 3000.0,
|
||||||
|
latitude: 34.2800,
|
||||||
|
longitude: -118.0700,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_createTestApp(
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) => Column(
|
||||||
|
children: [
|
||||||
|
if (verifiedItem.hasVerifiedGps)
|
||||||
|
const Text('GPS VERIFIED'),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () =>
|
||||||
|
FeedTelemetryInspectorModal.show(context, verifiedItem),
|
||||||
|
child: const Text('Inspect Verified Ride'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(verifiedItem.hasVerifiedGps, isTrue);
|
||||||
|
expect(find.text('GPS VERIFIED'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Inspect Verified Ride'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// No empty-state overlay when real telemetry samples exist
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('inspector_no_telemetry_state')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
|
||||||
|
// At default _scrubFraction = 0.5, scrubber readout matches sample[1] (63 MPH, 46°, 0.84G)
|
||||||
|
expect(find.text('POINT: 10.0 MI (50%)'), findsOneWidget);
|
||||||
|
expect(find.text('63 MPH'), findsOneWidget);
|
||||||
|
expect(find.text('46°'), findsWidgets);
|
||||||
|
expect(find.text('0.84G'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue