306 lines
9.5 KiB
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();
|
|
}
|
|
}
|