diff --git a/lib/features/social/feed/social_feed_screen.dart b/lib/features/social/feed/social_feed_screen.dart index d36d801..16b3e27 100644 --- a/lib/features/social/feed/social_feed_screen.dart +++ b/lib/features/social/feed/social_feed_screen.dart @@ -1502,6 +1502,7 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> } Widget _buildRouteTrackModule(BuildContext context, SocialFeedItem item) { + final hasValidTrack = item.hasRecordedRoute; return Container( height: 160, decoration: BoxDecoration( @@ -1517,13 +1518,54 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> borderRadius: BorderRadius.circular(14), child: CustomPaint( painter: _FeedRoutePainter( - points: item.routePoints, + points: hasValidTrack ? item.routePoints : const [], 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 Positioned( bottom: 8, @@ -1560,8 +1602,8 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> ), ), - // GPS Verified Badge - if (item.isGpsVerified) ...[ + // GPS Verified Badge (strictly requires verified GPS trace criteria) + if (item.hasVerifiedGps) ...[ Positioned( top: 8, right: 10, @@ -1690,7 +1732,7 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> ], ), ), - if (item.isGpsVerified) + if (item.hasVerifiedGps) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4), @@ -1698,27 +1740,27 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> color: const Color(0xFF101419).withValues(alpha: 0.85), borderRadius: BorderRadius.circular(6), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon( - Icons.verified_rounded, - size: 13, - color: AppColors.electricCyan, - ), - SizedBox(width: 4), - Text( - 'GPS VERIFIED', - style: TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 10, - fontWeight: FontWeight.w800, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon( + Icons.verified_rounded, + size: 13, 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. +/// Plots recorded [points] normalized to canvas bounds. Does not fabricate +/// synthetic curves when fewer than 2 points exist. class _FeedRoutePainter extends CustomPainter { final List points; final double maxLean; @@ -1757,36 +1801,56 @@ class _FeedRoutePainter extends CustomPainter { canvas.drawLine(Offset(0, y), Offset(w, y), gridPaint); } - // 2. High-contrast curved canyon path - final path = Path(); - 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); + if (points.length < 2) { + return; + } - path.moveTo(p0.dx, p0.dy); - path.cubicTo( - w * 0.2, h * 0.75, - w * 0.25, h * 0.25, - p1.dx, p1.dy, - ); - path.cubicTo( - w * 0.42, h * 0.25, - w * 0.45, h * 0.7, - p2.dx, p2.dy, - ); - path.cubicTo( - w * 0.65, h * 0.7, - w * 0.72, h * 0.28, - p3.dx, p3.dy, - ); + // 2. Normalize recorded GPS points into canvas coordinates + double minLat = points.first.latitude; + double maxLat = points.first.latitude; + double minLng = points.first.longitude; + double maxLng = points.first.longitude; + + for (final pt in points) { + if (pt.latitude < minLat) minLat = pt.latitude; + if (pt.latitude > maxLat) maxLat = pt.latitude; + if (pt.longitude < minLng) minLng = pt.longitude; + if (pt.longitude > maxLng) maxLng = pt.longitude; + } + + const padX = 28.0; + 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 = []; + 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 final glowPaint = Paint() ..color = AppColors.electricCyan.withValues(alpha: 0.25) ..strokeWidth = 8 ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round; + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; canvas.drawPath(path, glowPaint); // Active trail @@ -1794,70 +1858,51 @@ class _FeedRoutePainter extends CustomPainter { ..color = const Color(0xFFC3F5FF) ..strokeWidth = 3.5 ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round; + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; canvas.drawPath(path, trailPaint); - // High G-Force Apex Segment Highlight (Amber Sector) - final amberPath = Path(); - amberPath.moveTo(p1.dx, p1.dy); - 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); + final startOffset = mappedPoints.first; + final finishOffset = mappedPoints.last; + final apexOffset = mappedPoints[mappedPoints.length ~/ 2]; - // Start marker with dashed ring + // Start marker with ring final startPaint = Paint()..color = AppColors.electricCyan; - canvas.drawCircle(p0, 4.5, startPaint); + canvas.drawCircle(startOffset, 4.5, startPaint); final ringPaint = Paint() ..color = AppColors.electricCyan.withValues(alpha: 0.6) ..style = PaintingStyle.stroke ..strokeWidth = 1.5; - canvas.drawCircle(p0, 8.0, ringPaint); + canvas.drawCircle(startOffset, 8.0, ringPaint); - // Apex markers with badges - final apexPaint = Paint()..color = AppColors.electricCyan; - canvas.drawCircle(p1, 4.0, apexPaint); + // Apex marker (only if maxLean > 0) + final textPainter = TextPainter(textDirection: TextDirection.ltr); + if (maxLean > 0) { + final apexPaint = Paint()..color = AppColors.electricCyan; + canvas.drawCircle(apexOffset, 4.0, apexPaint); - final amberDotPaint = Paint()..color = const Color(0xFFF59E0B); - canvas.drawCircle(p2, 4.0, amberDotPaint); + 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( + (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 final finishPaint = Paint()..color = const Color(0xFFBAC9CC); - canvas.drawCircle(p3, 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)); + canvas.drawCircle(finishOffset, 4.0, finishPaint); textPainter.text = const TextSpan( text: 'FINISH', @@ -1869,7 +1914,13 @@ class _FeedRoutePainter extends CustomPainter { ), ); 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 diff --git a/lib/features/social/feed/widgets/feed_telemetry_inspector_modal.dart b/lib/features/social/feed/widgets/feed_telemetry_inspector_modal.dart index 8b539b2..4447e9e 100644 --- a/lib/features/social/feed/widgets/feed_telemetry_inspector_modal.dart +++ b/lib/features/social/feed/widgets/feed_telemetry_inspector_modal.dart @@ -446,6 +446,7 @@ class _FeedTelemetryInspectorModalState SocialFeedItem item, MotorSignalThemeColors colors, ) { + final hasRecorded = item.hasRecordedTelemetry; return Container( height: 220, decoration: BoxDecoration( @@ -455,33 +456,90 @@ class _FeedTelemetryInspectorModalState ), child: ClipRRect( borderRadius: BorderRadius.circular(16), - child: GestureDetector( - onHorizontalDragUpdate: (details) { - final box = context.findRenderObject() as RenderBox?; - if (box != null) { - final localX = details.localPosition.dx; - setState(() { - _scrubFraction = (localX / 340).clamp(0.0, 1.0); - }); - } - }, - onTapDown: (details) { - final box = context.findRenderObject() as RenderBox?; - if (box != null) { - final localX = details.localPosition.dx; - setState(() { - _scrubFraction = (localX / 340).clamp(0.0, 1.0); - }); - } - }, - child: CustomPaint( - size: const Size(double.infinity, 220), - painter: _TelemetryWaveformPainter( - tab: _selectedTab, - item: item, - scrubFraction: _scrubFraction, + child: Stack( + fit: StackFit.expand, + children: [ + GestureDetector( + onHorizontalDragUpdate: hasRecorded + ? (details) { + final box = context.findRenderObject() as RenderBox?; + if (box != null) { + final localX = details.localPosition.dx; + setState(() { + _scrubFraction = (localX / 340).clamp(0.0, 1.0); + }); + } + } + : null, + onTapDown: hasRecorded + ? (details) { + final box = context.findRenderObject() as RenderBox?; + if (box != null) { + final localX = details.localPosition.dx; + setState(() { + _scrubFraction = (localX / 340).clamp(0.0, 1.0); + }); + } + } + : 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, MotorSignalThemeColors colors, ) { + final hasRecorded = item.hasRecordedTelemetry; final scrubDist = (item.distanceMiles * _scrubFraction).toStringAsFixed(1); 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); return Container( @@ -520,7 +579,9 @@ class _FeedTelemetryInspectorModalState const SizedBox(width: 6), Flexible( child: Text( - 'POINT: $scrubDist MI ($scrubPct%)', + hasRecorded + ? 'POINT: $scrubDist MI ($scrubPct%)' + : 'TELEMETRY TRACE UNAVAILABLE', overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: AppTypography.fontTelemetry, @@ -535,9 +596,9 @@ class _FeedTelemetryInspectorModalState ), ), const SizedBox(width: 8), - const Text( - 'DRAG TO SCRUB', - style: TextStyle( + Text( + hasRecorded ? 'DRAG TO SCRUB' : 'SUMMARY ONLY', + style: const TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: 8.5, fontWeight: FontWeight.w700, @@ -550,11 +611,11 @@ class _FeedTelemetryInspectorModalState Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _buildScrubStat('SPEED', '$speedVal MPH'), + _buildScrubStat('SPEED', hasRecorded ? '$speedVal MPH' : '-- MPH'), 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)), - _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) { - // Generate realistic wave curves based on session metrics - final baseSpeed = 48.0; - final speedVariation = 26.0 * math.sin(frac * math.pi * 5); - final speed = (baseSpeed + speedVariation).clamp(20, 115).toInt(); + if (!item.hasRecordedTelemetry || item.telemetrySamples.isEmpty) { + return (0, 0, '0.00'); + } - final leanVariation = item.maxLeanAngleDeg * math.sin(frac * math.pi * 7).abs(); - final lean = leanVariation.clamp(0, item.maxLeanAngleDeg).toInt(); + final samples = item.telemetrySamples; + 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) { @@ -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 { final _InspectorTraceTab tab; final SocialFeedItem item; @@ -671,6 +744,10 @@ class _TelemetryWaveformPainter extends CustomPainter { canvas.drawLine(Offset(x, 0), Offset(x, h), gridPaint); } + if (!item.hasRecordedTelemetry || item.telemetrySamples.length < 2) { + return; + } + switch (tab) { case _InspectorTraceTab.leanApex: _paintLeanWaveform(canvas, size); @@ -701,6 +778,7 @@ class _TelemetryWaveformPainter extends CustomPainter { final w = size.width; final h = size.height; final midY = h * 0.52; + final samples = item.telemetrySamples; // Baseline (0° upright) final baselinePaint = Paint() @@ -710,24 +788,27 @@ class _TelemetryWaveformPainter extends CustomPainter { canvas.drawLine(Offset(0, midY), Offset(w, midY), baselinePaint); final path = Path(); - final points = []; - const steps = 100; - final maxLean = item.maxLeanAngleDeg; + var peakIndex = 0; + var peakAbsLean = 0.0; + var peakPoint = Offset(0, midY); - for (var i = 0; i <= steps; i++) { - final t = i / steps; + for (var i = 0; i < samples.length; i++) { + final s = samples[i]; + final t = samples.length == 1 ? 0.0 : i / (samples.length - 1); final x = t * w; - // Multi-sine wave generating cornering sections - final wave = math.sin(t * math.pi * 6) * 0.7 + - math.sin(t * math.pi * 12) * 0.3; - final leanAngle = wave * maxLean; - final y = midY - (leanAngle / 60.0) * (h * 0.38); - points.add(Offset(x, y)); + final leanAngle = s.leanAngleDeg.clamp(-65.0, 65.0); + final y = midY - (leanAngle / 65.0) * (h * 0.38); + final pt = Offset(x, y); if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } + if (s.leanAngleDeg.abs() >= peakAbsLean) { + peakAbsLean = s.leanAngleDeg.abs(); + peakIndex = i; + peakPoint = pt; + } } // Draw Gradient Area under Lean Curve @@ -754,49 +835,59 @@ class _TelemetryWaveformPainter extends CustomPainter { ..style = PaintingStyle.stroke; canvas.drawPath(path, linePaint); - // Draw Apex Markers (Peak Corners) - final apexMarkers = [ - (0.26, '${item.maxLeanAngleDeg.toStringAsFixed(0)}° APEX', const Color(0xFF00E676)), - (0.68, '${(item.maxLeanAngleDeg * 0.94).toStringAsFixed(0)}° SWEEP', const Color(0xFFFFB300)), - ]; - - 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); + if (peakIndex >= 0 && peakAbsLean > 0) { + const markerColor = Color(0xFF00E676); + final dotPaint = Paint()..color = markerColor; + canvas.drawCircle(peakPoint, 5.0, dotPaint); final ringPaint = Paint() - ..color = marker.$3.withValues(alpha: 0.4) + ..color = markerColor.withValues(alpha: 0.4) ..style = PaintingStyle.stroke ..strokeWidth = 3; - canvas.drawCircle(Offset(mx, my), 8.0, ringPaint); + canvas.drawCircle(peakPoint, 8.0, ringPaint); - // Label Pill - _drawTextBadge(canvas, marker.$2, Offset(mx - 24, my - 24), marker.$3); + _drawTextBadge( + 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) { final w = size.width; 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(); - const steps = 100; + var peakSpeed = 0.0; + var peakPoint = Offset(w * 0.5, h * 0.5); - for (var i = 0; i <= steps; i++) { - final t = i / steps; + for (var i = 0; i < samples.length; i++) { + final s = samples[i]; + final t = samples.length == 1 ? 0.0 : i / (samples.length - 1); 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 - (speed / 120.0) * (h * 0.75); + final y = (h * 0.90 - (s.speedMph / speedScale) * (h * 0.75)) + .clamp(h * 0.1, h * 0.90); if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } + if (s.speedMph >= peakSpeed) { + peakSpeed = s.speedMph; + peakPoint = Offset(x, y); + } } final fillPath = Path.from(path) @@ -821,26 +912,49 @@ class _TelemetryWaveformPainter extends CustomPainter { ..style = PaintingStyle.stroke; 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) { final w = size.width; 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(); - const steps = 100; + var crestPoint = Offset(w * 0.5, h * 0.25); + var highestAlt = minAlt; - for (var i = 0; i <= steps; i++) { - final t = i / steps; + for (var i = 0; i < samples.length; i++) { + final s = samples[i]; + final t = samples.length == 1 ? 0.0 : i / (samples.length - 1); final x = t * w; - final elevationNorm = 0.2 + 0.65 * (math.sin(t * math.pi * 2.2) * 0.5 + 0.5); - final y = h * 0.92 - elevationNorm * (h * 0.75); + final norm = altSpan < 1.0 ? 0.5 : (s.altitudeFt - minAlt) / altSpan; + final y = h * 0.92 - (0.15 + norm * 0.70) * (h * 0.75); if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } + if (s.altitudeFt >= highestAlt) { + highestAlt = s.altitudeFt; + crestPoint = Offset(x, y); + } } final fillPath = Path.from(path) @@ -868,7 +982,10 @@ class _TelemetryWaveformPainter extends CustomPainter { _drawTextBadge( canvas, '+${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), ); } diff --git a/lib/services/social/social_feed_models.dart b/lib/services/social/social_feed_models.dart index ddb0911..370fd37 100644 --- a/lib/services/social/social_feed_models.dart +++ b/lib/services/social/social_feed_models.dart @@ -279,13 +279,13 @@ class SocialFeedItem { 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 hasDistinctPoint = validPoints.skip(1).any( (p) => (p.latitude - first.latitude).abs() + (p.longitude - first.longitude).abs() > - 0.00001, + 0.0005, ); return hasDistinctPoint; } @@ -301,15 +301,16 @@ class SocialFeedItem { 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 => + !isDemo && routePoints - .where((p) => - p.latitude.isFinite && - p.longitude.isFinite && - (p.latitude != 0.0 || p.longitude != 0.0)) - .length >= - 2; + .where((p) => + p.latitude.isFinite && + p.longitude.isFinite && + (p.latitude != 0.0 || p.longitude != 0.0)) + .length >= + 2; /// True when the post contains recorded telemetry waveform samples. bool get hasRecordedTelemetry => !isDemo && telemetrySamples.length >= 2; diff --git a/test/community_social_feed_test.dart b/test/community_social_feed_test.dart index 0b1fec0..a5f3b90 100644 --- a/test/community_social_feed_test.dart +++ b/test/community_social_feed_test.dart @@ -5,6 +5,7 @@ import 'package:motorcycle_dash/core/theme/theme_extensions.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/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/social_hub_screen.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': >[], + }); + 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); + }, + ); + }); }