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

306 lines
9.5 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,
snapshotType: FeedSnapshotType.routeTrack,
isGpsVerified: 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,
snapshotType: FeedSnapshotType.scenicPhoto,
photoUrl: 'assets/images/rock_store_lookout.png',
photoCaption: 'Rock Store Lookout',
isGpsVerified: 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,
snapshotType: FeedSnapshotType.routeTrack,
isGpsVerified: 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 [],
),
];
_localFeedController.add(List.unmodifiable(_memoryFeed));
}
List<SocialFeedItem> get currentFeed => List.unmodifiable(_memoryFeed);
List<SocialFeedItem> getFilteredItems({
FeedFilter filter = FeedFilter.allActivity,
String? userClubName,
}) {
return _filterItems(_memoryFeed, filter, userClubName);
}
Stream<List<SocialFeedItem>> getFeedStream({
FeedFilter filter = FeedFilter.allActivity,
String? userClubName,
}) {
late StreamController<List<SocialFeedItem>> controller;
StreamSubscription<List<SocialFeedItem>>? sub;
controller = StreamController<List<SocialFeedItem>>(
onListen: () {
controller.add(_filterItems(_memoryFeed, filter, userClubName));
sub = _localFeedController.stream.listen((list) {
if (!controller.isClosed) {
controller.add(_filterItems(list, filter, userClubName));
}
});
},
onCancel: () {
sub?.cancel();
},
);
return controller.stream;
}
List<SocialFeedItem> _filterItems(
List<SocialFeedItem> items,
FeedFilter filter,
String? userClubName,
) {
switch (filter) {
case FeedFilter.allActivity:
return items;
case FeedFilter.clubOnly:
if (userClubName == null || userClubName.isEmpty) {
return items.where((i) => i.clubName != null && i.clubName!.isNotEmpty).toList();
}
return items.where((i) => i.clubName?.toLowerCase() == userClubName.toLowerCase()).toList();
case FeedFilter.nearbyPacks:
return items.where((i) => i.distanceMiles < 60.0).toList();
}
}
/// 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,
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,
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();
}
}