fix(social): make shared session telemetry read-only and authoritative (#50)
This commit is contained in:
parent
1d702102fc
commit
07bd567cdb
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue