import 'dart:async'; import 'package:cloud_firestore/cloud_firestore.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; SocialFeedService({this.firestore}) { _initFeed(); } void _initFeed() { if (_initialized) return; _initialized = true; // Populate with Stitch SOC-01 canonical community items _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, 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, 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, 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, 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, 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.isGpsVerified).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. 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, required double distanceMiles, required int durationSeconds, required double maxLeanAngleDeg, String maxLeanSide = 'RIGHT', String paceFactor = '1.2x Spirited', int hazardCount = 0, int elevationFt = 1500, RidingDiscipline discipline = RidingDiscipline.canyonSport, String? regionName, bool isFeatured = false, FeedSnapshotType snapshotType = FeedSnapshotType.routeTrack, List routePoints = const [], }) async { final newPost = SocialFeedItem( id: 'feed_post_${DateTime.now().millisecondsSinceEpoch}', authorUid: authorUid, authorDisplayName: authorDisplayName, authorHandle: authorHandle, authorAvatarPreset: authorAvatarPreset, authorBadge: authorBadge ?? 'PRO', clubName: clubName, locationName: locationName, timestamp: DateTime.now(), rideTitle: rideTitle, bikeModel: bikeModel, distanceMiles: distanceMiles, durationSeconds: durationSeconds, maxLeanAngleDeg: maxLeanAngleDeg, maxLeanSide: maxLeanSide, paceFactor: paceFactor, hazardCount: hazardCount, elevationFt: elevationFt, discipline: discipline, regionName: regionName, isFeatured: isFeatured, snapshotType: snapshotType, routePoints: routePoints, isGpsVerified: true, 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() { _localFeedController.close(); } }