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 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 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 routePoints; 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, 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? routePoints, String? photoUrl, String? photoCaption, bool? isGpsVerified, bool? isDemo, int? fistBumpCount, List? fistBumpUserIds, List? 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 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 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 commentList = json['comments'] as List? ?? []; final comments = commentList .whereType>() .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?)?.cast() ?? [], comments: comments, ); } }