376 lines
12 KiB
Dart
376 lines
12 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 community social feed item (Stitch SOC-01).
|
|
@immutable
|
|
class SocialFeedItem {
|
|
final String id;
|
|
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'
|
|
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 Module
|
|
final FeedSnapshotType snapshotType;
|
|
final List<AppLatLng> routePoints;
|
|
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,
|
|
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 = 1200,
|
|
this.discipline = RidingDiscipline.canyonSport,
|
|
this.regionName,
|
|
this.isFeatured = false,
|
|
this.snapshotType = FeedSnapshotType.routeTrack,
|
|
this.routePoints = const [],
|
|
this.photoUrl,
|
|
this.photoCaption,
|
|
this.isGpsVerified = true,
|
|
this.isDemo = false,
|
|
this.fistBumpCount = 0,
|
|
this.fistBumpUserIds = const [],
|
|
this.comments = const [],
|
|
});
|
|
|
|
bool isFistBumpedBy(String? uid) {
|
|
if (uid == null) return false;
|
|
return fistBumpUserIds.contains(uid);
|
|
}
|
|
|
|
SocialFeedItem copyWith({
|
|
String? id,
|
|
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,
|
|
String? photoUrl,
|
|
String? photoCaption,
|
|
bool? isGpsVerified,
|
|
bool? isDemo,
|
|
int? fistBumpCount,
|
|
List<String>? fistBumpUserIds,
|
|
List<FeedPostComment>? comments,
|
|
}) {
|
|
return SocialFeedItem(
|
|
id: id ?? this.id,
|
|
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,
|
|
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,
|
|
'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 (photoUrl != null) 'photoUrl': photoUrl,
|
|
if (photoCaption != null) 'photoCaption': photoCaption,
|
|
'isGpsVerified': isGpsVerified,
|
|
'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 commentList = json['comments'] as List<dynamic>? ?? [];
|
|
final comments = commentList
|
|
.whereType<Map<String, dynamic>>()
|
|
.map((c) => FeedPostComment.fromJson(c))
|
|
.toList();
|
|
|
|
return SocialFeedItem(
|
|
id: json['id'] as String? ?? '',
|
|
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: (json['distanceMiles'] as num?)?.toDouble() ?? 0.0,
|
|
durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0,
|
|
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,
|
|
photoUrl: json['photoUrl'] as String?,
|
|
photoCaption: json['photoCaption'] as String?,
|
|
isGpsVerified: json['isGpsVerified'] as bool? ?? true,
|
|
isDemo: json['isDemo'] as bool? ?? false,
|
|
fistBumpCount: (json['fistBumpCount'] as num?)?.toInt() ?? 0,
|
|
fistBumpUserIds: (json['fistBumpUserIds'] as List<dynamic>?)?.cast<String>() ?? [],
|
|
comments: comments,
|
|
);
|
|
}
|
|
}
|