MotorSignal-App/lib/services/social/social_feed_models.dart

648 lines
21 KiB
Dart

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import '../map/map_provider_interface.dart';
DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
if (value is Timestamp) return value.toDate();
if (value is DateTime) return value;
if (value is String) return DateTime.tryParse(value);
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
return null;
}
dynamic _dateTimeToFirestore(DateTime? dt) {
if (dt == null) return null;
return Timestamp.fromDate(dt);
}
/// Filter for the community social feed stream.
enum FeedFilter {
allActivity,
forYou,
nearbyPacks,
clubOnly,
following;
String get displayName {
switch (this) {
case FeedFilter.allActivity:
return 'All Activity';
case FeedFilter.forYou:
return 'For You';
case FeedFilter.nearbyPacks:
return 'Nearby Packs';
case FeedFilter.clubOnly:
return 'Club Only';
case FeedFilter.following:
return 'Following';
}
}
}
/// Motorcycle riding discipline / style categorization.
enum RidingDiscipline {
all,
canyonSport,
advDualSport,
touring,
trackDay,
cruiser;
String get displayName {
switch (this) {
case RidingDiscipline.all:
return 'All Styles';
case RidingDiscipline.canyonSport:
return '⚡ Canyon / Sport';
case RidingDiscipline.advDualSport:
return '🌲 ADV & Dirt';
case RidingDiscipline.touring:
return '🛣️ Touring';
case RidingDiscipline.trackDay:
return '🏁 Track Day';
case RidingDiscipline.cruiser:
return '🏍️ Cruiser';
}
}
String get shortTag {
switch (this) {
case RidingDiscipline.all:
return 'ALL';
case RidingDiscipline.canyonSport:
return 'CANYON';
case RidingDiscipline.advDualSport:
return 'ADV';
case RidingDiscipline.touring:
return 'TOURING';
case RidingDiscipline.trackDay:
return 'TRACK';
case RidingDiscipline.cruiser:
return 'CRUISE';
}
}
}
/// A comment on a community social feed post.
@immutable
class FeedPostComment {
final String id;
final String authorUid;
final String authorDisplayName;
final String? authorHandle;
final String? authorAvatarPreset;
final String content;
final DateTime createdAt;
const FeedPostComment({
required this.id,
required this.authorUid,
required this.authorDisplayName,
this.authorHandle,
this.authorAvatarPreset,
required this.content,
required this.createdAt,
});
Map<String, dynamic> toJson() => {
'id': id,
'authorUid': authorUid,
'authorDisplayName': authorDisplayName,
if (authorHandle != null) 'authorHandle': authorHandle,
if (authorAvatarPreset != null) 'authorAvatarPreset': authorAvatarPreset,
'content': content,
'createdAt': _dateTimeToFirestore(createdAt),
};
factory FeedPostComment.fromJson(Map<String, dynamic> json) {
return FeedPostComment(
id: json['id'] as String? ?? '',
authorUid: json['authorUid'] as String? ?? '',
authorDisplayName: json['authorDisplayName'] as String? ?? 'Rider',
authorHandle: json['authorHandle'] as String?,
authorAvatarPreset: json['authorAvatarPreset'] as String?,
content: json['content'] as String? ?? '',
createdAt: _parseDateTime(json['createdAt']) ?? DateTime.now(),
);
}
}
/// Visual snapshot type for a feed item.
enum FeedSnapshotType {
routeTrack,
scenicPhoto,
}
/// A recorded telemetry sample point attached to a shared social feed session.
@immutable
class FeedTelemetrySample {
/// Matches RideStateMachine._processStats 3.0 MPH moving gate and 65.0°
/// physical bound so stationary kickstand/dismount frames never enter
/// published lean traces or apex calculations.
static const double movingLeanGateSpeedMph = 3.0;
static const double maxPhysicalLeanDeg = 65.0;
static double applyMovingLeanGate({
required double? speedMph,
required double? leanAngleDeg,
}) {
if (speedMph == null ||
!speedMph.isFinite ||
speedMph < movingLeanGateSpeedMph ||
leanAngleDeg == null ||
!leanAngleDeg.isFinite ||
leanAngleDeg.abs() > maxPhysicalLeanDeg) {
return 0.0;
}
return leanAngleDeg;
}
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;
final DateTime? timestamp;
const FeedTelemetrySample({
required this.progress,
required this.speedMph,
required this.leanAngleDeg,
this.lateralG = 0.0,
this.altitudeFt = 0.0,
this.latitude,
this.longitude,
this.timestamp,
});
FeedTelemetrySample withMovingLeanGate() {
final gatedLean = applyMovingLeanGate(
speedMph: speedMph,
leanAngleDeg: leanAngleDeg,
);
if (gatedLean == leanAngleDeg) return this;
return FeedTelemetrySample(
progress: progress,
speedMph: speedMph,
leanAngleDeg: gatedLean,
lateralG: lateralG,
altitudeFt: altitudeFt,
latitude: latitude,
longitude: longitude,
timestamp: timestamp,
);
}
Map<String, dynamic> toJson() => {
'progress': progress,
'speedMph': speedMph,
'leanAngleDeg': leanAngleDeg,
'lateralG': lateralG,
'altitudeFt': altitudeFt,
if (latitude != null) 'lat': latitude,
if (longitude != null) 'lng': longitude,
if (timestamp != null) 'timestamp': _dateTimeToFirestore(timestamp),
};
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(),
timestamp: _parseDateTime(json['timestamp']),
);
}
}
/// A community social feed item (Stitch SOC-01).
@immutable
class SocialFeedItem {
/// Minimum geodesic displacement required for GPS verification: 0.05 miles (~80.47 meters).
static const double minimumVerifiedDisplacementMiles = 0.05;
static const double minimumVerifiedDisplacementMeters =
minimumVerifiedDisplacementMiles * 1609.344;
final String id;
final int? rideId;
final String authorUid;
final String authorDisplayName;
final String authorHandle;
final String? authorAvatarPreset;
final String? authorBadge; // e.g. 'PRO', 'ROAD CAPTAIN', 'FOUNDER'
final String? clubName; // e.g. 'Bay Area Twisties Club'
final String locationName; // e.g. "Alice's Restaurant", "Mulholland Hwy"
final DateTime timestamp;
final String rideTitle; // e.g. "Sunday Skyline & Pescadero Carve"
final String bikeModel; // e.g. "2024 Ducati Streetfighter V4"
// Inset Telemetry Metrics
final double distanceMiles;
final int durationSeconds;
final double maxLeanAngleDeg;
final String maxLeanSide; // 'RIGHT', 'LEFT', 'PEAK', 'NONE'
final String paceFactor; // e.g. '1.32x Spirited'
final int hazardCount; // e.g. 0
final int elevationFt; // e.g. +2140
// Quality & Discipline Scoping
final RidingDiscipline discipline;
final String? regionName;
final bool isFeatured;
// Visual Snapshot & Recorded Telemetry Module
final FeedSnapshotType snapshotType;
final List<AppLatLng> routePoints;
final List<FeedTelemetrySample> telemetrySamples;
final String? photoUrl;
final String? photoCaption;
final bool isGpsVerified;
final bool isDemo;
// Kudos & Reactions
final int fistBumpCount;
final List<String> fistBumpUserIds;
final List<FeedPostComment> comments;
const SocialFeedItem({
required this.id,
this.rideId,
required this.authorUid,
required this.authorDisplayName,
required this.authorHandle,
this.authorAvatarPreset,
this.authorBadge,
this.clubName,
required this.locationName,
required this.timestamp,
required this.rideTitle,
required this.bikeModel,
required this.distanceMiles,
required this.durationSeconds,
required this.maxLeanAngleDeg,
this.maxLeanSide = 'RIGHT',
this.paceFactor = '1.2x Spirited',
this.hazardCount = 0,
this.elevationFt = 0,
this.discipline = RidingDiscipline.canyonSport,
this.regionName,
this.isFeatured = false,
this.snapshotType = FeedSnapshotType.routeTrack,
this.routePoints = const [],
this.telemetrySamples = const [],
this.photoUrl,
this.photoCaption,
this.isGpsVerified = false,
this.isDemo = false,
this.fistBumpCount = 0,
this.fistBumpUserIds = const [],
this.comments = const [],
});
/// Evaluates whether a set of ride metrics, GPS route coordinates, and
/// recorded telemetry samples satisfy authentic GPS verification criteria.
static bool verifyGpsCriteria({
required bool isDemo,
required double distanceMiles,
required int durationSeconds,
required List<AppLatLng> routePoints,
List<FeedTelemetrySample> telemetrySamples = const [],
}) {
if (isDemo) return false;
if (!distanceMiles.isFinite || distanceMiles <= 0.0) return false;
if (durationSeconds <= 0) return false;
if (telemetrySamples.length < 2) return false;
double? firstProgress;
double? prevProgress;
DateTime? firstTimestamp;
DateTime? prevTimestamp;
int timestampCount = 0;
final validSamplePoints = <AppLatLng>[];
for (final sample in telemetrySamples) {
if (!sample.progress.isFinite ||
sample.progress < 0.0 ||
sample.progress > 1.0 ||
!sample.speedMph.isFinite ||
sample.speedMph < 0.0 ||
!sample.leanAngleDeg.isFinite ||
!sample.lateralG.isFinite ||
!sample.altitudeFt.isFinite) {
return false;
}
if (prevProgress != null && sample.progress < prevProgress) {
return false;
}
firstProgress ??= sample.progress;
prevProgress = sample.progress;
if (sample.timestamp != null) {
if (prevTimestamp != null &&
sample.timestamp!.isBefore(prevTimestamp)) {
return false;
}
firstTimestamp ??= sample.timestamp;
prevTimestamp = sample.timestamp;
timestampCount++;
}
final lat = sample.latitude;
final lng = sample.longitude;
if (lat != null &&
lng != null &&
lat.isFinite &&
lng.isFinite &&
lat.abs() <= 90.0 &&
lng.abs() <= 180.0 &&
(lat != 0.0 || lng != 0.0)) {
validSamplePoints.add(AppLatLng(lat, lng));
}
}
// Require a positive recorded span across telemetry samples.
if (firstProgress == null ||
prevProgress == null ||
(prevProgress - firstProgress) <= 0.0) {
return false;
}
if (timestampCount > 0) {
if (timestampCount < 2 ||
firstTimestamp == null ||
prevTimestamp == null ||
prevTimestamp.difference(firstTimestamp).inMilliseconds <= 0) {
return false;
}
}
if (!_hasMinimumGeodesicDisplacement(validSamplePoints)) {
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();
return _hasMinimumGeodesicDisplacement(validPoints);
}
/// Require at least two coordinates separated by >= 0.05 miles (~80.47 m)
/// measured geodesically via [Geolocator.distanceBetween].
static bool _hasMinimumGeodesicDisplacement(List<AppLatLng> points) {
if (points.length < 2) return false;
for (int i = 0; i < points.length - 1; i++) {
final origin = points[i];
for (int j = i + 1; j < points.length; j++) {
final target = points[j];
final distMeters = Geolocator.distanceBetween(
origin.latitude,
origin.longitude,
target.latitude,
target.longitude,
);
if (distMeters.isFinite &&
distMeters >= minimumVerifiedDisplacementMeters) {
return true;
}
}
}
return false;
}
/// True only when this post is flagged as GPS-verified AND backed by valid,
/// non-demo GPS route coordinates, recorded telemetry samples, positive
/// distance, and positive duration.
bool get hasVerifiedGps =>
isGpsVerified &&
verifyGpsCriteria(
isDemo: isDemo,
distanceMiles: distanceMiles,
durationSeconds: durationSeconds,
routePoints: routePoints,
telemetrySamples: telemetrySamples,
);
/// True when the post is a non-demo ride containing recorded route coordinates that can be plotted.
bool get hasRecordedRoute =>
!isDemo &&
routePoints
.where((p) =>
p.latitude.isFinite &&
p.longitude.isFinite &&
(p.latitude != 0.0 || p.longitude != 0.0))
.length >=
2;
/// True when the post contains recorded telemetry waveform samples.
bool get hasRecordedTelemetry => !isDemo && telemetrySamples.length >= 2;
bool isFistBumpedBy(String? uid) {
if (uid == null) return false;
return fistBumpUserIds.contains(uid);
}
SocialFeedItem copyWith({
String? id,
int? rideId,
String? authorUid,
String? authorDisplayName,
String? authorHandle,
String? authorAvatarPreset,
String? authorBadge,
String? clubName,
String? locationName,
DateTime? timestamp,
String? rideTitle,
String? bikeModel,
double? distanceMiles,
int? durationSeconds,
double? maxLeanAngleDeg,
String? maxLeanSide,
String? paceFactor,
int? hazardCount,
int? elevationFt,
RidingDiscipline? discipline,
String? regionName,
bool? isFeatured,
FeedSnapshotType? snapshotType,
List<AppLatLng>? routePoints,
List<FeedTelemetrySample>? telemetrySamples,
String? photoUrl,
String? photoCaption,
bool? isGpsVerified,
bool? isDemo,
int? fistBumpCount,
List<String>? fistBumpUserIds,
List<FeedPostComment>? comments,
}) {
return SocialFeedItem(
id: id ?? this.id,
rideId: rideId ?? this.rideId,
authorUid: authorUid ?? this.authorUid,
authorDisplayName: authorDisplayName ?? this.authorDisplayName,
authorHandle: authorHandle ?? this.authorHandle,
authorAvatarPreset: authorAvatarPreset ?? this.authorAvatarPreset,
authorBadge: authorBadge ?? this.authorBadge,
clubName: clubName ?? this.clubName,
locationName: locationName ?? this.locationName,
timestamp: timestamp ?? this.timestamp,
rideTitle: rideTitle ?? this.rideTitle,
bikeModel: bikeModel ?? this.bikeModel,
distanceMiles: distanceMiles ?? this.distanceMiles,
durationSeconds: durationSeconds ?? this.durationSeconds,
maxLeanAngleDeg: maxLeanAngleDeg ?? this.maxLeanAngleDeg,
maxLeanSide: maxLeanSide ?? this.maxLeanSide,
paceFactor: paceFactor ?? this.paceFactor,
hazardCount: hazardCount ?? this.hazardCount,
elevationFt: elevationFt ?? this.elevationFt,
discipline: discipline ?? this.discipline,
regionName: regionName ?? this.regionName,
isFeatured: isFeatured ?? this.isFeatured,
snapshotType: snapshotType ?? this.snapshotType,
routePoints: routePoints ?? this.routePoints,
telemetrySamples: telemetrySamples ?? this.telemetrySamples,
photoUrl: photoUrl ?? this.photoUrl,
photoCaption: photoCaption ?? this.photoCaption,
isGpsVerified: isGpsVerified ?? this.isGpsVerified,
isDemo: isDemo ?? this.isDemo,
fistBumpCount: fistBumpCount ?? this.fistBumpCount,
fistBumpUserIds: fistBumpUserIds ?? this.fistBumpUserIds,
comments: comments ?? this.comments,
);
}
Map<String, dynamic> toJson() => {
'id': id,
if (rideId != null) 'rideId': rideId,
'authorUid': authorUid,
'authorDisplayName': authorDisplayName,
'authorHandle': authorHandle,
if (authorAvatarPreset != null) 'authorAvatarPreset': authorAvatarPreset,
if (authorBadge != null) 'authorBadge': authorBadge,
if (clubName != null) 'clubName': clubName,
'locationName': locationName,
'timestamp': _dateTimeToFirestore(timestamp),
'rideTitle': rideTitle,
'bikeModel': bikeModel,
'distanceMiles': distanceMiles,
'durationSeconds': durationSeconds,
'maxLeanAngleDeg': maxLeanAngleDeg,
'maxLeanSide': maxLeanSide,
'paceFactor': paceFactor,
'hazardCount': hazardCount,
'elevationFt': elevationFt,
'discipline': discipline.name,
if (regionName != null) 'regionName': regionName,
'isFeatured': isFeatured,
'snapshotType': snapshotType.name,
'routePoints': routePoints
.map((p) => {'lat': p.latitude, 'lng': p.longitude})
.toList(),
if (telemetrySamples.isNotEmpty)
'telemetrySamples': telemetrySamples.map((s) => s.toJson()).toList(),
if (photoUrl != null) 'photoUrl': photoUrl,
if (photoCaption != null) 'photoCaption': photoCaption,
'isGpsVerified': hasVerifiedGps,
'isDemo': isDemo,
'fistBumpCount': fistBumpCount,
'fistBumpUserIds': fistBumpUserIds,
'comments': comments.map((c) => c.toJson()).toList(),
};
factory SocialFeedItem.fromJson(Map<String, dynamic> json) {
final routeList = json['routePoints'] as List<dynamic>? ?? [];
final points = <AppLatLng>[];
for (final item in routeList) {
if (item is Map) {
final lat = (item['lat'] as num?)?.toDouble();
final lng = (item['lng'] as num?)?.toDouble();
if (lat != null && lng != null) {
points.add(AppLatLng(lat, lng));
}
}
}
final telemetryList = json['telemetrySamples'] as List<dynamic>? ?? [];
final samples = telemetryList
.whereType<Map>()
.map((s) => FeedTelemetrySample.fromJson(Map<String, dynamic>.from(s))
.withMovingLeanGate())
.toList();
final commentList = json['comments'] as List<dynamic>? ?? [];
final comments = commentList
.whereType<Map<String, dynamic>>()
.map((c) => FeedPostComment.fromJson(c))
.toList();
final distanceMiles = (json['distanceMiles'] as num?)?.toDouble() ?? 0.0;
final durationSeconds = (json['durationSeconds'] as num?)?.toInt() ?? 0;
final isDemo = json['isDemo'] as bool? ?? false;
final rawVerified = json['isGpsVerified'] as bool? ?? false;
final verified = rawVerified &&
verifyGpsCriteria(
isDemo: isDemo,
distanceMiles: distanceMiles,
durationSeconds: durationSeconds,
routePoints: points,
telemetrySamples: samples,
);
return SocialFeedItem(
id: json['id'] as String? ?? '',
rideId: (json['rideId'] as num?)?.toInt(),
authorUid: json['authorUid'] as String? ?? '',
authorDisplayName: json['authorDisplayName'] as String? ?? 'Rider',
authorHandle: json['authorHandle'] as String? ?? 'rider',
authorAvatarPreset: json['authorAvatarPreset'] as String?,
authorBadge: json['authorBadge'] as String?,
clubName: json['clubName'] as String?,
locationName: (json['locationName'] as String? ?? '').trim(),
timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(),
rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve',
bikeModel: json['bikeModel'] as String? ?? 'Motorcycle',
distanceMiles: distanceMiles,
durationSeconds: durationSeconds,
maxLeanAngleDeg: (json['maxLeanAngleDeg'] as num?)?.toDouble() ?? 0.0,
maxLeanSide: json['maxLeanSide'] as String? ?? 'RIGHT',
paceFactor: json['paceFactor'] as String? ?? '1.0x',
hazardCount: (json['hazardCount'] as num?)?.toInt() ?? 0,
elevationFt: (json['elevationFt'] as num?)?.toInt() ?? 0,
discipline: json['discipline'] != null
? RidingDiscipline.values.firstWhere(
(d) => d.name == json['discipline'],
orElse: () => RidingDiscipline.canyonSport,
)
: RidingDiscipline.canyonSport,
regionName: json['regionName'] as String?,
isFeatured: json['isFeatured'] as bool? ?? false,
snapshotType: json['snapshotType'] == 'scenicPhoto'
? FeedSnapshotType.scenicPhoto
: FeedSnapshotType.routeTrack,
routePoints: points,
telemetrySamples: samples,
photoUrl: json['photoUrl'] as String?,
photoCaption: json['photoCaption'] as String?,
isGpsVerified: verified,
isDemo: isDemo,
fistBumpCount: (json['fistBumpCount'] as num?)?.toInt() ?? 0,
fistBumpUserIds:
(json['fistBumpUserIds'] as List<dynamic>?)?.cast<String>() ?? [],
comments: comments,
);
}
}