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

567 lines
18 KiB
Dart

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<List<SocialFeedItem>> _localFeedController =
StreamController<List<SocialFeedItem>>.broadcast();
List<SocialFeedItem> _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<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.hasVerifiedGps).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.
///
/// 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<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,
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<AppLatLng> routePoints = const [],
List<FeedTelemetrySample> 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 meetsGpsVerification = SocialFeedItem.verifyGpsCriteria(
isDemo: false,
distanceMiles: resolvedDistance,
durationSeconds: resolvedDurationSeconds,
routePoints: routePoints,
);
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: telemetrySamples,
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();
}
}