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

512 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 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) {
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,
);
}
}