import 'dart:async'; import 'dart:math' as math; import 'package:cloud_firestore/cloud_firestore.dart'; import '../../database/database.dart'; import '../map/map_provider_interface.dart'; import 'social_feed_models.dart'; class SocialFeedService { final FirebaseFirestore? firestore; final StreamController> _localFeedController = StreamController>.broadcast(); List _memoryFeed = []; bool _initialized = false; StreamSubscription? _firestoreSub; SocialFeedService({this.firestore}) { _initFeed(); } void _initFeed() { if (_initialized) return; _initialized = true; final fs = firestore; if (fs != null) { _memoryFeed = []; _firestoreSub = fs .collection('social_feed') .orderBy('timestamp', descending: true) .limit(50) .snapshots() .listen((snapshot) { final realItems = snapshot.docs.map((doc) { final data = doc.data(); return SocialFeedItem.fromJson({...data, 'id': doc.id, 'isDemo': false}); }).toList(); _memoryFeed = realItems; _localFeedController.add(List.unmodifiable(_memoryFeed)); }, onError: (_) {}); return; } // Populate with Stitch SOC-01 canonical community items (flagged as demo sample) _memoryFeed = [ SocialFeedItem( id: 'feed_post_1', authorUid: 'uid_marcus', authorDisplayName: 'Marcus Vance', authorHandle: 'ApexMarcus', authorAvatarPreset: 'helmet_carbon_cyan', authorBadge: 'PRO', clubName: 'Bay Area Twisties Club', locationName: "Alice's Restaurant", timestamp: DateTime.now().subtract(const Duration(minutes: 28)), rideTitle: 'Sunday Skyline & Pescadero Carve', bikeModel: '2024 Ducati Streetfighter V4', distanceMiles: 68.4, durationSeconds: 4440, // 1h 14m maxLeanAngleDeg: 51.0, maxLeanSide: 'RIGHT', paceFactor: '1.32x Spirited', hazardCount: 0, elevationFt: 2140, discipline: RidingDiscipline.canyonSport, regionName: 'Bay Area, CA', isFeatured: true, snapshotType: FeedSnapshotType.routeTrack, isGpsVerified: false, isDemo: true, fistBumpCount: 42, fistBumpUserIds: const ['uid_rider_2', 'uid_rider_3'], routePoints: const [ AppLatLng(37.3861, -122.2641), AppLatLng(37.3600, -122.2400), AppLatLng(37.3200, -122.2100), AppLatLng(37.2800, -122.2400), AppLatLng(37.2500, -122.1800), ], comments: [ FeedPostComment( id: 'c1', authorUid: 'uid_sarah', authorDisplayName: 'Sarah Chen', authorHandle: 'SarahSweep', content: 'Clean line through section 3! Pavement was prime today.', createdAt: DateTime.now().subtract(const Duration(minutes: 15)), ), ], ), SocialFeedItem( id: 'feed_post_2', authorUid: 'uid_sarah', authorDisplayName: 'Sarah Chen', authorHandle: 'SarahSweep', authorAvatarPreset: 'helmet_aerodynamic_grey', authorBadge: 'ROAD CAPTAIN', clubName: 'Pacific Coast Riders', locationName: 'Mulholland Hwy', timestamp: DateTime.now().subtract(const Duration(hours: 2)), rideTitle: 'The Snake & Rock Store Pace Lap', bikeModel: 'Yamaha MT-09 SP', distanceMiles: 44.2, durationSeconds: 2900, // 48m 20s maxLeanAngleDeg: 48.0, maxLeanSide: 'PEAK', paceFactor: '1.25x Swift', hazardCount: 0, elevationFt: 1850, discipline: RidingDiscipline.canyonSport, regionName: 'SoCal, CA', isFeatured: false, snapshotType: FeedSnapshotType.scenicPhoto, photoUrl: 'assets/images/rock_store_lookout.png', photoCaption: 'Rock Store Lookout', isGpsVerified: false, isDemo: true, fistBumpCount: 28, fistBumpUserIds: const ['uid_marcus'], comments: const [], ), SocialFeedItem( id: 'feed_post_3', authorUid: 'uid_dave', authorDisplayName: 'Dave Sterling', authorHandle: 'GhostRider99', authorAvatarPreset: 'helmet_matte_stealth', authorBadge: 'FOUNDER', clubName: 'Angeles Crest Squad', locationName: 'Newcomb\'s Ranch', timestamp: DateTime.now().subtract(const Duration(hours: 5)), rideTitle: 'Angeles Crest Mountain Pace Run', bikeModel: 'BMW S1000RR M-Sport', distanceMiles: 82.0, durationSeconds: 5200, maxLeanAngleDeg: 53.5, maxLeanSide: 'LEFT', paceFactor: '1.45x Fast', hazardCount: 1, elevationFt: 5300, discipline: RidingDiscipline.trackDay, regionName: 'Angeles Crest, CA', isFeatured: true, snapshotType: FeedSnapshotType.routeTrack, isGpsVerified: false, isDemo: true, fistBumpCount: 65, fistBumpUserIds: const ['uid_marcus', 'uid_sarah'], routePoints: const [ AppLatLng(34.2000, -118.1500), AppLatLng(34.2500, -118.0800), AppLatLng(34.3300, -118.0000), ], comments: const [], ), SocialFeedItem( id: 'feed_post_4', authorUid: 'uid_alex', authorDisplayName: 'Alex Mercer', authorHandle: 'TrailScout', authorAvatarPreset: 'helmet_carbon_cyan', authorBadge: 'ADV LEAD', clubName: 'Blue Ridge Trail Explorers', locationName: 'Blood Mountain Gap', timestamp: DateTime.now().subtract(const Duration(hours: 7)), rideTitle: 'Chattahoochee Forest Pass & Water Crossings', bikeModel: 'KTM 890 Adventure R', distanceMiles: 58.6, durationSeconds: 6100, maxLeanAngleDeg: 34.0, maxLeanSide: 'PEAK', paceFactor: '1.15x Trail Pace', hazardCount: 2, elevationFt: 3420, discipline: RidingDiscipline.advDualSport, regionName: 'North Georgia, GA', isFeatured: true, snapshotType: FeedSnapshotType.routeTrack, isGpsVerified: false, isDemo: true, fistBumpCount: 39, fistBumpUserIds: const ['uid_marcus'], routePoints: const [ AppLatLng(34.7330, -83.9180), AppLatLng(34.7500, -83.9000), AppLatLng(34.7800, -83.8800), ], comments: const [], ), SocialFeedItem( id: 'feed_post_5', authorUid: 'uid_ben', authorDisplayName: 'Ben Holloway', authorHandle: 'IronButtBen', authorAvatarPreset: 'helmet_aerodynamic_grey', authorBadge: 'TOUR MASTER', clubName: 'Smoky Mountain Cruisers', locationName: 'Cherohala Skyway Overlook', timestamp: DateTime.now().subtract(const Duration(hours: 11)), rideTitle: 'Cherohala Skyway Endurance Sweep', bikeModel: 'BMW R1250GS Adventure', distanceMiles: 148.2, durationSeconds: 11200, maxLeanAngleDeg: 39.5, maxLeanSide: 'RIGHT', paceFactor: '1.20x Touring', hazardCount: 0, elevationFt: 5400, discipline: RidingDiscipline.touring, regionName: 'Tellico Plains, TN', isFeatured: true, snapshotType: FeedSnapshotType.routeTrack, isGpsVerified: false, isDemo: true, fistBumpCount: 51, fistBumpUserIds: const ['uid_sarah', 'uid_alex'], routePoints: const [ AppLatLng(35.3500, -84.1500), AppLatLng(35.3700, -84.0500), AppLatLng(35.3900, -83.9500), ], comments: const [], ), ]; _localFeedController.add(List.unmodifiable(_memoryFeed)); } List get currentFeed => List.unmodifiable(_memoryFeed); List getFilteredItems({ FeedFilter filter = FeedFilter.allActivity, String? userClubName, RidingDiscipline discipline = RidingDiscipline.all, String? searchQuery, double? minDistanceMiles, bool? verifiedGpsOnly, String? userUid, List? followingUids, }) { return _filterItems( _memoryFeed, filter, userClubName, discipline: discipline, searchQuery: searchQuery, minDistanceMiles: minDistanceMiles, verifiedGpsOnly: verifiedGpsOnly, userUid: userUid, followingUids: followingUids, ); } Stream> getFeedStream({ FeedFilter filter = FeedFilter.allActivity, String? userClubName, RidingDiscipline discipline = RidingDiscipline.all, String? searchQuery, double? minDistanceMiles, bool? verifiedGpsOnly, String? userUid, List? followingUids, }) { late StreamController> controller; StreamSubscription>? sub; List filterCurrent(List list) { return _filterItems( list, filter, userClubName, discipline: discipline, searchQuery: searchQuery, minDistanceMiles: minDistanceMiles, verifiedGpsOnly: verifiedGpsOnly, userUid: userUid, followingUids: followingUids, ); } controller = StreamController>( onListen: () { controller.add(filterCurrent(_memoryFeed)); sub = _localFeedController.stream.listen((list) { if (!controller.isClosed) { controller.add(filterCurrent(list)); } }); }, onCancel: () { sub?.cancel(); }, ); return controller.stream; } List _filterItems( List items, FeedFilter filter, String? userClubName, { RidingDiscipline discipline = RidingDiscipline.all, String? searchQuery, double? minDistanceMiles, bool? verifiedGpsOnly, String? userUid, List? followingUids, }) { var result = List.from(items); // 1. Primary Stream / Tab Scoping switch (filter) { case FeedFilter.allActivity: break; // All posts case FeedFilter.forYou: // Featured & high-signal runs: featured, or lean >= 40°, or distance >= 20 mi, or elevation >= 1500, or fist bumps >= 20 result = result .where((i) => i.isFeatured || i.maxLeanAngleDeg >= 40.0 || i.distanceMiles >= 20.0 || i.elevationFt >= 1500 || i.fistBumpCount >= 20) .toList(); break; case FeedFilter.clubOnly: if (userClubName == null || userClubName.isEmpty) { result = result.where((i) => i.clubName != null && i.clubName!.isNotEmpty).toList(); } else { result = result.where((i) => i.clubName?.toLowerCase() == userClubName.toLowerCase()).toList(); } break; case FeedFilter.nearbyPacks: result = result.where((i) => i.distanceMiles < 60.0).toList(); break; case FeedFilter.following: if (followingUids != null && followingUids.isNotEmpty) { result = result.where((i) => followingUids.contains(i.authorUid)).toList(); } else { // Fallback to recognized squad/badge riders if user has no following graph yet result = result.where((i) => i.authorBadge != null && i.authorBadge!.isNotEmpty).toList(); } break; } // 2. Discipline / Riding Style Filter if (discipline != RidingDiscipline.all) { result = result.where((i) => i.discipline == discipline).toList(); } // 3. Search Query Filter (road, canyon, bike, rider) if (searchQuery != null && searchQuery.trim().isNotEmpty) { final q = searchQuery.trim().toLowerCase(); result = result.where((i) => i.rideTitle.toLowerCase().contains(q) || i.locationName.toLowerCase().contains(q) || i.bikeModel.toLowerCase().contains(q) || i.authorHandle.toLowerCase().contains(q) || (i.regionName?.toLowerCase().contains(q) ?? false)).toList(); } // 4. Quality Thresholds if (minDistanceMiles != null && minDistanceMiles > 0) { result = result.where((i) => i.distanceMiles >= minDistanceMiles).toList(); } if (verifiedGpsOnly == true) { result = result.where((i) => i.hasVerifiedGps).toList(); } return result; } /// Toggle fist bump on a post. Future toggleFistBump({ required String postId, required String userUid, }) async { final index = _memoryFeed.indexWhere((p) => p.id == postId); if (index == -1) return; final post = _memoryFeed[index]; final isBumped = post.isFistBumpedBy(userUid); final updatedList = List.from(post.fistBumpUserIds); final updatedCount = isBumped ? post.fistBumpCount - 1 : post.fistBumpCount + 1; if (isBumped) { updatedList.remove(userUid); } else { updatedList.add(userUid); } final updatedPost = post.copyWith( fistBumpCount: updatedCount < 0 ? 0 : updatedCount, fistBumpUserIds: updatedList, ); _memoryFeed[index] = updatedPost; _localFeedController.add(List.unmodifiable(_memoryFeed)); final fs = firestore; if (fs != null) { try { await fs.collection('social_feed').doc(postId).update({ 'fistBumpCount': updatedPost.fistBumpCount, 'fistBumpUserIds': updatedPost.fistBumpUserIds, }); } catch (_) {} } } /// Add a comment to a feed post. Future addComment({ required String postId, required FeedPostComment comment, }) async { final index = _memoryFeed.indexWhere((p) => p.id == postId); if (index == -1) return; final post = _memoryFeed[index]; final updatedComments = List.from(post.comments)..add(comment); final updatedPost = post.copyWith(comments: updatedComments); _memoryFeed[index] = updatedPost; _localFeedController.add(List.unmodifiable(_memoryFeed)); final fs = firestore; if (fs != null) { try { await fs.collection('social_feed').doc(postId).update({ 'comments': updatedComments.map((c) => c.toJson()).toList(), }); } catch (_) {} } } /// Share a recent completed ride session to the community feed. /// /// When [authoritativeRide] is provided, telemetry metrics (distance, /// duration, max lean angle, lean side, elevation gain, and ride ID) are /// derived strictly from [authoritativeRide] so manual/forged telemetry /// overrides cannot be injected at the submission layer. Future shareRecentRide({ required String authorUid, required String authorDisplayName, required String authorHandle, String? authorAvatarPreset, String? authorBadge, String? clubName, required String locationName, required String rideTitle, required String bikeModel, double distanceMiles = 0.0, int durationSeconds = 0, double maxLeanAngleDeg = 0.0, String maxLeanSide = 'RIGHT', String paceFactor = '1.0x Cruiser', int hazardCount = 0, int elevationFt = 0, RidingDiscipline discipline = RidingDiscipline.canyonSport, String? regionName, bool isFeatured = false, FeedSnapshotType snapshotType = FeedSnapshotType.routeTrack, List routePoints = const [], List telemetrySamples = const [], Ride? authoritativeRide, int? rideId, bool? isGpsVerified, }) async { double resolvedDistance = distanceMiles; int resolvedDurationSeconds = durationSeconds; double resolvedMaxLean = maxLeanAngleDeg; String resolvedLeanSide = maxLeanSide; int resolvedElevationFt = elevationFt; int? resolvedRideId = rideId; if (authoritativeRide != null) { resolvedRideId = authoritativeRide.id; resolvedDistance = authoritativeRide.distanceMiles > 0 ? authoritativeRide.distanceMiles : authoritativeRide.distance; resolvedDurationSeconds = authoritativeRide.movingTime > 0 ? authoritativeRide.movingTime : (authoritativeRide.endTime != null ? authoritativeRide.endTime! .difference(authoritativeRide.startTime) .inSeconds .clamp(0, 864000) : 0); resolvedMaxLean = math.max( authoritativeRide.maxLeanLeft, authoritativeRide.maxLeanRight, ); if (authoritativeRide.maxLeanLeft == 0 && authoritativeRide.maxLeanRight == 0) { resolvedLeanSide = 'NONE'; } else { resolvedLeanSide = authoritativeRide.maxLeanRight >= authoritativeRide.maxLeanLeft ? 'RIGHT' : 'LEFT'; } resolvedElevationFt = (authoritativeRide.elevationGain * 3.28084).round(); } final gatedTelemetrySamples = telemetrySamples .map((s) => s.withMovingLeanGate()) .toList(growable: false); final meetsGpsVerification = SocialFeedItem.verifyGpsCriteria( isDemo: false, distanceMiles: resolvedDistance, durationSeconds: resolvedDurationSeconds, routePoints: routePoints, telemetrySamples: gatedTelemetrySamples, ); final effectiveGpsVerified = (isGpsVerified ?? true) && meetsGpsVerification; final newPost = SocialFeedItem( id: 'feed_post_${DateTime.now().millisecondsSinceEpoch}', rideId: resolvedRideId, authorUid: authorUid, authorDisplayName: authorDisplayName, authorHandle: authorHandle, authorAvatarPreset: authorAvatarPreset, authorBadge: authorBadge ?? 'PRO', clubName: clubName, locationName: locationName, timestamp: DateTime.now(), rideTitle: rideTitle, bikeModel: bikeModel, distanceMiles: resolvedDistance, durationSeconds: resolvedDurationSeconds, maxLeanAngleDeg: resolvedMaxLean, maxLeanSide: resolvedLeanSide, paceFactor: paceFactor, hazardCount: hazardCount, elevationFt: resolvedElevationFt, discipline: discipline, regionName: regionName, isFeatured: isFeatured, snapshotType: snapshotType, routePoints: routePoints, telemetrySamples: gatedTelemetrySamples, isGpsVerified: effectiveGpsVerified, isDemo: false, fistBumpCount: 1, fistBumpUserIds: [authorUid], comments: const [], ); _memoryFeed.insert(0, newPost); _localFeedController.add(List.unmodifiable(_memoryFeed)); final fs = firestore; if (fs != null) { try { await fs.collection('social_feed').doc(newPost.id).set(newPost.toJson()); } catch (_) {} } return newPost; } void dispose() { _firestoreSub?.cancel(); _localFeedController.close(); } }