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 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 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 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 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 routePoints; final List telemetrySamples; final String? photoUrl; final String? photoCaption; final bool isGpsVerified; final bool isDemo; // Kudos & Reactions final int fistBumpCount; final List fistBumpUserIds; final List 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 routePoints, List 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 = []; 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 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? routePoints, List? telemetrySamples, String? photoUrl, String? photoCaption, bool? isGpsVerified, bool? isDemo, int? fistBumpCount, List? fistBumpUserIds, List? 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 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 json) { final routeList = json['routePoints'] as List? ?? []; final points = []; 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? ?? []; final samples = telemetryList .whereType() .map((s) => FeedTelemetrySample.fromJson(Map.from(s)) .withMovingLeanGate()) .toList(); final commentList = json['comments'] as List? ?? []; final comments = commentList .whereType>() .map((c) => FeedPostComment.fromJson(c)) .toList(); final distanceMiles = (json['distanceMiles'] as num?)?.toDouble() ?? 0.0; final durationSeconds = (json['durationSeconds'] as num?)?.toInt() ?? 0; final isDemo = json['isDemo'] as bool? ?? false; final rawVerified = json['isGpsVerified'] as bool? ?? false; final verified = rawVerified && verifyGpsCriteria( isDemo: isDemo, distanceMiles: distanceMiles, durationSeconds: durationSeconds, routePoints: points, 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?)?.cast() ?? [], comments: comments, ); } }