From 07bd567cdb632d28acc0e820fd8646d36789c696 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 16 Oct 2026 23:01:28 -0400 Subject: [PATCH] fix(social): make shared session telemetry read-only and authoritative (#50) --- .../feed/widgets/share_session_modal.dart | 493 +++++++++++------- lib/services/social/social_feed_models.dart | 158 +++++- lib/services/social/social_feed_service.dart | 84 ++- test/community_social_feed_test.dart | 152 ++++++ 4 files changed, 662 insertions(+), 225 deletions(-) diff --git a/lib/features/social/feed/widgets/share_session_modal.dart b/lib/features/social/feed/widgets/share_session_modal.dart index 2852482..b2da446 100644 --- a/lib/features/social/feed/widgets/share_session_modal.dart +++ b/lib/features/social/feed/widgets/share_session_modal.dart @@ -15,14 +15,28 @@ import '../../../garage/garage_providers.dart'; /// Modal dialog / bottom sheet allowing riders to share their recent ride /// or canyon session to the Community Social Feed. class ShareSessionModal extends ConsumerStatefulWidget { - const ShareSessionModal({super.key}); + final Ride? initialRide; + final List? initialTelemetrySamples; - static Future show(BuildContext context) { + const ShareSessionModal({ + super.key, + this.initialRide, + this.initialTelemetrySamples, + }); + + static Future show( + BuildContext context, { + Ride? initialRide, + List? initialTelemetrySamples, + }) { return showModalBottomSheet( context: context, isScrollControlled: true, 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 { final _formKey = GlobalKey(); 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 _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; bool _isSubmitting = false; List _recentRides = []; Ride? _selectedRide; - List _selectedRoutePoints = const [ - AppLatLng(37.3861, -122.2641), - AppLatLng(37.3500, -122.2300), - AppLatLng(37.3100, -122.2000), - AppLatLng(37.2800, -122.2200), - ]; + List _selectedRoutePoints = const []; + List _selectedTelemetrySamples = const []; + + double get _authoritativeDistanceMiles { + 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 void initState() { super.initState(); + if (widget.initialRide != null) { + _recentRides = [widget.initialRide!]; + _applySelectedRideMetadata(widget.initialRide!); + if (widget.initialTelemetrySamples != null) { + _extractSamples(widget.initialTelemetrySamples!); + } + } WidgetsBinding.instance.addPostFrameCallback((_) async { final defaultBike = ref.read(defaultMotorcycleProvider); - if (defaultBike != null) { - _bikeController.text = - '${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'.trim(); - } else { - _bikeController.text = '2024 Ducati Streetfighter V4'; + if (_bikeController.text.isEmpty) { + if (defaultBike != null) { + _bikeController.text = + '${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}' + .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 { try { final db = ref.read(databaseProvider); 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)) .get(); if (mounted) { setState(() { _recentRides = rides; - if (rides.isNotEmpty) { - _selectRide(rides.first); - } }); + if (rides.isNotEmpty) { + await _selectRide(rides.first); + } } } catch (_) { // Ignore database retrieval errors on rides } } - Future _selectRide(Ride ride) async { + void _applySelectedRideMetadata(Ride ride) { _selectedRide = ride; - final dist = ride.distanceMiles > 0 ? ride.distanceMiles : ride.distance; - final durMins = ride.movingTime > 0 - ? (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; + _titleController.text = + 'Ride #${ride.id} • ${ride.startTime.month}/${ride.startTime.day} Ride'; if (ride.motorcycleMake != null && ride.motorcycleMake!.isNotEmpty) { _bikeController.text = - '${ride.motorcycleYear ?? ''} ${ride.motorcycleMake} ${ride.motorcycleModel ?? ''}'.trim(); + '${ride.motorcycleYear ?? ''} ${ride.motorcycleMake} ${ride.motorcycleModel ?? ''}' + .trim(); + } + } + + void _extractSamples(List 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 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 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 _loadSamplesForRide(Ride ride) async { try { final db = ref.read(databaseProvider); final samples = await (db.select(db.telemetrySamples) ..where((t) => t.rideId.equals(ride.id)) ..orderBy([(t) => drift.OrderingTerm(expression: t.id)])) .get(); - - final validSamples = samples.where((s) => s.latitude != null && s.longitude != null).toList(); - if (validSamples.isNotEmpty) { - 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; - }); - } + if (mounted && _selectedRide?.id == ride.id) { + setState(() { + _extractSamples(samples); + }); } } catch (_) {} - if (mounted) setState(() {}); + } + + Future _selectRide(Ride ride) async { + setState(() { + _applySelectedRideMetadata(ride); + _selectedRoutePoints = const []; + _selectedTelemetrySamples = const []; + }); + await _loadSamplesForRide(ride); } @override @@ -138,9 +258,6 @@ class _ShareSessionModalState extends ConsumerState { _titleController.dispose(); _locationController.dispose(); _bikeController.dispose(); - _distanceController.dispose(); - _durationController.dispose(); - _maxLeanController.dispose(); super.dispose(); } @@ -155,9 +272,7 @@ class _ShareSessionModalState extends ConsumerState { final authorDisplayName = user?.displayName ?? 'Apex Rider'; final authorHandle = user?.email?.split('@').first ?? 'apex_rider'; - final dist = double.tryParse(_distanceController.text) ?? 35.0; - final durMins = int.tryParse(_durationController.text) ?? 45; - final lean = double.tryParse(_maxLeanController.text) ?? 45.0; + final ride = _selectedRide; await ref.read(socialFeedServiceProvider).shareRecentRide( authorUid: authorUid, @@ -174,16 +289,19 @@ class _ShareSessionModalState extends ConsumerState { bikeModel: _bikeController.text.trim().isNotEmpty ? _bikeController.text.trim() : '2024 Ducati Streetfighter V4', - distanceMiles: dist, - durationSeconds: durMins * 60, - maxLeanAngleDeg: lean, - maxLeanSide: _selectedLeanSide, - paceFactor: _selectedPace, + distanceMiles: _authoritativeDistanceMiles, + durationSeconds: _authoritativeDurationSeconds, + maxLeanAngleDeg: _authoritativeMaxLeanDeg, + maxLeanSide: _authoritativeLeanSide, + paceFactor: _authoritativePaceFactor, hazardCount: 0, - elevationFt: 1840, + elevationFt: _authoritativeElevationFt, discipline: _selectedDiscipline, snapshotType: FeedSnapshotType.routeTrack, routePoints: _selectedRoutePoints, + telemetrySamples: _selectedTelemetrySamples, + authoritativeRide: ride, + rideId: ride?.id, ); // Reset filter to All Activity so published session is immediately displayed @@ -471,158 +589,82 @@ class _ShareSessionModalState extends ConsumerState { ), const SizedBox(height: 14), - // Telemetry Metrics Grid (Distance, Duration, Lean) + // Read-Only Telemetry Metrics Grid (Distance, Duration, Lean, Side, Pace) Row( children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _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 Icon( + Icons.lock_outline_rounded, + size: 13, + color: AppColors.electricCyan, ), - const SizedBox(width: 10), + const SizedBox(width: 6), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFieldLabel('TIME (MINS)', colors), - TextFormField( - controller: _durationController, - keyboardType: TextInputType.number, - style: const TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 14, - color: Colors.white, - ), - 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°'), - ), - ], + child: Text( + _selectedRide != null + ? 'RECORDED TELEMETRY (READ-ONLY)' + : 'RECORDED TELEMETRY (NO ACTIVE RIDE SELECTED)', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 10.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.7, + color: AppColors.electricCyan, + ), ), ), ], ), - const SizedBox(height: 14), - - // Lean Side & Pace Segment Selection + const SizedBox(height: 8), Row( children: [ Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFieldLabel('LEAN APEX SIDE', colors), - Container( - 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( - 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); - } - }, - ), - ), - ), - ], + child: _buildReadOnlyTelemetryTile( + key: const ValueKey('share_session_readonly_distance'), + label: 'DIST (MI)', + value: _authoritativeDistanceMiles.toStringAsFixed(1), + colors: colors, ), ), const SizedBox(width: 10), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFieldLabel('PACE FACTOR', colors), - Container( - 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( - value: _selectedPace, - isExpanded: true, - dropdownColor: const Color(0xFF181C21), - style: const TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 12, - color: Colors.white, - ), - items: const [ - DropdownMenuItem( - value: '1.0x Cruiser', - child: Text('1.0x Cruiser')), - DropdownMenuItem( - value: '1.25x Spirited', - child: Text('1.25x Spirited')), - DropdownMenuItem( - value: '1.45x Fast Pace', - child: Text('1.45x Fast Pace')), - ], - onChanged: (val) { - if (val != null) { - setState(() => _selectedPace = val); - } - }, - ), - ), - ), - ], + child: _buildReadOnlyTelemetryTile( + key: const ValueKey('share_session_readonly_duration'), + label: 'TIME (MINS)', + value: _authoritativeDurationMinutes.toString(), + colors: colors, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildReadOnlyTelemetryTile( + key: const ValueKey('share_session_readonly_lean'), + label: 'LEAN (DEG)', + value: '${_authoritativeMaxLeanDeg.toStringAsFixed(0)}°', + colors: colors, + highlight: true, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _buildReadOnlyTelemetryTile( + key: const ValueKey('share_session_readonly_lean_side'), + label: 'LEAN APEX SIDE', + value: _authoritativeLeanSide, + colors: colors, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildReadOnlyTelemetryTile( + key: const ValueKey('share_session_readonly_pace'), + label: 'PACE FACTOR', + value: _authoritativePaceFactor, + colors: colors, ), ), ], @@ -687,6 +729,53 @@ class _ShareSessionModalState extends ConsumerState { ); } + 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) { return Padding( padding: const EdgeInsets.only(bottom: 6), diff --git a/lib/services/social/social_feed_models.dart b/lib/services/social/social_feed_models.dart index 789741d..ddb0911 100644 --- a/lib/services/social/social_feed_models.dart +++ b/lib/services/social/social_feed_models.dart @@ -134,10 +134,55 @@ enum FeedSnapshotType { 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 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 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). @immutable class SocialFeedItem { final String id; + final int? rideId; final String authorUid; final String authorDisplayName; final String authorHandle; @@ -153,7 +198,7 @@ class SocialFeedItem { final double distanceMiles; final int durationSeconds; 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 int hazardCount; // e.g. 0 final int elevationFt; // e.g. +2140 @@ -163,9 +208,10 @@ class SocialFeedItem { final String? regionName; final bool isFeatured; - // Visual Snapshot Module + // Visual Snapshot & Recorded Telemetry Module final FeedSnapshotType snapshotType; final List routePoints; + final List telemetrySamples; final String? photoUrl; final String? photoCaption; final bool isGpsVerified; @@ -178,6 +224,7 @@ class SocialFeedItem { const SocialFeedItem({ required this.id, + this.rideId, required this.authorUid, required this.authorDisplayName, required this.authorHandle, @@ -194,21 +241,79 @@ class SocialFeedItem { this.maxLeanSide = 'RIGHT', this.paceFactor = '1.2x Spirited', this.hazardCount = 0, - this.elevationFt = 1200, + this.elevationFt = 0, this.discipline = RidingDiscipline.canyonSport, this.regionName, this.isFeatured = false, this.snapshotType = FeedSnapshotType.routeTrack, this.routePoints = const [], + this.telemetrySamples = const [], this.photoUrl, this.photoCaption, - this.isGpsVerified = true, + this.isGpsVerified = false, this.isDemo = false, this.fistBumpCount = 0, this.fistBumpUserIds = 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 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 along the track. + final first = validPoints.first; + final hasDistinctPoint = validPoints.skip(1).any( + (p) => + (p.latitude - first.latitude).abs() + + (p.longitude - first.longitude).abs() > + 0.00001, + ); + 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 contains recorded route coordinates that can be plotted. + bool get hasRecordedRoute => + 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) { if (uid == null) return false; return fistBumpUserIds.contains(uid); @@ -216,6 +321,7 @@ class SocialFeedItem { SocialFeedItem copyWith({ String? id, + int? rideId, String? authorUid, String? authorDisplayName, String? authorHandle, @@ -238,6 +344,7 @@ class SocialFeedItem { bool? isFeatured, FeedSnapshotType? snapshotType, List? routePoints, + List? telemetrySamples, String? photoUrl, String? photoCaption, bool? isGpsVerified, @@ -248,6 +355,7 @@ class SocialFeedItem { }) { return SocialFeedItem( id: id ?? this.id, + rideId: rideId ?? this.rideId, authorUid: authorUid ?? this.authorUid, authorDisplayName: authorDisplayName ?? this.authorDisplayName, authorHandle: authorHandle ?? this.authorHandle, @@ -270,6 +378,7 @@ class SocialFeedItem { isFeatured: isFeatured ?? this.isFeatured, snapshotType: snapshotType ?? this.snapshotType, routePoints: routePoints ?? this.routePoints, + telemetrySamples: telemetrySamples ?? this.telemetrySamples, photoUrl: photoUrl ?? this.photoUrl, photoCaption: photoCaption ?? this.photoCaption, isGpsVerified: isGpsVerified ?? this.isGpsVerified, @@ -282,6 +391,7 @@ class SocialFeedItem { Map toJson() => { 'id': id, + if (rideId != null) 'rideId': rideId, 'authorUid': authorUid, 'authorDisplayName': authorDisplayName, 'authorHandle': authorHandle, @@ -303,10 +413,14 @@ class SocialFeedItem { if (regionName != null) 'regionName': regionName, 'isFeatured': isFeatured, '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 (photoCaption != null) 'photoCaption': photoCaption, - 'isGpsVerified': isGpsVerified, + 'isGpsVerified': hasVerifiedGps, 'isDemo': isDemo, 'fistBumpCount': fistBumpCount, 'fistBumpUserIds': fistBumpUserIds, @@ -326,14 +440,33 @@ class SocialFeedItem { } } + final telemetryList = json['telemetrySamples'] as List? ?? []; + final samples = telemetryList + .whereType() + .map((s) => FeedTelemetrySample.fromJson(Map.from(s))) + .toList(); + final commentList = json['comments'] as List? ?? []; final comments = commentList .whereType>() .map((c) => FeedPostComment.fromJson(c)) .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( id: json['id'] as String? ?? '', + rideId: (json['rideId'] as num?)?.toInt(), authorUid: json['authorUid'] as String? ?? '', authorDisplayName: json['authorDisplayName'] as String? ?? 'Rider', authorHandle: json['authorHandle'] as String? ?? 'rider', @@ -344,8 +477,8 @@ class SocialFeedItem { timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(), rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve', bikeModel: json['bikeModel'] as String? ?? 'Motorcycle', - distanceMiles: (json['distanceMiles'] as num?)?.toDouble() ?? 0.0, - durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0, + distanceMiles: distanceMiles, + durationSeconds: durationSeconds, maxLeanAngleDeg: (json['maxLeanAngleDeg'] as num?)?.toDouble() ?? 0.0, maxLeanSide: json['maxLeanSide'] as String? ?? 'RIGHT', paceFactor: json['paceFactor'] as String? ?? '1.0x', @@ -363,13 +496,16 @@ class SocialFeedItem { ? FeedSnapshotType.scenicPhoto : FeedSnapshotType.routeTrack, routePoints: points, + telemetrySamples: samples, photoUrl: json['photoUrl'] as String?, photoCaption: json['photoCaption'] as String?, - isGpsVerified: json['isGpsVerified'] as bool? ?? true, - isDemo: json['isDemo'] as bool? ?? false, + isGpsVerified: verified, + isDemo: isDemo, fistBumpCount: (json['fistBumpCount'] as num?)?.toInt() ?? 0, - fistBumpUserIds: (json['fistBumpUserIds'] as List?)?.cast() ?? [], + fistBumpUserIds: + (json['fistBumpUserIds'] as List?)?.cast() ?? [], comments: comments, ); } } + diff --git a/lib/services/social/social_feed_service.dart b/lib/services/social/social_feed_service.dart index 3ed6294..df59e8c 100644 --- a/lib/services/social/social_feed_service.dart +++ b/lib/services/social/social_feed_service.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:cloud_firestore/cloud_firestore.dart'; +import '../../database/database.dart'; import '../map/map_provider_interface.dart'; import 'social_feed_models.dart'; @@ -363,7 +365,7 @@ class SocialFeedService { result = result.where((i) => i.distanceMiles >= minDistanceMiles).toList(); } if (verifiedGpsOnly == true) { - result = result.where((i) => i.isGpsVerified).toList(); + result = result.where((i) => i.hasVerifiedGps).toList(); } return result; @@ -433,6 +435,11 @@ class SocialFeedService { } /// 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 shareRecentRide({ required String authorUid, required String authorDisplayName, @@ -443,21 +450,71 @@ class SocialFeedService { required String locationName, required String rideTitle, required String bikeModel, - required double distanceMiles, - required int durationSeconds, - required double maxLeanAngleDeg, + double distanceMiles = 0.0, + int durationSeconds = 0, + double maxLeanAngleDeg = 0.0, String maxLeanSide = 'RIGHT', - String paceFactor = '1.2x Spirited', + String paceFactor = '1.0x Cruiser', int hazardCount = 0, - int elevationFt = 1500, + int elevationFt = 0, RidingDiscipline discipline = RidingDiscipline.canyonSport, String? regionName, bool isFeatured = false, FeedSnapshotType snapshotType = FeedSnapshotType.routeTrack, List routePoints = const [], + List telemetrySamples = const [], + Ride? authoritativeRide, + int? rideId, + bool? isGpsVerified, }) 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( id: 'feed_post_${DateTime.now().millisecondsSinceEpoch}', + rideId: resolvedRideId, authorUid: authorUid, authorDisplayName: authorDisplayName, authorHandle: authorHandle, @@ -468,19 +525,21 @@ class SocialFeedService { timestamp: DateTime.now(), rideTitle: rideTitle, bikeModel: bikeModel, - distanceMiles: distanceMiles, - durationSeconds: durationSeconds, - maxLeanAngleDeg: maxLeanAngleDeg, - maxLeanSide: maxLeanSide, + distanceMiles: resolvedDistance, + durationSeconds: resolvedDurationSeconds, + maxLeanAngleDeg: resolvedMaxLean, + maxLeanSide: resolvedLeanSide, paceFactor: paceFactor, hazardCount: hazardCount, - elevationFt: elevationFt, + elevationFt: resolvedElevationFt, discipline: discipline, regionName: regionName, isFeatured: isFeatured, snapshotType: snapshotType, routePoints: routePoints, - isGpsVerified: true, + telemetrySamples: telemetrySamples, + isGpsVerified: effectiveGpsVerified, + isDemo: false, fistBumpCount: 1, fistBumpUserIds: [authorUid], comments: const [], @@ -504,3 +563,4 @@ class SocialFeedService { _localFeedController.close(); } } + diff --git a/test/community_social_feed_test.dart b/test/community_social_feed_test.dart index 323c6f7..0b1fec0 100644 --- a/test/community_social_feed_test.dart +++ b/test/community_social_feed_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.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/widgets/feed_comments_bottom_sheet.dart'; import 'package:motorcycle_dash/features/social/feed/widgets/share_session_modal.dart'; @@ -385,4 +386,155 @@ void main() { 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'); + }, + ); + }); }