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

480 lines
15 KiB
Dart

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<List<SocialFeedItem>> _localFeedController =
StreamController<List<SocialFeedItem>>.broadcast();
List<SocialFeedItem> _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<SocialFeedItem> get currentFeed => List.unmodifiable(_memoryFeed);
List<SocialFeedItem> getFilteredItems({
FeedFilter filter = FeedFilter.allActivity,
String? userClubName,
RidingDiscipline discipline = RidingDiscipline.all,
String? searchQuery,
double? minDistanceMiles,
bool? verifiedGpsOnly,
String? userUid,
List<String>? followingUids,
}) {
return _filterItems(
_memoryFeed,
filter,
userClubName,
discipline: discipline,
searchQuery: searchQuery,
minDistanceMiles: minDistanceMiles,
verifiedGpsOnly: verifiedGpsOnly,
userUid: userUid,
followingUids: followingUids,
);
}
Stream<List<SocialFeedItem>> getFeedStream({
FeedFilter filter = FeedFilter.allActivity,
String? userClubName,
RidingDiscipline discipline = RidingDiscipline.all,
String? searchQuery,
double? minDistanceMiles,
bool? verifiedGpsOnly,
String? userUid,
List<String>? followingUids,
}) {
late StreamController<List<SocialFeedItem>> controller;
StreamSubscription<List<SocialFeedItem>>? sub;
List<SocialFeedItem> filterCurrent(List<SocialFeedItem> list) {
return _filterItems(
list,
filter,
userClubName,
discipline: discipline,
searchQuery: searchQuery,
minDistanceMiles: minDistanceMiles,
verifiedGpsOnly: verifiedGpsOnly,
userUid: userUid,
followingUids: followingUids,
);
}
controller = StreamController<List<SocialFeedItem>>(
onListen: () {
controller.add(filterCurrent(_memoryFeed));
sub = _localFeedController.stream.listen((list) {
if (!controller.isClosed) {
controller.add(filterCurrent(list));
}
});
},
onCancel: () {
sub?.cancel();
},
);
return controller.stream;
}
List<SocialFeedItem> _filterItems(
List<SocialFeedItem> items,
FeedFilter filter,
String? userClubName, {
RidingDiscipline discipline = RidingDiscipline.all,
String? searchQuery,
double? minDistanceMiles,
bool? verifiedGpsOnly,
String? userUid,
List<String>? followingUids,
}) {
var result = List<SocialFeedItem>.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<void> 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<String>.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<void> 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<FeedPostComment>.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<SocialFeedItem> 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<AppLatLng> 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();
}
}