Compare commits

...

5 Commits

Author SHA1 Message Date
Developer eb4cd74f02 fix(social): prevent horizontal overflow in GPX export snackbar (#52)
- Wrap GPX export snackbar message Text in Expanded with maxLines: 2 and TextOverflow.ellipsis

- Add ValueKey('gpx_export_button_<id>') for deterministic testing

- Wrap narrow feed header/metric/empty-state Row labels in Flexible to prevent overflow under 320px/360px at 1.5x text scale

- Add BUG-005 widget tests across 320x640, 360x800, and 800x480 viewports at 1.5x text scale
2026-10-16 23:23:12 -04:00
Developer 1820739a49 fix(social): standardize community feed typography and theme colors (#49) 2026-10-16 23:18:24 -04:00
Developer 265ee83e03 fix(social): use recorded telemetry for feed charts and verify GPS badge (#51) 2026-10-16 23:09:44 -04:00
Developer 07bd567cdb fix(social): make shared session telemetry read-only and authoritative (#50) 2026-10-16 23:01:28 -04:00
Developer 1d702102fc fix(dashboard): ensure Touring map follows rider and supports recenter (#48) 2026-10-16 22:51:18 -04:00
12 changed files with 2301 additions and 749 deletions

View File

@ -5,15 +5,18 @@ import '../../../core/theme/app_typography.dart';
import '../../../services/alerts/road_alert_models.dart'; import '../../../services/alerts/road_alert_models.dart';
import '../../../services/alerts/road_alert_service.dart'; import '../../../services/alerts/road_alert_service.dart';
import '../../../services/map/map_provider_interface.dart'; import '../../../services/map/map_provider_interface.dart';
import '../../../services/map/map_providers.dart';
import '../../../services/map/unified_map_view.dart'; import '../../../services/map/unified_map_view.dart';
import '../../../services/navigation/navigation_session_service.dart'; import '../../../services/navigation/navigation_session_service.dart';
import '../../../services/telemetry_service.dart'; import '../../../services/telemetry_service.dart';
import '../../rides/ride_state_machine.dart';
/// Embedded tactical map panel for Touring mode and responsive dashboard views. /// Embedded tactical map panel for Touring mode and responsive dashboard views.
/// ///
/// Subscribes to vehicle coordinates and heading via [telemetryStreamProvider] /// Subscribes to vehicle coordinates and heading via [telemetryStreamProvider]
/// and alert pins via [activeAlertsStreamProvider]. /// and alert pins via [activeAlertsStreamProvider], maintains camera follow
class DashboardMapPanel extends ConsumerWidget { /// state, and provides a recenter control after manual pan.
class DashboardMapPanel extends ConsumerStatefulWidget {
final double zoom; final double zoom;
const DashboardMapPanel({ const DashboardMapPanel({
@ -22,7 +25,43 @@ class DashboardMapPanel extends ConsumerWidget {
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<DashboardMapPanel> createState() => _DashboardMapPanelState();
}
class _DashboardMapPanelState extends ConsumerState<DashboardMapPanel> {
static const AppLatLng _defaultFallbackCenter = AppLatLng(37.7749, -122.4194);
AppLatLng? _lastKnownRiderPos;
@override
void initState() {
super.initState();
// Ensure follow mode is active whenever the Touring map panel is mounted.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ref.read(mapFollowStateProvider.notifier).recenter();
}
});
}
bool _isValidCoord(double? lat, double? lng) {
return lat != null &&
lng != null &&
lat.isFinite &&
lng.isFinite &&
(lat != 0.0 || lng != 0.0);
}
@override
Widget build(BuildContext context) {
// Re-enable camera follow automatically when a ride session starts.
ref.listen<RideSession>(rideStateMachineProvider, (previous, next) {
if (previous?.state != RideState.riding &&
next.state == RideState.riding &&
mounted) {
ref.read(mapFollowStateProvider.notifier).recenter();
}
});
final vehicleCoords = ref.watch( final vehicleCoords = ref.watch(
telemetryStreamProvider.select( telemetryStreamProvider.select(
(s) => ( (s) => (
@ -34,19 +73,18 @@ class DashboardMapPanel extends ConsumerWidget {
), ),
); );
final centerLat = (vehicleCoords.lat != null && vehicleCoords.lat != 0.0) final hasCoordinates = _isValidCoord(vehicleCoords.lat, vehicleCoords.lng);
? vehicleCoords.lat! if (hasCoordinates) {
: 37.7749; _lastKnownRiderPos = AppLatLng(vehicleCoords.lat!, vehicleCoords.lng!);
final centerLng = (vehicleCoords.lng != null && vehicleCoords.lng != 0.0) }
? vehicleCoords.lng!
: -122.4194;
final activeCenter = _lastKnownRiderPos ?? _defaultFallbackCenter;
final isFresh = vehicleCoords.isGpsFresh; final isFresh = vehicleCoords.isGpsFresh;
final hasCoordinates = vehicleCoords.lat != null && vehicleCoords.lat != 0.0; final isFollowMode = ref.watch(mapFollowStateProvider);
final vehicleMarker = AppMapMarker( final vehicleMarker = AppMapMarker(
id: 'dash_vehicle', id: 'dash_vehicle',
position: AppLatLng(centerLat, centerLng), position: activeCenter,
heading: vehicleCoords.heading, heading: vehicleCoords.heading,
iconWidget: Icon( iconWidget: Icon(
Icons.navigation, Icons.navigation,
@ -64,7 +102,7 @@ class DashboardMapPanel extends ConsumerWidget {
for (final alert in activeAlerts) { for (final alert in activeAlerts) {
mapMarkers.add( mapMarkers.add(
AppMapMarker( AppMapMarker(
id: 'dash_alert__', id: 'dash_alert_${alert.id}',
position: AppLatLng(alert.latitude, alert.longitude), position: AppLatLng(alert.latitude, alert.longitude),
heading: alert.heading ?? 0.0, heading: alert.heading ?? 0.0,
label: alert.category.displayName, label: alert.category.displayName,
@ -116,14 +154,14 @@ class DashboardMapPanel extends ConsumerWidget {
Positioned.fill( Positioned.fill(
child: UnifiedMapView( child: UnifiedMapView(
key: const ValueKey('dash_map_x'), key: const ValueKey('dash_map_x'),
center: AppLatLng(centerLat, centerLng), center: activeCenter,
zoom: zoom, zoom: widget.zoom,
bearing: 0.0, bearing: 0.0,
markers: mapMarkers, markers: mapMarkers,
polylines: polylines, polylines: polylines,
), ),
), ),
if (!isFresh && hasCoordinates) if (!isFresh && (hasCoordinates || _lastKnownRiderPos != null))
Positioned( Positioned(
top: 10, top: 10,
left: 10, left: 10,
@ -154,6 +192,56 @@ class DashboardMapPanel extends ConsumerWidget {
), ),
), ),
), ),
if (!isFollowMode)
Positioned(
bottom: 10,
right: 10,
child: Material(
color: Colors.black.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(20),
child: InkWell(
key: const ValueKey('dashboard_map_recenter_button'),
borderRadius: BorderRadius.circular(20),
onTap: () {
ref.read(mapFollowStateProvider.notifier).recenter();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.cyberCyan,
width: 1.2,
),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.my_location,
size: 14,
color: AppColors.cyberCyan,
),
SizedBox(width: 5),
Text(
'RECENTER',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 10,
fontWeight: FontWeight.w800,
letterSpacing: 0.6,
color: AppColors.cyberCyan,
),
),
],
),
),
),
),
),
], ],
), ),
); );
@ -162,3 +250,4 @@ class DashboardMapPanel extends ConsumerWidget {
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -100,6 +100,14 @@ class _FeedCommentsBottomSheetState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.motorColors; final colors = context.motorColors;
final isLight = Theme.of(context).brightness == Brightness.light;
final secondaryTextColor =
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
final accentColor =
isLight ? AppColors.brandBlueStrong : colors.brandCyanBright;
final sendBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan;
final sendFg = isLight ? AppColors.onBrand : const Color(0xFF00363D);
final bottomInset = MediaQuery.of(context).viewInsets.bottom; final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final feedItems = ref.watch(socialFeedNotifierProvider); final feedItems = ref.watch(socialFeedNotifierProvider);
final livePost = feedItems.cast<SocialFeedItem?>().firstWhere( final livePost = feedItems.cast<SocialFeedItem?>().firstWhere(
@ -116,14 +124,14 @@ class _FeedCommentsBottomSheetState
), ),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF101419), color: colors.surfaceCardPrimary,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)), border: Border.all(color: colors.surfaceBorder),
boxShadow: const [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black87, color: colors.glowShadow,
blurRadius: 30, blurRadius: 30,
offset: Offset(0, -10), offset: const Offset(0, -10),
), ),
], ],
), ),
@ -139,7 +147,7 @@ class _FeedCommentsBottomSheetState
width: 44, width: 44,
height: 4, height: 4,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.outlineVariant, color: colors.surfaceBorder,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
@ -149,9 +157,9 @@ class _FeedCommentsBottomSheetState
padding: const EdgeInsets.fromLTRB(20, 8, 12, 12), padding: const EdgeInsets.fromLTRB(20, 8, 12, 12),
child: Row( child: Row(
children: [ children: [
const Icon( Icon(
Icons.chat_bubble_outline_rounded, Icons.chat_bubble_outline_rounded,
color: AppColors.electricCyan, color: accentColor,
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -161,7 +169,7 @@ class _FeedCommentsBottomSheetState
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white, color: colors.textPrimary,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -169,33 +177,35 @@ class _FeedCommentsBottomSheetState
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2), horizontal: 8, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1D2025), color: colors.surfaceCardSecondary,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: AppColors.electricCyan.withValues(alpha: 0.3), color: accentColor.withValues(alpha: 0.35),
), ),
), ),
child: Text( child: Text(
'${comments.length}', '${comments.length}',
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.electricCyan, color: accentColor,
), ),
), ),
), ),
const Spacer(), const Spacer(),
IconButton( IconButton(
icon: const Icon(Icons.close_rounded, icon: Icon(
color: Color(0xFF849396)), Icons.close_rounded,
color: secondaryTextColor,
),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
tooltip: 'Close', tooltip: 'Close',
), ),
], ],
), ),
), ),
const Divider(color: Color(0xFF1D2025), height: 1), Divider(color: colors.surfaceBorder, height: 1),
// Comment List or Empty // Comment List or Empty
Flexible( Flexible(
@ -209,16 +219,16 @@ class _FeedCommentsBottomSheetState
Icon( Icon(
Icons.forum_outlined, Icons.forum_outlined,
size: 40, size: 40,
color: colors.outline.withValues(alpha: 0.4), color: secondaryTextColor,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text( Text(
'No debrief comments yet', 'No debrief comments yet',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white70, color: colors.textPrimary,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -228,7 +238,7 @@ class _FeedCommentsBottomSheetState
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 12, fontSize: 12,
color: colors.outline, color: secondaryTextColor,
), ),
), ),
], ],
@ -246,10 +256,10 @@ class _FeedCommentsBottomSheetState
return Container( return Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF181C21), color: colors.surfaceCardSecondary,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: const Color(0xFF272A30), color: colors.surfaceBorder,
), ),
), ),
child: Column( child: Column(
@ -259,12 +269,11 @@ class _FeedCommentsBottomSheetState
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 14, radius: 14,
backgroundColor: backgroundColor: colors.surfaceCardPrimary,
const Color(0xFF272A30), child: Icon(
child: const Icon(
Icons.sports_motorsports_rounded, Icons.sports_motorsports_rounded,
size: 16, size: 16,
color: AppColors.electricCyan, color: accentColor,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -275,12 +284,12 @@ class _FeedCommentsBottomSheetState
child: Text( child: Text(
c.authorDisplayName, c.authorDisplayName,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
fontFamily: fontFamily:
AppTypography.fontHeadline, AppTypography.fontHeadline,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white, color: colors.textPrimary,
), ),
), ),
), ),
@ -292,7 +301,7 @@ class _FeedCommentsBottomSheetState
fontFamily: fontFamily:
AppTypography.fontBody, AppTypography.fontBody,
fontSize: 11, fontSize: 11,
color: colors.outline, color: secondaryTextColor,
), ),
), ),
], ],
@ -304,7 +313,7 @@ class _FeedCommentsBottomSheetState
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 10, fontSize: 10,
color: colors.outline, color: secondaryTextColor,
), ),
), ),
], ],
@ -314,11 +323,11 @@ class _FeedCommentsBottomSheetState
padding: const EdgeInsets.only(left: 36), padding: const EdgeInsets.only(left: 36),
child: Text( child: Text(
c.content, c.content,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 13, fontSize: 13,
height: 1.35, height: 1.35,
color: Color(0xFFE0E2EA), color: colors.textPrimary,
), ),
), ),
), ),
@ -329,7 +338,7 @@ class _FeedCommentsBottomSheetState
), ),
), ),
const Divider(color: Color(0xFF1D2025), height: 1), Divider(color: colors.surfaceBorder, height: 1),
// Comment Input Field (Glove Friendly) // Comment Input Field (Glove Friendly)
Padding( Padding(
@ -340,28 +349,28 @@ class _FeedCommentsBottomSheetState
child: Container( child: Container(
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF181C21), color: colors.surfaceCardSecondary,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: const Color(0xFF272A30), color: colors.surfaceBorder,
), ),
), ),
child: TextField( child: TextField(
controller: _commentController, controller: _commentController,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 13, fontSize: 13,
color: Colors.white, color: colors.textPrimary,
), ),
decoration: const InputDecoration( decoration: InputDecoration(
hintText: 'Add comment or pace tip...', hintText: 'Add comment or pace tip...',
hintStyle: TextStyle( hintStyle: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 13, fontSize: 13,
color: Color(0xFF849396), color: secondaryTextColor,
), ),
border: InputBorder.none, border: InputBorder.none,
contentPadding: EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 14, horizontal: 14,
vertical: 14, vertical: 14,
), ),
@ -376,8 +385,8 @@ class _FeedCommentsBottomSheetState
width: 48, width: 48,
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.electricCyan, backgroundColor: sendBg,
foregroundColor: const Color(0xFF00363D), foregroundColor: sendFg,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -387,12 +396,12 @@ class _FeedCommentsBottomSheetState
onPressed: onPressed:
_isSubmitting ? null : () => _handlePostComment(), _isSubmitting ? null : () => _handlePostComment(),
child: _isSubmitting child: _isSubmitting
? const SizedBox( ? SizedBox(
width: 18, width: 18,
height: 18, height: 18,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: Color(0xFF00363D), color: sendFg,
), ),
) )
: const Icon(Icons.send_rounded, size: 20), : const Icon(Icons.send_rounded, size: 20),

View File

@ -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),
); );
} }

View File

@ -15,14 +15,28 @@ import '../../../garage/garage_providers.dart';
/// Modal dialog / bottom sheet allowing riders to share their recent ride /// Modal dialog / bottom sheet allowing riders to share their recent ride
/// or canyon session to the Community Social Feed. /// or canyon session to the Community Social Feed.
class ShareSessionModal extends ConsumerStatefulWidget { class ShareSessionModal extends ConsumerStatefulWidget {
const ShareSessionModal({super.key}); final Ride? initialRide;
final List<TelemetrySample>? initialTelemetrySamples;
static Future<void> show(BuildContext context) { const ShareSessionModal({
super.key,
this.initialRide,
this.initialTelemetrySamples,
});
static Future<void> show(
BuildContext context, {
Ride? initialRide,
List<TelemetrySample>? initialTelemetrySamples,
}) {
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (ctx) => const ShareSessionModal(), builder: (ctx) => ShareSessionModal(
initialRide: initialRide,
initialTelemetrySamples: initialTelemetrySamples,
),
); );
} }
@ -33,38 +47,104 @@ class ShareSessionModal extends ConsumerStatefulWidget {
class _ShareSessionModalState extends ConsumerState<ShareSessionModal> { class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController(text: 'Morning Canyon Carve'); final _titleController = TextEditingController(text: 'Morning Canyon Carve');
final _locationController = TextEditingController(text: 'Skyline Blvd & Highway 9'); final _locationController =
TextEditingController(text: 'Skyline Blvd & Highway 9');
final _bikeController = TextEditingController(); final _bikeController = TextEditingController();
final _distanceController = TextEditingController(text: '48.5');
final _durationController = TextEditingController(text: '52');
final _maxLeanController = TextEditingController(text: '49');
String _selectedLeanSide = 'RIGHT';
String _selectedPace = '1.25x Spirited';
RidingDiscipline _selectedDiscipline = RidingDiscipline.canyonSport; RidingDiscipline _selectedDiscipline = RidingDiscipline.canyonSport;
bool _isSubmitting = false; bool _isSubmitting = false;
List<Ride> _recentRides = []; List<Ride> _recentRides = [];
Ride? _selectedRide; Ride? _selectedRide;
List<AppLatLng> _selectedRoutePoints = const [ List<AppLatLng> _selectedRoutePoints = const [];
AppLatLng(37.3861, -122.2641), List<FeedTelemetrySample> _selectedTelemetrySamples = const [];
AppLatLng(37.3500, -122.2300),
AppLatLng(37.3100, -122.2000), double get _authoritativeDistanceMiles {
AppLatLng(37.2800, -122.2200), final ride = _selectedRide;
]; if (ride == null) return 0.0;
return ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance;
}
int get _authoritativeDurationSeconds {
final ride = _selectedRide;
if (ride == null) return 0;
if (ride.movingTime > 0) return ride.movingTime;
if (ride.endTime != null) {
return ride.endTime!
.difference(ride.startTime)
.inSeconds
.clamp(0, 864000);
}
return 0;
}
int get _authoritativeDurationMinutes =>
(_authoritativeDurationSeconds / 60).round();
double get _authoritativeMaxLeanDeg {
final ride = _selectedRide;
if (ride == null) return 0.0;
return math.max(ride.maxLeanLeft, ride.maxLeanRight);
}
String get _authoritativeLeanSide {
final ride = _selectedRide;
if (ride == null ||
(ride.maxLeanLeft == 0.0 && ride.maxLeanRight == 0.0)) {
return 'NONE';
}
return ride.maxLeanRight >= ride.maxLeanLeft ? 'RIGHT' : 'LEFT';
}
int get _authoritativeElevationFt {
final ride = _selectedRide;
if (ride == null) return 0;
return (ride.elevationGain * 3.28084).round();
}
String get _authoritativePaceFactor {
final ride = _selectedRide;
if (ride == null) return 'N/A';
final avgSpeed = ride.averageSpeed > 0
? ride.averageSpeed
: (_authoritativeDurationSeconds > 0
? _authoritativeDistanceMiles /
(_authoritativeDurationSeconds / 3600.0)
: 0.0);
if (avgSpeed >= 45.0) return '1.45x Fast Pace';
if (avgSpeed >= 30.0) return '1.25x Spirited';
if (avgSpeed > 0.0) return '1.0x Cruiser';
return '1.0x Recorded';
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.initialRide != null) {
_recentRides = [widget.initialRide!];
_applySelectedRideMetadata(widget.initialRide!);
if (widget.initialTelemetrySamples != null) {
_extractSamples(widget.initialTelemetrySamples!);
}
}
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final defaultBike = ref.read(defaultMotorcycleProvider); final defaultBike = ref.read(defaultMotorcycleProvider);
if (defaultBike != null) { if (_bikeController.text.isEmpty) {
_bikeController.text = if (defaultBike != null) {
'${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'.trim(); _bikeController.text =
} else { '${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'
_bikeController.text = '2024 Ducati Streetfighter V4'; .trim();
} else {
_bikeController.text = '2024 Ducati Streetfighter V4';
}
} }
await _loadRecentRides(); if (widget.initialRide != null) {
if (widget.initialTelemetrySamples == null) {
await _loadSamplesForRide(widget.initialRide!);
}
} else {
await _loadRecentRides();
}
}); });
} }
@ -72,65 +152,105 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
try { try {
final db = ref.read(databaseProvider); final db = ref.read(databaseProvider);
final rides = await (db.select(db.rides) final rides = await (db.select(db.rides)
..orderBy([(t) => drift.OrderingTerm(expression: t.id, mode: drift.OrderingMode.desc)]) ..orderBy([
(t) => drift.OrderingTerm(
expression: t.id, mode: drift.OrderingMode.desc)
])
..limit(10)) ..limit(10))
.get(); .get();
if (mounted) { if (mounted) {
setState(() { setState(() {
_recentRides = rides; _recentRides = rides;
if (rides.isNotEmpty) {
_selectRide(rides.first);
}
}); });
if (rides.isNotEmpty) {
await _selectRide(rides.first);
}
} }
} catch (_) { } catch (_) {
// Ignore database retrieval errors on rides // Ignore database retrieval errors on rides
} }
} }
Future<void> _selectRide(Ride ride) async { void _applySelectedRideMetadata(Ride ride) {
_selectedRide = ride; _selectedRide = ride;
final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance; _titleController.text =
final durMins = ride.movingTime > 0 'Ride #${ride.id} • ${ride.startTime.month}/${ride.startTime.day} Ride';
? (ride.movingTime / 60).round()
: (ride.endTime?.difference(ride.startTime).inMinutes ?? 35).clamp(1, 999);
final maxLean = math.max(ride.maxLeanLeft, ride.maxLeanRight);
final side = ride.maxLeanRight >= ride.maxLeanLeft ? 'RIGHT' : 'LEFT';
_titleController.text = 'Ride #${ride.id} • ${ride.startTime.month}/${ride.startTime.day} Ride';
_distanceController.text = dist > 0 ? dist.toStringAsFixed(1) : '24.5';
_durationController.text = durMins.toString();
_maxLeanController.text = maxLean > 0 ? maxLean.toStringAsFixed(0) : '38';
_selectedLeanSide = side;
if (ride.motorcycleMake != null && ride.motorcycleMake!.isNotEmpty) { if (ride.motorcycleMake != null && ride.motorcycleMake!.isNotEmpty) {
_bikeController.text = _bikeController.text =
'${ride.motorcycleYear ?? ''} ${ride.motorcycleMake} ${ride.motorcycleModel ?? ''}'.trim(); '${ride.motorcycleYear ?? ''} ${ride.motorcycleMake} ${ride.motorcycleModel ?? ''}'
.trim();
}
}
void _extractSamples(List<TelemetrySample> samples) {
final validGps = samples
.where((s) =>
s.latitude != null &&
s.longitude != null &&
s.latitude!.isFinite &&
s.longitude!.isFinite &&
(s.latitude != 0.0 || s.longitude != 0.0))
.toList();
List<AppLatLng> downsampledRoute = const [];
if (validGps.isNotEmpty) {
final routeStep =
(validGps.length / 40).ceil().clamp(1, validGps.length);
downsampledRoute = [
for (int i = 0; i < validGps.length; i += routeStep)
AppLatLng(validGps[i].latitude!, validGps[i].longitude!),
if ((validGps.length - 1) % routeStep != 0)
AppLatLng(validGps.last.latitude!, validGps.last.longitude!),
];
} }
List<FeedTelemetrySample> downsampledTelemetry = const [];
if (samples.isNotEmpty) {
final sampleStep =
(samples.length / 60).ceil().clamp(1, samples.length);
final totalIdx = math.max(1, samples.length - 1);
downsampledTelemetry = [
for (int i = 0; i < samples.length; i += sampleStep)
FeedTelemetrySample(
progress: (i / totalIdx).clamp(0.0, 1.0),
speedMph: samples[i].speed ?? 0.0,
leanAngleDeg: samples[i].leanAngle ?? 0.0,
lateralG: samples[i].lateralG ?? 0.0,
altitudeFt: (samples[i].altitude ?? 0.0) * 3.28084,
latitude: samples[i].latitude,
longitude: samples[i].longitude,
),
];
}
_selectedRoutePoints = downsampledRoute;
_selectedTelemetrySamples = downsampledTelemetry;
}
Future<void> _loadSamplesForRide(Ride ride) async {
try { try {
final db = ref.read(databaseProvider); final db = ref.read(databaseProvider);
final samples = await (db.select(db.telemetrySamples) final samples = await (db.select(db.telemetrySamples)
..where((t) => t.rideId.equals(ride.id)) ..where((t) => t.rideId.equals(ride.id))
..orderBy([(t) => drift.OrderingTerm(expression: t.id)])) ..orderBy([(t) => drift.OrderingTerm(expression: t.id)]))
.get(); .get();
if (mounted && _selectedRide?.id == ride.id) {
final validSamples = samples.where((s) => s.latitude != null && s.longitude != null).toList(); setState(() {
if (validSamples.isNotEmpty) { _extractSamples(samples);
final step = (validSamples.length / 25).ceil().clamp(1, validSamples.length); });
final downsampled = [
for (int i = 0; i < validSamples.length; i += step)
AppLatLng(validSamples[i].latitude!, validSamples[i].longitude!)
];
if (mounted) {
setState(() {
_selectedRoutePoints = downsampled;
});
}
} }
} catch (_) {} } catch (_) {}
if (mounted) setState(() {}); }
Future<void> _selectRide(Ride ride) async {
setState(() {
_applySelectedRideMetadata(ride);
_selectedRoutePoints = const [];
_selectedTelemetrySamples = const [];
});
await _loadSamplesForRide(ride);
} }
@override @override
@ -138,9 +258,6 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
_titleController.dispose(); _titleController.dispose();
_locationController.dispose(); _locationController.dispose();
_bikeController.dispose(); _bikeController.dispose();
_distanceController.dispose();
_durationController.dispose();
_maxLeanController.dispose();
super.dispose(); super.dispose();
} }
@ -155,9 +272,7 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
final authorDisplayName = user?.displayName ?? 'Apex Rider'; final authorDisplayName = user?.displayName ?? 'Apex Rider';
final authorHandle = user?.email?.split('@').first ?? 'apex_rider'; final authorHandle = user?.email?.split('@').first ?? 'apex_rider';
final dist = double.tryParse(_distanceController.text) ?? 35.0; final ride = _selectedRide;
final durMins = int.tryParse(_durationController.text) ?? 45;
final lean = double.tryParse(_maxLeanController.text) ?? 45.0;
await ref.read(socialFeedServiceProvider).shareRecentRide( await ref.read(socialFeedServiceProvider).shareRecentRide(
authorUid: authorUid, authorUid: authorUid,
@ -174,16 +289,19 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
bikeModel: _bikeController.text.trim().isNotEmpty bikeModel: _bikeController.text.trim().isNotEmpty
? _bikeController.text.trim() ? _bikeController.text.trim()
: '2024 Ducati Streetfighter V4', : '2024 Ducati Streetfighter V4',
distanceMiles: dist, distanceMiles: _authoritativeDistanceMiles,
durationSeconds: durMins * 60, durationSeconds: _authoritativeDurationSeconds,
maxLeanAngleDeg: lean, maxLeanAngleDeg: _authoritativeMaxLeanDeg,
maxLeanSide: _selectedLeanSide, maxLeanSide: _authoritativeLeanSide,
paceFactor: _selectedPace, paceFactor: _authoritativePaceFactor,
hazardCount: 0, hazardCount: 0,
elevationFt: 1840, elevationFt: _authoritativeElevationFt,
discipline: _selectedDiscipline, discipline: _selectedDiscipline,
snapshotType: FeedSnapshotType.routeTrack, snapshotType: FeedSnapshotType.routeTrack,
routePoints: _selectedRoutePoints, routePoints: _selectedRoutePoints,
telemetrySamples: _selectedTelemetrySamples,
authoritativeRide: ride,
rideId: ride?.id,
); );
// Reset filter to All Activity so published session is immediately displayed // Reset filter to All Activity so published session is immediately displayed
@ -303,13 +421,13 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
color: Colors.white, color: Colors.white,
), ),
), ),
Text( const Text(
'Publish telemetry & route to community feed', 'Publish telemetry & route to community feed',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 12, fontSize: 12,
color: colors.outline, color: AppColors.darkOnSurfaceVariant,
), ),
), ),
], ],
@ -471,158 +589,82 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
// Telemetry Metrics Grid (Distance, Duration, Lean) // Read-Only Telemetry Metrics Grid (Distance, Duration, Lean, Side, Pace)
Row( Row(
children: [ children: [
Expanded( const Icon(
child: Column( Icons.lock_outline_rounded,
crossAxisAlignment: CrossAxisAlignment.start, size: 13,
children: [ color: AppColors.electricCyan,
_buildFieldLabel('DIST (MI)', colors),
TextFormField(
controller: _distanceController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 14,
color: Colors.white,
),
decoration: _buildInputDecoration(hint: '0.0'),
),
],
),
), ),
const SizedBox(width: 10), const SizedBox(width: 6),
Expanded( Expanded(
child: Column( child: Text(
crossAxisAlignment: CrossAxisAlignment.start, _selectedRide != null
children: [ ? 'RECORDED TELEMETRY (READ-ONLY)'
_buildFieldLabel('TIME (MINS)', colors), : 'RECORDED TELEMETRY (NO ACTIVE RIDE SELECTED)',
TextFormField( overflow: TextOverflow.ellipsis,
controller: _durationController, style: const TextStyle(
keyboardType: TextInputType.number, fontFamily: AppTypography.fontTelemetry,
style: const TextStyle( fontSize: 10.5,
fontFamily: AppTypography.fontTelemetry, fontWeight: FontWeight.w700,
fontSize: 14, letterSpacing: 0.7,
color: Colors.white, color: AppColors.electricCyan,
), ),
decoration: _buildInputDecoration(hint: '0'),
),
],
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildFieldLabel('LEAN (DEG)', colors),
TextFormField(
controller: _maxLeanController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 14,
color: AppColors.electricCyan,
),
decoration: _buildInputDecoration(hint: '0°'),
),
],
), ),
), ),
], ],
), ),
const SizedBox(height: 14), const SizedBox(height: 8),
// Lean Side & Pace Segment Selection
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: _buildReadOnlyTelemetryTile(
crossAxisAlignment: CrossAxisAlignment.start, key: const ValueKey('share_session_readonly_distance'),
children: [ label: 'DIST (MI)',
_buildFieldLabel('LEAN APEX SIDE', colors), value: _authoritativeDistanceMiles.toStringAsFixed(1),
Container( colors: colors,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFF181C21),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF272A30)),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedLeanSide,
isExpanded: true,
dropdownColor: const Color(0xFF181C21),
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 12,
color: Colors.white,
),
items: const [
DropdownMenuItem(
value: 'RIGHT', child: Text('RIGHT')),
DropdownMenuItem(
value: 'LEFT', child: Text('LEFT')),
DropdownMenuItem(
value: 'PEAK', child: Text('PEAK')),
],
onChanged: (val) {
if (val != null) {
setState(() => _selectedLeanSide = val);
}
},
),
),
),
],
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: _buildReadOnlyTelemetryTile(
crossAxisAlignment: CrossAxisAlignment.start, key: const ValueKey('share_session_readonly_duration'),
children: [ label: 'TIME (MINS)',
_buildFieldLabel('PACE FACTOR', colors), value: _authoritativeDurationMinutes.toString(),
Container( colors: colors,
padding: const EdgeInsets.symmetric(horizontal: 12), ),
decoration: BoxDecoration( ),
color: const Color(0xFF181C21), const SizedBox(width: 10),
borderRadius: BorderRadius.circular(10), Expanded(
border: Border.all(color: const Color(0xFF272A30)), child: _buildReadOnlyTelemetryTile(
), key: const ValueKey('share_session_readonly_lean'),
child: DropdownButtonHideUnderline( label: 'LEAN (DEG)',
child: DropdownButton<String>( value: '${_authoritativeMaxLeanDeg.toStringAsFixed(0)}°',
value: _selectedPace, colors: colors,
isExpanded: true, highlight: true,
dropdownColor: const Color(0xFF181C21), ),
style: const TextStyle( ),
fontFamily: AppTypography.fontTelemetry, ],
fontSize: 12, ),
color: Colors.white, const SizedBox(height: 10),
), Row(
items: const [ children: [
DropdownMenuItem( Expanded(
value: '1.0x Cruiser', child: _buildReadOnlyTelemetryTile(
child: Text('1.0x Cruiser')), key: const ValueKey('share_session_readonly_lean_side'),
DropdownMenuItem( label: 'LEAN APEX SIDE',
value: '1.25x Spirited', value: _authoritativeLeanSide,
child: Text('1.25x Spirited')), colors: colors,
DropdownMenuItem( ),
value: '1.45x Fast Pace', ),
child: Text('1.45x Fast Pace')), const SizedBox(width: 10),
], Expanded(
onChanged: (val) { child: _buildReadOnlyTelemetryTile(
if (val != null) { key: const ValueKey('share_session_readonly_pace'),
setState(() => _selectedPace = val); label: 'PACE FACTOR',
} value: _authoritativePaceFactor,
}, colors: colors,
),
),
),
],
), ),
), ),
], ],
@ -687,6 +729,53 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
); );
} }
Widget _buildReadOnlyTelemetryTile({
required Key key,
required String label,
required String value,
required MotorSignalThemeColors colors,
bool highlight = false,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildFieldLabel(label, colors),
Container(
key: key,
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
decoration: BoxDecoration(
color: const Color(0xFF14181E),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF263238)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
value,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 13,
fontWeight: FontWeight.w700,
color: highlight ? AppColors.electricCyan : Colors.white,
),
),
),
const Icon(
Icons.lock_outline_rounded,
size: 13,
color: Color(0xFF64748B),
),
],
),
),
],
);
}
Widget _buildFieldLabel(String label, MotorSignalThemeColors colors) { Widget _buildFieldLabel(String label, MotorSignalThemeColors colors) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.only(bottom: 6),

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
@ -102,11 +103,31 @@ class _AwsMapViewState extends State<_AwsMapView> {
late final MapController _mapController; late final MapController _mapController;
ll.LatLng? _lastFollowPos; ll.LatLng? _lastFollowPos;
DateTime? _lastFollowTime; DateTime? _lastFollowTime;
Timer? _pendingFollowTimer;
static const int _followThrottleMs = 250;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_mapController = MapController(); _mapController = MapController();
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
}
void _applyFollowMove(ll.LatLng target, double zoom) {
_lastFollowPos = target;
_lastFollowTime = DateTime.now();
try {
_mapController.move(target, zoom);
} catch (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(target, zoom);
} catch (_) {}
}
});
}
} }
@override @override
@ -124,33 +145,46 @@ class _AwsMapViewState extends State<_AwsMapView> {
? 1.0 ? 1.0
: (centerLl.latitude - _lastFollowPos!.latitude).abs() + : (centerLl.latitude - _lastFollowPos!.latitude).abs() +
(centerLl.longitude - _lastFollowPos!.longitude).abs(); (centerLl.longitude - _lastFollowPos!.longitude).abs();
final hasMoved = posDelta > 0.00001;
final elapsedMs = _lastFollowTime == null
? _followThrottleMs + 1
: now.difference(_lastFollowTime!).inMilliseconds;
final shouldMove = justBecameFollowMode || final shouldMoveNow = justBecameFollowMode ||
_lastFollowPos == null || _lastFollowPos == null ||
zoomChanged || zoomChanged ||
(posDelta > 0.00005 && (hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001));
(_lastFollowTime == null ||
now.difference(_lastFollowTime!).inMilliseconds > 500));
if (shouldMove) { if (shouldMoveNow) {
_lastFollowPos = centerLl; _pendingFollowTimer?.cancel();
_lastFollowTime = now; _pendingFollowTimer = null;
_applyFollowMove(centerLl, widget.zoom);
} else if (hasMoved) {
_pendingFollowTimer?.cancel();
final remainingMs = (_followThrottleMs - elapsedMs).clamp(16, _followThrottleMs);
_pendingFollowTimer = Timer(Duration(milliseconds: remainingMs), () {
_pendingFollowTimer = null;
if (mounted && widget.isFollowMode) {
final latestCenter = ll.LatLng(
widget.center.latitude,
widget.center.longitude,
);
_applyFollowMove(latestCenter, widget.zoom);
}
});
}
} else {
_pendingFollowTimer?.cancel();
_pendingFollowTimer = null;
if (zoomChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
try { try {
_mapController.move(centerLl, widget.zoom); _mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {} } catch (_) {}
} }
}); });
} }
} else if (zoomChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {}
}
});
} }
if (bearingChanged) { if (bearingChanged) {
@ -166,6 +200,7 @@ class _AwsMapViewState extends State<_AwsMapView> {
@override @override
void dispose() { void dispose() {
_pendingFollowTimer?.cancel();
_mapController.dispose(); _mapController.dispose();
super.dispose(); super.dispose();
} }

View File

@ -4,8 +4,6 @@ import '../../core/theme/map_style.dart';
import 'map_provider_interface.dart'; import 'map_provider_interface.dart';
class GoogleMapProvider implements AppMapProvider { class GoogleMapProvider implements AppMapProvider {
gmaps.GoogleMapController? _controller;
@override @override
AppMapType get type => AppMapType.googleMaps; AppMapType get type => AppMapType.googleMaps;
@ -25,15 +23,122 @@ class GoogleMapProvider implements AppMapProvider {
required ValueChanged<AppLatLng>? onPositionChanged, required ValueChanged<AppLatLng>? onPositionChanged,
required VoidCallback? onUserInteraction, required VoidCallback? onUserInteraction,
}) { }) {
final target = gmaps.LatLng(center.latitude, center.longitude); return _GoogleMapView(
center: center,
zoom: zoom,
bearing: bearing,
markers: markers,
polylines: polylines,
isFollowMode: isFollowMode,
onPositionChanged: onPositionChanged,
onUserInteraction: onUserInteraction,
);
}
}
if (isFollowMode && _controller != null) { class _GoogleMapView extends StatefulWidget {
_controller!.animateCamera( final AppLatLng center;
gmaps.CameraUpdate.newLatLngZoom(target, zoom), final double zoom;
final double bearing;
final List<AppMapMarker> markers;
final List<AppPolyline> polylines;
final bool isFollowMode;
final ValueChanged<AppLatLng>? onPositionChanged;
final VoidCallback? onUserInteraction;
const _GoogleMapView({
required this.center,
required this.zoom,
this.bearing = 0.0,
required this.markers,
required this.polylines,
required this.isFollowMode,
required this.onPositionChanged,
required this.onUserInteraction,
});
@override
State<_GoogleMapView> createState() => _GoogleMapViewState();
}
class _GoogleMapViewState extends State<_GoogleMapView> {
gmaps.GoogleMapController? _controller;
gmaps.LatLng? _lastFollowPos;
DateTime? _lastFollowTime;
bool _isProgrammaticMove = false;
static const int _followThrottleMs = 250;
@override
void initState() {
super.initState();
_lastFollowPos = gmaps.LatLng(widget.center.latitude, widget.center.longitude);
}
Future<void> _moveCamera(gmaps.LatLng target, double zoom, double bearing) async {
final controller = _controller;
if (controller == null) return;
_lastFollowPos = target;
_lastFollowTime = DateTime.now();
_isProgrammaticMove = true;
try {
await controller.animateCamera(
gmaps.CameraUpdate.newCameraPosition(
gmaps.CameraPosition(
target: target,
zoom: zoom,
bearing: bearing,
),
),
); );
} catch (_) {
} finally {
_isProgrammaticMove = false;
} }
}
final gMarkers = markers.map((m) { @override
void didUpdateWidget(covariant _GoogleMapView oldWidget) {
super.didUpdateWidget(oldWidget);
if (_controller == null) return;
final target = gmaps.LatLng(widget.center.latitude, widget.center.longitude);
final zoomChanged = (oldWidget.zoom - widget.zoom).abs() > 0.01;
final bearingChanged = (oldWidget.bearing - widget.bearing).abs() > 0.1;
final justBecameFollowMode = !oldWidget.isFollowMode && widget.isFollowMode;
if (widget.isFollowMode) {
final now = DateTime.now();
final posDelta = _lastFollowPos == null
? 1.0
: (target.latitude - _lastFollowPos!.latitude).abs() +
(target.longitude - _lastFollowPos!.longitude).abs();
final hasMoved = posDelta > 0.00001;
final elapsedMs = _lastFollowTime == null
? _followThrottleMs + 1
: now.difference(_lastFollowTime!).inMilliseconds;
if (justBecameFollowMode ||
_lastFollowPos == null ||
zoomChanged ||
bearingChanged ||
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001))) {
_moveCamera(target, widget.zoom, widget.bearing);
}
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final target = gmaps.LatLng(widget.center.latitude, widget.center.longitude);
final gMarkers = widget.markers.map((m) {
return gmaps.Marker( return gmaps.Marker(
markerId: gmaps.MarkerId(m.id), markerId: gmaps.MarkerId(m.id),
position: gmaps.LatLng(m.position.latitude, m.position.longitude), position: gmaps.LatLng(m.position.latitude, m.position.longitude),
@ -42,7 +147,7 @@ class GoogleMapProvider implements AppMapProvider {
); );
}).toSet(); }).toSet();
final gPolylines = polylines.map((p) { final gPolylines = widget.polylines.map((p) {
return gmaps.Polyline( return gmaps.Polyline(
polylineId: gmaps.PolylineId(p.id), polylineId: gmaps.PolylineId(p.id),
points: p.points.map((pt) => gmaps.LatLng(pt.latitude, pt.longitude)).toList(), points: p.points.map((pt) => gmaps.LatLng(pt.latitude, pt.longitude)).toList(),
@ -58,7 +163,8 @@ class GoogleMapProvider implements AppMapProvider {
key: ValueKey('google_map_$sizeKey'), key: ValueKey('google_map_$sizeKey'),
initialCameraPosition: gmaps.CameraPosition( initialCameraPosition: gmaps.CameraPosition(
target: target, target: target,
zoom: zoom, zoom: widget.zoom,
bearing: widget.bearing,
), ),
markers: gMarkers, markers: gMarkers,
polylines: gPolylines, polylines: gPolylines,
@ -68,13 +174,21 @@ class GoogleMapProvider implements AppMapProvider {
compassEnabled: false, compassEnabled: false,
onMapCreated: (controller) { onMapCreated: (controller) {
_controller = controller; _controller = controller;
if (widget.isFollowMode) {
_moveCamera(target, widget.zoom, widget.bearing);
}
}, },
onCameraMoveStarted: () { onCameraMoveStarted: () {
onUserInteraction?.call(); if (!_isProgrammaticMove) {
widget.onUserInteraction?.call();
}
}, },
onCameraMove: (position) { onCameraMove: (position) {
onPositionChanged?.call(AppLatLng(position.target.latitude, position.target.longitude)); widget.onPositionChanged?.call(
AppLatLng(position.target.latitude, position.target.longitude),
);
}, },
); );
} }
} }

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
@ -69,11 +70,31 @@ class _OsmMapViewState extends State<_OsmMapView> {
late final MapController _mapController; late final MapController _mapController;
ll.LatLng? _lastFollowPos; ll.LatLng? _lastFollowPos;
DateTime? _lastFollowTime; DateTime? _lastFollowTime;
Timer? _pendingFollowTimer;
static const int _followThrottleMs = 250;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_mapController = MapController(); _mapController = MapController();
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
}
void _applyFollowMove(ll.LatLng target, double zoom) {
_lastFollowPos = target;
_lastFollowTime = DateTime.now();
try {
_mapController.move(target, zoom);
} catch (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(target, zoom);
} catch (_) {}
}
});
}
} }
@override @override
@ -90,33 +111,46 @@ class _OsmMapViewState extends State<_OsmMapView> {
? 1.0 ? 1.0
: (centerLl.latitude - _lastFollowPos!.latitude).abs() + : (centerLl.latitude - _lastFollowPos!.latitude).abs() +
(centerLl.longitude - _lastFollowPos!.longitude).abs(); (centerLl.longitude - _lastFollowPos!.longitude).abs();
final hasMoved = posDelta > 0.00001;
final elapsedMs = _lastFollowTime == null
? _followThrottleMs + 1
: now.difference(_lastFollowTime!).inMilliseconds;
final shouldMove = justBecameFollowMode || final shouldMoveNow = justBecameFollowMode ||
_lastFollowPos == null || _lastFollowPos == null ||
zoomChanged || zoomChanged ||
(posDelta > 0.00005 && (hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001));
(_lastFollowTime == null ||
now.difference(_lastFollowTime!).inMilliseconds > 500));
if (shouldMove) { if (shouldMoveNow) {
_lastFollowPos = centerLl; _pendingFollowTimer?.cancel();
_lastFollowTime = now; _pendingFollowTimer = null;
_applyFollowMove(centerLl, widget.zoom);
} else if (hasMoved) {
_pendingFollowTimer?.cancel();
final remainingMs = (_followThrottleMs - elapsedMs).clamp(16, _followThrottleMs);
_pendingFollowTimer = Timer(Duration(milliseconds: remainingMs), () {
_pendingFollowTimer = null;
if (mounted && widget.isFollowMode) {
final latestCenter = ll.LatLng(
widget.center.latitude,
widget.center.longitude,
);
_applyFollowMove(latestCenter, widget.zoom);
}
});
}
} else {
_pendingFollowTimer?.cancel();
_pendingFollowTimer = null;
if (zoomChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
try { try {
_mapController.move(centerLl, widget.zoom); _mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {} } catch (_) {}
} }
}); });
} }
} else if (zoomChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {}
}
});
} }
if (bearingChanged) { if (bearingChanged) {
@ -132,6 +166,7 @@ class _OsmMapViewState extends State<_OsmMapView> {
@override @override
void dispose() { void dispose() {
_pendingFollowTimer?.cancel();
_mapController.dispose(); _mapController.dispose();
super.dispose(); super.dispose();
} }

View File

@ -134,10 +134,55 @@ enum FeedSnapshotType {
scenicPhoto, scenicPhoto,
} }
/// A recorded telemetry sample point attached to a shared social feed session.
@immutable
class FeedTelemetrySample {
final double progress; // 0.0 to 1.0 along the session
final double speedMph;
final double leanAngleDeg; // signed degrees (-left, +right)
final double lateralG;
final double altitudeFt;
final double? latitude;
final double? longitude;
const FeedTelemetrySample({
required this.progress,
required this.speedMph,
required this.leanAngleDeg,
this.lateralG = 0.0,
this.altitudeFt = 0.0,
this.latitude,
this.longitude,
});
Map<String, dynamic> toJson() => {
'progress': progress,
'speedMph': speedMph,
'leanAngleDeg': leanAngleDeg,
'lateralG': lateralG,
'altitudeFt': altitudeFt,
if (latitude != null) 'lat': latitude,
if (longitude != null) 'lng': longitude,
};
factory FeedTelemetrySample.fromJson(Map<String, dynamic> json) {
return FeedTelemetrySample(
progress: ((json['progress'] as num?)?.toDouble() ?? 0.0).clamp(0.0, 1.0),
speedMph: (json['speedMph'] as num?)?.toDouble() ?? 0.0,
leanAngleDeg: (json['leanAngleDeg'] as num?)?.toDouble() ?? 0.0,
lateralG: (json['lateralG'] as num?)?.toDouble() ?? 0.0,
altitudeFt: (json['altitudeFt'] as num?)?.toDouble() ?? 0.0,
latitude: (json['lat'] as num?)?.toDouble(),
longitude: (json['lng'] as num?)?.toDouble(),
);
}
}
/// A community social feed item (Stitch SOC-01). /// A community social feed item (Stitch SOC-01).
@immutable @immutable
class SocialFeedItem { class SocialFeedItem {
final String id; final String id;
final int? rideId;
final String authorUid; final String authorUid;
final String authorDisplayName; final String authorDisplayName;
final String authorHandle; final String authorHandle;
@ -153,7 +198,7 @@ class SocialFeedItem {
final double distanceMiles; final double distanceMiles;
final int durationSeconds; final int durationSeconds;
final double maxLeanAngleDeg; final double maxLeanAngleDeg;
final String maxLeanSide; // 'RIGHT', 'LEFT', 'PEAK' final String maxLeanSide; // 'RIGHT', 'LEFT', 'PEAK', 'NONE'
final String paceFactor; // e.g. '1.32x Spirited' final String paceFactor; // e.g. '1.32x Spirited'
final int hazardCount; // e.g. 0 final int hazardCount; // e.g. 0
final int elevationFt; // e.g. +2140 final int elevationFt; // e.g. +2140
@ -163,9 +208,10 @@ class SocialFeedItem {
final String? regionName; final String? regionName;
final bool isFeatured; final bool isFeatured;
// Visual Snapshot Module // Visual Snapshot & Recorded Telemetry Module
final FeedSnapshotType snapshotType; final FeedSnapshotType snapshotType;
final List<AppLatLng> routePoints; final List<AppLatLng> routePoints;
final List<FeedTelemetrySample> telemetrySamples;
final String? photoUrl; final String? photoUrl;
final String? photoCaption; final String? photoCaption;
final bool isGpsVerified; final bool isGpsVerified;
@ -178,6 +224,7 @@ class SocialFeedItem {
const SocialFeedItem({ const SocialFeedItem({
required this.id, required this.id,
this.rideId,
required this.authorUid, required this.authorUid,
required this.authorDisplayName, required this.authorDisplayName,
required this.authorHandle, required this.authorHandle,
@ -194,21 +241,80 @@ class SocialFeedItem {
this.maxLeanSide = 'RIGHT', this.maxLeanSide = 'RIGHT',
this.paceFactor = '1.2x Spirited', this.paceFactor = '1.2x Spirited',
this.hazardCount = 0, this.hazardCount = 0,
this.elevationFt = 1200, this.elevationFt = 0,
this.discipline = RidingDiscipline.canyonSport, this.discipline = RidingDiscipline.canyonSport,
this.regionName, this.regionName,
this.isFeatured = false, this.isFeatured = false,
this.snapshotType = FeedSnapshotType.routeTrack, this.snapshotType = FeedSnapshotType.routeTrack,
this.routePoints = const [], this.routePoints = const [],
this.telemetrySamples = const [],
this.photoUrl, this.photoUrl,
this.photoCaption, this.photoCaption,
this.isGpsVerified = true, this.isGpsVerified = false,
this.isDemo = false, this.isDemo = false,
this.fistBumpCount = 0, this.fistBumpCount = 0,
this.fistBumpUserIds = const [], this.fistBumpUserIds = const [],
this.comments = const [], this.comments = const [],
}); });
/// Evaluates whether a set of ride metrics and GPS route coordinates satisfy
/// authentic GPS verification criteria.
static bool verifyGpsCriteria({
required bool isDemo,
required double distanceMiles,
required int durationSeconds,
required List<AppLatLng> routePoints,
}) {
if (isDemo) return false;
if (!distanceMiles.isFinite || distanceMiles <= 0.0) return false;
if (durationSeconds <= 0) return false;
final validPoints = routePoints.where((p) {
return p.latitude.isFinite &&
p.longitude.isFinite &&
p.latitude.abs() <= 90.0 &&
p.longitude.abs() <= 180.0 &&
(p.latitude != 0.0 || p.longitude != 0.0);
}).toList();
if (validPoints.length < 2) return false;
// 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.0005,
);
return hasDistinctPoint;
}
/// True only when this post is flagged as GPS-verified AND backed by valid,
/// non-demo GPS route coordinates, positive distance, and positive duration.
bool get hasVerifiedGps =>
isGpsVerified &&
verifyGpsCriteria(
isDemo: isDemo,
distanceMiles: distanceMiles,
durationSeconds: durationSeconds,
routePoints: routePoints,
);
/// 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;
/// True when the post contains recorded telemetry waveform samples.
bool get hasRecordedTelemetry => !isDemo && telemetrySamples.length >= 2;
bool isFistBumpedBy(String? uid) { bool isFistBumpedBy(String? uid) {
if (uid == null) return false; if (uid == null) return false;
return fistBumpUserIds.contains(uid); return fistBumpUserIds.contains(uid);
@ -216,6 +322,7 @@ class SocialFeedItem {
SocialFeedItem copyWith({ SocialFeedItem copyWith({
String? id, String? id,
int? rideId,
String? authorUid, String? authorUid,
String? authorDisplayName, String? authorDisplayName,
String? authorHandle, String? authorHandle,
@ -238,6 +345,7 @@ class SocialFeedItem {
bool? isFeatured, bool? isFeatured,
FeedSnapshotType? snapshotType, FeedSnapshotType? snapshotType,
List<AppLatLng>? routePoints, List<AppLatLng>? routePoints,
List<FeedTelemetrySample>? telemetrySamples,
String? photoUrl, String? photoUrl,
String? photoCaption, String? photoCaption,
bool? isGpsVerified, bool? isGpsVerified,
@ -248,6 +356,7 @@ class SocialFeedItem {
}) { }) {
return SocialFeedItem( return SocialFeedItem(
id: id ?? this.id, id: id ?? this.id,
rideId: rideId ?? this.rideId,
authorUid: authorUid ?? this.authorUid, authorUid: authorUid ?? this.authorUid,
authorDisplayName: authorDisplayName ?? this.authorDisplayName, authorDisplayName: authorDisplayName ?? this.authorDisplayName,
authorHandle: authorHandle ?? this.authorHandle, authorHandle: authorHandle ?? this.authorHandle,
@ -270,6 +379,7 @@ class SocialFeedItem {
isFeatured: isFeatured ?? this.isFeatured, isFeatured: isFeatured ?? this.isFeatured,
snapshotType: snapshotType ?? this.snapshotType, snapshotType: snapshotType ?? this.snapshotType,
routePoints: routePoints ?? this.routePoints, routePoints: routePoints ?? this.routePoints,
telemetrySamples: telemetrySamples ?? this.telemetrySamples,
photoUrl: photoUrl ?? this.photoUrl, photoUrl: photoUrl ?? this.photoUrl,
photoCaption: photoCaption ?? this.photoCaption, photoCaption: photoCaption ?? this.photoCaption,
isGpsVerified: isGpsVerified ?? this.isGpsVerified, isGpsVerified: isGpsVerified ?? this.isGpsVerified,
@ -282,6 +392,7 @@ class SocialFeedItem {
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
if (rideId != null) 'rideId': rideId,
'authorUid': authorUid, 'authorUid': authorUid,
'authorDisplayName': authorDisplayName, 'authorDisplayName': authorDisplayName,
'authorHandle': authorHandle, 'authorHandle': authorHandle,
@ -303,10 +414,14 @@ class SocialFeedItem {
if (regionName != null) 'regionName': regionName, if (regionName != null) 'regionName': regionName,
'isFeatured': isFeatured, 'isFeatured': isFeatured,
'snapshotType': snapshotType.name, 'snapshotType': snapshotType.name,
'routePoints': routePoints.map((p) => {'lat': p.latitude, 'lng': p.longitude}).toList(), 'routePoints': routePoints
.map((p) => {'lat': p.latitude, 'lng': p.longitude})
.toList(),
if (telemetrySamples.isNotEmpty)
'telemetrySamples': telemetrySamples.map((s) => s.toJson()).toList(),
if (photoUrl != null) 'photoUrl': photoUrl, if (photoUrl != null) 'photoUrl': photoUrl,
if (photoCaption != null) 'photoCaption': photoCaption, if (photoCaption != null) 'photoCaption': photoCaption,
'isGpsVerified': isGpsVerified, 'isGpsVerified': hasVerifiedGps,
'isDemo': isDemo, 'isDemo': isDemo,
'fistBumpCount': fistBumpCount, 'fistBumpCount': fistBumpCount,
'fistBumpUserIds': fistBumpUserIds, 'fistBumpUserIds': fistBumpUserIds,
@ -326,14 +441,33 @@ class SocialFeedItem {
} }
} }
final telemetryList = json['telemetrySamples'] as List<dynamic>? ?? [];
final samples = telemetryList
.whereType<Map>()
.map((s) => FeedTelemetrySample.fromJson(Map<String, dynamic>.from(s)))
.toList();
final commentList = json['comments'] as List<dynamic>? ?? []; final commentList = json['comments'] as List<dynamic>? ?? [];
final comments = commentList final comments = commentList
.whereType<Map<String, dynamic>>() .whereType<Map<String, dynamic>>()
.map((c) => FeedPostComment.fromJson(c)) .map((c) => FeedPostComment.fromJson(c))
.toList(); .toList();
final distanceMiles = (json['distanceMiles'] as num?)?.toDouble() ?? 0.0;
final durationSeconds = (json['durationSeconds'] as num?)?.toInt() ?? 0;
final isDemo = json['isDemo'] as bool? ?? false;
final rawVerified = json['isGpsVerified'] as bool? ?? false;
final verified = rawVerified &&
verifyGpsCriteria(
isDemo: isDemo,
distanceMiles: distanceMiles,
durationSeconds: durationSeconds,
routePoints: points,
);
return SocialFeedItem( return SocialFeedItem(
id: json['id'] as String? ?? '', id: json['id'] as String? ?? '',
rideId: (json['rideId'] as num?)?.toInt(),
authorUid: json['authorUid'] as String? ?? '', authorUid: json['authorUid'] as String? ?? '',
authorDisplayName: json['authorDisplayName'] as String? ?? 'Rider', authorDisplayName: json['authorDisplayName'] as String? ?? 'Rider',
authorHandle: json['authorHandle'] as String? ?? 'rider', authorHandle: json['authorHandle'] as String? ?? 'rider',
@ -344,8 +478,8 @@ class SocialFeedItem {
timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(), timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(),
rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve', rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve',
bikeModel: json['bikeModel'] as String? ?? 'Motorcycle', bikeModel: json['bikeModel'] as String? ?? 'Motorcycle',
distanceMiles: (json['distanceMiles'] as num?)?.toDouble() ?? 0.0, distanceMiles: distanceMiles,
durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0, durationSeconds: durationSeconds,
maxLeanAngleDeg: (json['maxLeanAngleDeg'] as num?)?.toDouble() ?? 0.0, maxLeanAngleDeg: (json['maxLeanAngleDeg'] as num?)?.toDouble() ?? 0.0,
maxLeanSide: json['maxLeanSide'] as String? ?? 'RIGHT', maxLeanSide: json['maxLeanSide'] as String? ?? 'RIGHT',
paceFactor: json['paceFactor'] as String? ?? '1.0x', paceFactor: json['paceFactor'] as String? ?? '1.0x',
@ -363,13 +497,16 @@ class SocialFeedItem {
? FeedSnapshotType.scenicPhoto ? FeedSnapshotType.scenicPhoto
: FeedSnapshotType.routeTrack, : FeedSnapshotType.routeTrack,
routePoints: points, routePoints: points,
telemetrySamples: samples,
photoUrl: json['photoUrl'] as String?, photoUrl: json['photoUrl'] as String?,
photoCaption: json['photoCaption'] as String?, photoCaption: json['photoCaption'] as String?,
isGpsVerified: json['isGpsVerified'] as bool? ?? true, isGpsVerified: verified,
isDemo: json['isDemo'] as bool? ?? false, isDemo: isDemo,
fistBumpCount: (json['fistBumpCount'] as num?)?.toInt() ?? 0, fistBumpCount: (json['fistBumpCount'] as num?)?.toInt() ?? 0,
fistBumpUserIds: (json['fistBumpUserIds'] as List<dynamic>?)?.cast<String>() ?? [], fistBumpUserIds:
(json['fistBumpUserIds'] as List<dynamic>?)?.cast<String>() ?? [],
comments: comments, comments: comments,
); );
} }
} }

View File

@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import '../../database/database.dart';
import '../map/map_provider_interface.dart'; import '../map/map_provider_interface.dart';
import 'social_feed_models.dart'; import 'social_feed_models.dart';
@ -363,7 +365,7 @@ class SocialFeedService {
result = result.where((i) => i.distanceMiles >= minDistanceMiles).toList(); result = result.where((i) => i.distanceMiles >= minDistanceMiles).toList();
} }
if (verifiedGpsOnly == true) { if (verifiedGpsOnly == true) {
result = result.where((i) => i.isGpsVerified).toList(); result = result.where((i) => i.hasVerifiedGps).toList();
} }
return result; return result;
@ -433,6 +435,11 @@ class SocialFeedService {
} }
/// Share a recent completed ride session to the community feed. /// Share a recent completed ride session to the community feed.
///
/// When [authoritativeRide] is provided, telemetry metrics (distance,
/// duration, max lean angle, lean side, elevation gain, and ride ID) are
/// derived strictly from [authoritativeRide] so manual/forged telemetry
/// overrides cannot be injected at the submission layer.
Future<SocialFeedItem> shareRecentRide({ Future<SocialFeedItem> shareRecentRide({
required String authorUid, required String authorUid,
required String authorDisplayName, required String authorDisplayName,
@ -443,21 +450,71 @@ class SocialFeedService {
required String locationName, required String locationName,
required String rideTitle, required String rideTitle,
required String bikeModel, required String bikeModel,
required double distanceMiles, double distanceMiles = 0.0,
required int durationSeconds, int durationSeconds = 0,
required double maxLeanAngleDeg, double maxLeanAngleDeg = 0.0,
String maxLeanSide = 'RIGHT', String maxLeanSide = 'RIGHT',
String paceFactor = '1.2x Spirited', String paceFactor = '1.0x Cruiser',
int hazardCount = 0, int hazardCount = 0,
int elevationFt = 1500, int elevationFt = 0,
RidingDiscipline discipline = RidingDiscipline.canyonSport, RidingDiscipline discipline = RidingDiscipline.canyonSport,
String? regionName, String? regionName,
bool isFeatured = false, bool isFeatured = false,
FeedSnapshotType snapshotType = FeedSnapshotType.routeTrack, FeedSnapshotType snapshotType = FeedSnapshotType.routeTrack,
List<AppLatLng> routePoints = const [], List<AppLatLng> routePoints = const [],
List<FeedTelemetrySample> telemetrySamples = const [],
Ride? authoritativeRide,
int? rideId,
bool? isGpsVerified,
}) async { }) async {
double resolvedDistance = distanceMiles;
int resolvedDurationSeconds = durationSeconds;
double resolvedMaxLean = maxLeanAngleDeg;
String resolvedLeanSide = maxLeanSide;
int resolvedElevationFt = elevationFt;
int? resolvedRideId = rideId;
if (authoritativeRide != null) {
resolvedRideId = authoritativeRide.id;
resolvedDistance = authoritativeRide.distanceMiles > 0
? authoritativeRide.distanceMiles
: authoritativeRide.distance;
resolvedDurationSeconds = authoritativeRide.movingTime > 0
? authoritativeRide.movingTime
: (authoritativeRide.endTime != null
? authoritativeRide.endTime!
.difference(authoritativeRide.startTime)
.inSeconds
.clamp(0, 864000)
: 0);
resolvedMaxLean = math.max(
authoritativeRide.maxLeanLeft,
authoritativeRide.maxLeanRight,
);
if (authoritativeRide.maxLeanLeft == 0 &&
authoritativeRide.maxLeanRight == 0) {
resolvedLeanSide = 'NONE';
} else {
resolvedLeanSide =
authoritativeRide.maxLeanRight >= authoritativeRide.maxLeanLeft
? 'RIGHT'
: 'LEFT';
}
resolvedElevationFt =
(authoritativeRide.elevationGain * 3.28084).round();
}
final meetsGpsVerification = SocialFeedItem.verifyGpsCriteria(
isDemo: false,
distanceMiles: resolvedDistance,
durationSeconds: resolvedDurationSeconds,
routePoints: routePoints,
);
final effectiveGpsVerified = (isGpsVerified ?? true) && meetsGpsVerification;
final newPost = SocialFeedItem( final newPost = SocialFeedItem(
id: 'feed_post_${DateTime.now().millisecondsSinceEpoch}', id: 'feed_post_${DateTime.now().millisecondsSinceEpoch}',
rideId: resolvedRideId,
authorUid: authorUid, authorUid: authorUid,
authorDisplayName: authorDisplayName, authorDisplayName: authorDisplayName,
authorHandle: authorHandle, authorHandle: authorHandle,
@ -468,19 +525,21 @@ class SocialFeedService {
timestamp: DateTime.now(), timestamp: DateTime.now(),
rideTitle: rideTitle, rideTitle: rideTitle,
bikeModel: bikeModel, bikeModel: bikeModel,
distanceMiles: distanceMiles, distanceMiles: resolvedDistance,
durationSeconds: durationSeconds, durationSeconds: resolvedDurationSeconds,
maxLeanAngleDeg: maxLeanAngleDeg, maxLeanAngleDeg: resolvedMaxLean,
maxLeanSide: maxLeanSide, maxLeanSide: resolvedLeanSide,
paceFactor: paceFactor, paceFactor: paceFactor,
hazardCount: hazardCount, hazardCount: hazardCount,
elevationFt: elevationFt, elevationFt: resolvedElevationFt,
discipline: discipline, discipline: discipline,
regionName: regionName, regionName: regionName,
isFeatured: isFeatured, isFeatured: isFeatured,
snapshotType: snapshotType, snapshotType: snapshotType,
routePoints: routePoints, routePoints: routePoints,
isGpsVerified: true, telemetrySamples: telemetrySamples,
isGpsVerified: effectiveGpsVerified,
isDemo: false,
fistBumpCount: 1, fistBumpCount: 1,
fistBumpUserIds: [authorUid], fistBumpUserIds: [authorUid],
comments: const [], comments: const [],
@ -504,3 +563,4 @@ class SocialFeedService {
_localFeedController.close(); _localFeedController.close();
} }
} }

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -20,6 +21,8 @@ import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
import 'package:motorcycle_dash/services/location_service.dart'; import 'package:motorcycle_dash/services/location_service.dart';
import 'package:motorcycle_dash/services/map/map_providers.dart';
import 'package:motorcycle_dash/services/map/unified_map_view.dart';
import 'package:motorcycle_dash/services/telemetry_service.dart'; import 'package:motorcycle_dash/services/telemetry_service.dart';
void main() { void main() {
@ -344,6 +347,166 @@ void main() {
expect(find.text('Enable'), findsOneWidget); expect(find.text('Enable'), findsOneWidget);
}); });
}); });
group('BUG-001: Touring Map Rider Follow & Recenter Regression Tests', () {
testWidgets(
'Touring map follows incoming GPS coordinates in portrait and landscape, pauses on manual pan, and resumes on recenter',
(tester) async {
await prefs.setString('dashboardLayout', 'touring');
configureViewport(tester, 400, 800);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(find.byType(DashboardMapPanel), findsOneWidget);
expect(find.byType(FlutterMap), findsOneWidget);
expect(
find.byKey(const ValueKey('dashboard_map_recenter_button')),
findsNothing,
);
// Emit initial GPS fix within 50ms of mount (previously dropped by 500ms cooldown)
telemetryController.add(
TelemetrySample(
timestamp: DateTime.now(),
speed: 35.0,
heading: 90.0,
latitude: 34.0522,
longitude: -118.2437,
gpsAccuracy: 5.0,
gpsAgeMs: 100,
),
);
await tester.pump(const Duration(milliseconds: 50));
var unifiedMap = tester.widget<UnifiedMapView>(
find.byType(UnifiedMapView),
);
var flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(unifiedMap.center.latitude, closeTo(34.0522, 0.0001));
expect(unifiedMap.center.longitude, closeTo(-118.2437, 0.0001));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(34.0522, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(-118.2437, 0.0001),
);
// Emit rapid subsequent GPS fix within 100ms (tests trailing timer follow)
telemetryController.add(
TelemetrySample(
timestamp: DateTime.now(),
speed: 45.0,
heading: 95.0,
latitude: 34.0526,
longitude: -118.2431,
gpsAccuracy: 5.0,
gpsAgeMs: 80,
),
);
await tester.pump(const Duration(milliseconds: 50));
// Advance past the 250ms trailing follow timer window
await tester.pump(const Duration(milliseconds: 300));
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(34.0526, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(-118.2431, 0.0001),
);
// Simulate user manual pan / gesture interaction on the Touring map
final element = tester.element(find.byType(DashboardMapPanel));
final container = ProviderScope.containerOf(element);
container.read(mapFollowStateProvider.notifier).userInteracted();
flutterMap.mapController!.move(
flutterMap.mapController!.camera.center,
16.5,
);
await tester.pump();
// Recenter button must appear when follow mode is disabled
final recenterFinder = find.byKey(
const ValueKey('dashboard_map_recenter_button'),
);
expect(recenterFinder, findsOneWidget);
// While panned (follow mode false), incoming GPS updates move the rider marker
// but do NOT hijack the user's panned camera center
final pannedCameraCenter = flutterMap.mapController!.camera.center;
telemetryController.add(
TelemetrySample(
timestamp: DateTime.now(),
speed: 50.0,
heading: 100.0,
latitude: 34.0600,
longitude: -118.2500,
gpsAccuracy: 5.0,
gpsAgeMs: 80,
),
);
await tester.pump(const Duration(milliseconds: 300));
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(pannedCameraCenter.latitude, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(pannedCameraCenter.longitude, 0.0001),
);
// Tap RECENTER button -> camera snaps back to latest rider position (34.0600, -118.2500)
await tester.tap(recenterFinder);
await tester.pump();
expect(recenterFinder, findsNothing);
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(34.0600, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(-118.2500, 0.0001),
);
// Rotate to landscape Touring mode and verify GPS follow continues cleanly
configureViewport(tester, 800, 360);
await tester.pump();
telemetryController.add(
TelemetrySample(
timestamp: DateTime.now(),
speed: 55.0,
heading: 110.0,
latitude: 34.0700,
longitude: -118.2600,
gpsAccuracy: 5.0,
gpsAgeMs: 80,
),
);
await tester.pump(const Duration(milliseconds: 300));
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(34.0700, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(-118.2600, 0.0001),
);
},
);
});
} }
class _TestRideStateMachine extends StateNotifier<RideSession> class _TestRideStateMachine extends StateNotifier<RideSession>

View File

@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/core/theme/theme_extensions.dart'; 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/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';
@ -385,4 +387,597 @@ void main() {
expect(find.text('OPEN FULL RIDE REPLAY'), findsOneWidget); expect(find.text('OPEN FULL RIDE REPLAY'), findsOneWidget);
}); });
}); });
group('BUG-003: Share Recent Session Read-Only Telemetry Integrity Tests', () {
test(
'SocialFeedService.shareRecentRide enforces authoritativeRide telemetry over forged input parameters',
() async {
final service = SocialFeedService();
addTearDown(service.dispose);
final recordedRide = Ride(
id: 77,
startTime: DateTime(2026, 10, 10, 8, 0),
endTime: DateTime(2026, 10, 10, 8, 42),
distance: 31.4,
distanceMiles: 31.4,
movingTime: 2520, // 42 mins
stoppedTime: 0,
topSpeed: 74.5,
topSpeedMph: 74.5,
averageSpeed: 44.8,
maxLeanLeft: 47.0,
maxLeanRight: 39.0,
maxAccelG: 0.55,
maxBrakingG: 0.62,
elevationGain: 300.0, // ~984 ft
elevationLoss: 100.0,
);
final published = await service.shareRecentRide(
authorUid: 'uid_1',
authorDisplayName: 'Test Rider',
authorHandle: 'test_rider',
locationName: 'Highway 9',
rideTitle: 'Morning Run',
bikeModel: 'Yamaha R1',
// Attempt to pass forged telemetry values alongside authoritativeRide
distanceMiles: 999.9,
durationSeconds: 99999,
maxLeanAngleDeg: 88.0,
maxLeanSide: 'RIGHT',
elevationFt: 50000,
authoritativeRide: recordedRide,
routePoints: const [
AppLatLng(37.2500, -122.1200),
AppLatLng(37.2900, -122.1600),
],
);
expect(published.rideId, 77);
expect(published.distanceMiles, closeTo(31.4, 0.001));
expect(published.durationSeconds, 2520);
expect(published.maxLeanAngleDeg, closeTo(47.0, 0.001));
expect(published.maxLeanSide, 'LEFT');
expect(published.elevationFt, (300.0 * 3.28084).round());
},
);
testWidgets(
'ShareSessionModal renders telemetry as read-only tiles and publishes authoritative Ride values',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 900));
final service = SocialFeedService();
addTearDown(service.dispose);
final recordedRide = Ride(
id: 42,
startTime: DateTime(2026, 10, 12, 9, 0),
endTime: DateTime(2026, 10, 12, 9, 42),
distance: 31.4,
distanceMiles: 31.4,
movingTime: 2520, // 42 mins
stoppedTime: 0,
topSpeed: 78.0,
topSpeedMph: 78.0,
averageSpeed: 45.2,
maxLeanLeft: 47.0,
maxLeanRight: 41.0,
maxAccelG: 0.5,
maxBrakingG: 0.6,
elevationGain: 450.0,
elevationLoss: 150.0,
motorcycleYear: 2024,
motorcycleMake: 'Aprilia',
motorcycleModel: 'RSV4 Factory',
);
await tester.pumpWidget(
_createTestApp(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => ShareSessionModal.show(
context,
initialRide: recordedRide,
),
child: const Text('Open Share Modal'),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open Share Modal'));
await tester.pumpAndSettle();
// Only 3 editable TextFormFields exist (Title, Motorcycle Model, Location)
// Telemetry fields (Distance, Time, Lean) are NOT editable TextFormFields
expect(find.byType(TextFormField), findsNWidgets(3));
// Verify read-only telemetry tiles display the authoritative Ride metrics
expect(
find.byKey(const ValueKey('share_session_readonly_distance')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('share_session_readonly_duration')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('share_session_readonly_lean')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('share_session_readonly_lean_side')),
findsOneWidget,
);
expect(find.text('31.4'), findsOneWidget);
expect(find.text('42'), findsOneWidget);
expect(find.text('47°'), findsOneWidget);
expect(find.text('LEFT'), findsOneWidget);
// Edit metadata (Title) and publish
await tester.enterText(
find.byType(TextFormField).first,
'Verified Highway 9 Dawn Patrol',
);
await tester.tap(find.text('PUBLISH TO COMMUNITY FEED'));
await tester.pumpAndSettle();
final feedItems = await service.getFeedStream().first;
final latest = feedItems.first;
expect(latest.rideTitle, 'Verified Highway 9 Dawn Patrol');
expect(latest.distanceMiles, closeTo(31.4, 0.001));
expect(latest.durationSeconds, 2520);
expect(latest.maxLeanAngleDeg, closeTo(47.0, 0.001));
expect(latest.maxLeanSide, 'LEFT');
},
);
});
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);
},
);
});
group('BUG-002: Social Feed Typography & Theme Contrast Tests', () {
double contrastRatio(Color fg, Color bg) {
final l1 = fg.computeLuminance();
final l2 = bg.computeLuminance();
final brightest = l1 > l2 ? l1 : l2;
final darkest = l1 > l2 ? l2 : l1;
return (brightest + 0.05) / (darkest + 0.05);
}
for (final isLight in [false, true]) {
testWidgets(
'SocialFeedScreen and FeedCommentsBottomSheet maintain WCAG AA (>= 4.5:1) contrast in ${isLight ? "Light" : "Dark"} mode',
(tester) async {
await tester.binding.setSurfaceSize(const Size(412, 915));
final themeColors = isLight
? MotorSignalThemeColors.light
: MotorSignalThemeColors.dark;
final baseTheme = isLight ? ThemeData.light() : ThemeData.dark();
final service = SocialFeedService();
addTearDown(service.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: MediaQuery(
data: const MediaQueryData(
size: Size(412, 915),
textScaler: TextScaler.linear(1.2),
),
child: MaterialApp(
theme: baseTheme.copyWith(
extensions: [themeColors],
),
home: const SocialFeedScreen(),
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(tester.takeException(), isNull);
// 1. AppBar title contrast against canvas surface
final appBarTitle = tester.widget<Text>(find.text('Community Feed'));
expect(
contrastRatio(appBarTitle.style!.color!, themeColors.surfaceCanvas),
greaterThanOrEqualTo(4.5),
);
// 2. Card primary text (@authorHandle and rideTitle) against card primary surface
final handleText = tester.widget<Text>(find.text('@ApexMarcus'));
expect(
contrastRatio(
handleText.style!.color!,
themeColors.surfaceCardPrimary,
),
greaterThanOrEqualTo(4.5),
);
final rideTitleText = tester.widget<Text>(
find.text('Sunday Skyline & Pescadero Carve'),
);
expect(
contrastRatio(
rideTitleText.style!.color!,
themeColors.surfaceCardPrimary,
),
greaterThanOrEqualTo(4.5),
);
// 3. Card secondary text (clubName) against card primary surface
final clubText = tester.widget<Text>(
find.text('Bay Area Twisties Club'),
);
expect(
contrastRatio(
clubText.style!.color!,
themeColors.surfaceCardPrimary,
),
greaterThanOrEqualTo(4.5),
);
// 4. Filter tabs (selected vs unselected) contrast
final selectedFilterText = tester.widget<Text>(
find.text('All Activity'),
);
final selectedFilterBg = isLight
? const Color(0xFF0047B3)
: const Color(0xFF00E5FF);
expect(
contrastRatio(selectedFilterText.style!.color!, selectedFilterBg),
greaterThanOrEqualTo(4.5),
);
final unselectedFilterText = tester.widget<Text>(
find.text('Club Only'),
);
expect(
contrastRatio(
unselectedFilterText.style!.color!,
themeColors.surfaceCardSecondary,
),
greaterThanOrEqualTo(4.5),
);
// 5. Telemetry metric labels and values contrast against metric cell surface
final distLabel = tester.widget<Text>(find.text('DISTANCE').first);
expect(
contrastRatio(
distLabel.style!.color!,
themeColors.surfaceCardPrimary,
),
greaterThanOrEqualTo(4.5),
);
final leanValue = tester.widget<Text>(find.text('51°').first);
expect(
contrastRatio(
leanValue.style!.color!,
themeColors.surfaceCardPrimary,
),
greaterThanOrEqualTo(4.5),
);
},
);
}
});
group('BUG-005: GPX Export Snackbar Horizontal Overflow Tests', () {
for (final viewport in const [
Size(320, 640),
Size(360, 800),
Size(800, 480),
]) {
testWidgets(
'GPX export snackbar renders long rideTitle without RenderFlex overflow at ${viewport.width.toInt()}x${viewport.height.toInt()} (1.5x text scale)',
(tester) async {
await tester.binding.setSurfaceSize(viewport);
final service = SocialFeedService();
addTearDown(service.dispose);
const longRideTitle =
'Ultra Long Sunday Skyline Boulevard & Pescadero Creek Canyon Telemetry Endurance Carve Session With Full Squad Pack';
final post = await service.shareRecentRide(
authorUid: 'uid_overflow_test',
authorDisplayName: 'Marcus Vance',
authorHandle: 'ApexMarcus',
locationName: 'Skyline Blvd',
rideTitle: longRideTitle,
bikeModel: '2024 Ducati Streetfighter V4',
distanceMiles: 68.4,
durationSeconds: 4440,
maxLeanAngleDeg: 51.0,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: MediaQuery(
data: MediaQueryData(
size: viewport,
textScaler: const TextScaler.linear(1.5),
),
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [MotorSignalThemeColors.dark],
),
home: const SocialFeedScreen(),
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
final gpxButton = find.byKey(
ValueKey('gpx_export_button_${post.id}'),
);
await tester.scrollUntilVisible(
gpxButton,
120.0,
scrollable: find.descendant(
of: find.byKey(const ValueKey('social_feed_list_view')),
matching: find.byType(Scrollable),
),
);
await tester.pumpAndSettle();
await tester.tap(gpxButton);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
// Must not throw any RenderFlex overflow exception
expect(tester.takeException(), isNull);
expect(
find.text('GPX track exported for $longRideTitle'),
findsOneWidget,
);
},
);
}
});
} }