fix(social): make shared session telemetry read-only and authoritative (#50)

This commit is contained in:
Developer 2026-10-16 23:01:28 -04:00
parent 1d702102fc
commit 07bd567cdb
4 changed files with 662 additions and 225 deletions

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 (_bikeController.text.isEmpty) {
if (defaultBike != null) { if (defaultBike != null) {
_bikeController.text = _bikeController.text =
'${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'.trim(); '${defaultBike.year} ${defaultBike.makeName} ${defaultBike.modelName}'
.trim();
} else { } else {
_bikeController.text = '2024 Ducati Streetfighter V4'; _bikeController.text = '2024 Ducati Streetfighter V4';
} }
}
if (widget.initialRide != null) {
if (widget.initialTelemetrySamples == null) {
await _loadSamplesForRide(widget.initialRide!);
}
} else {
await _loadRecentRides(); 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();
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(() { setState(() {
_selectedRoutePoints = downsampled; _extractSamples(samples);
}); });
} }
}
} 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
@ -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: [
_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),
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, color: AppColors.electricCyan,
), ),
decoration: _buildInputDecoration(hint: '0°'), const SizedBox(width: 6),
Expanded(
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), 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'),
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: [ children: [
_buildFieldLabel('PACE FACTOR', colors), Expanded(
Container( child: _buildReadOnlyTelemetryTile(
padding: const EdgeInsets.symmetric(horizontal: 12), key: const ValueKey('share_session_readonly_lean_side'),
decoration: BoxDecoration( label: 'LEAN APEX SIDE',
color: const Color(0xFF181C21), value: _authoritativeLeanSide,
borderRadius: BorderRadius.circular(10), colors: colors,
border: Border.all(color: const Color(0xFF272A30)),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
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);
}
},
), ),
), ),
), 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<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

@ -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,79 @@ 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 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) { bool isFistBumpedBy(String? uid) {
if (uid == null) return false; if (uid == null) return false;
return fistBumpUserIds.contains(uid); return fistBumpUserIds.contains(uid);
@ -216,6 +321,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 +344,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 +355,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 +378,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 +391,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 +413,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 +440,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 +477,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 +496,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

@ -2,6 +2,7 @@ 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/share_session_modal.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); 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');
},
);
});
} }