513 lines
17 KiB
Dart
513 lines
17 KiB
Dart
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:flutter/foundation.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 {
|
|
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).
|
|
@immutable
|
|
class SocialFeedItem {
|
|
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 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 with meaningful displacement (~50m+).
|
|
final first = validPoints.first;
|
|
final hasDistinctPoint = validPoints.skip(1).any(
|
|
(p) =>
|
|
(p.latitude - first.latitude).abs() +
|
|
(p.longitude - first.longitude).abs() >
|
|
0.0005,
|
|
);
|
|
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 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)))
|
|
.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,
|
|
);
|
|
|
|
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? ?? 'Canyon Route',
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|