MotorSignal-App/lib/services/clubs/club_wall_service.dart

337 lines
10 KiB
Dart

import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'club_models.dart';
import 'club_wall_models.dart';
/// Manages Club Wall discussion board posts, pinned announcements,
/// fist-bump kudos, and rider comments.
class ClubWallService {
final FirebaseFirestore? _firestore;
// In-memory cache of posts by clubId for responsive local state & fallback
final Map<String, List<ClubWallPost>> _localPosts = {};
final Map<String, StreamController<List<ClubWallPost>>> _controllers = {};
ClubWallService({FirebaseFirestore? firestore})
: _firestore = firestore ?? _resolveFirestore();
static FirebaseFirestore? _resolveFirestore() {
try {
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
} catch (_) {}
return null;
}
StreamController<List<ClubWallPost>> _getController(String clubId) {
return _controllers.putIfAbsent(
clubId,
() => StreamController<List<ClubWallPost>>.broadcast(),
);
}
List<ClubWallPost> _getSeedPosts(String clubId) {
final now = DateTime.now();
return [
ClubWallPost(
id: '${clubId}_seed_1',
clubId: clubId,
authorUid: 'seed_pres',
authorDisplayName: 'Marcus Vance',
authorHandle: '@marcus_v',
authorDisplayRank: 'President',
authorRole: ClubRole.owner,
content: '🏍️ SATURDAY RIDE BRIEFING: Staging at Ridgepoint Shell at 08:30 AM. Route is Blood Mountain Loop (78 miles, standard pace). Ensure tire pressures and chain tension are checked. Road Captain will lead paceline.',
createdAt: now.subtract(const Duration(hours: 3)),
isPinned: true,
fistBumpCount: 14,
fistBumpUserIds: const ['seed_user_2', 'seed_user_3'],
comments: [
ClubWallComment(
id: 'c1',
postId: '${clubId}_seed_1',
authorUid: 'seed_rc',
authorDisplayName: 'Dave Miller',
authorHandle: '@dave_m',
authorDisplayRank: 'Road Captain',
authorRole: ClubRole.admin,
content: 'GPX route cue sheet uploaded to the club rides tab. We will fuel up at mile 42.',
createdAt: now.subtract(const Duration(hours: 2)),
),
],
),
ClubWallPost(
id: '${clubId}_seed_2',
clubId: clubId,
authorUid: 'seed_tg',
authorDisplayName: 'Samir Chen',
authorHandle: '@samir_c',
authorDisplayRank: 'Tail Gunner',
authorRole: ClubRole.member,
content: 'Sweep radar active on channel 4. Let\'s maintain 2-second stagger spacing through the canyon twisties. See everyone at staging!',
createdAt: now.subtract(const Duration(hours: 5)),
isPinned: false,
fistBumpCount: 9,
fistBumpUserIds: const ['seed_user_1'],
),
ClubWallPost(
id: '${clubId}_seed_3',
clubId: clubId,
authorUid: 'seed_mem',
authorDisplayName: 'Sarah Jenkins',
authorHandle: '@sjenkins',
authorDisplayRank: 'Road Marshal',
authorRole: ClubRole.member,
content: 'Just fitted new Pirelli Diablo Rosso IVs, ready for the elevation climbs this weekend! 💨 Weather radar looks clear.',
createdAt: now.subtract(const Duration(hours: 8)),
isPinned: false,
fistBumpCount: 6,
),
];
}
/// Real-time stream of wall posts for a club.
Stream<List<ClubWallPost>> clubWallStream(String clubId) {
final controller = _getController(clubId);
// Initialize seed posts if not present
if (!_localPosts.containsKey(clubId)) {
_localPosts[clubId] = _getSeedPosts(clubId);
Timer.run(() {
if (!controller.isClosed) {
controller.add(_sortPosts(_localPosts[clubId]!));
}
});
} else {
Timer.run(() {
if (!controller.isClosed) {
controller.add(_sortPosts(_localPosts[clubId]!));
}
});
}
// Attempt to listen to Firestore if available
final firestore = _firestore;
if (firestore != null) {
firestore
.collection('clubs')
.doc(clubId)
.collection('wall_posts')
.orderBy('createdAt', descending: true)
.limit(50)
.snapshots()
.listen(
(snap) {
if (snap.docs.isNotEmpty) {
final firestorePosts =
snap.docs.map((d) => ClubWallPost.fromFirestore(d)).toList();
_localPosts[clubId] = firestorePosts;
if (!controller.isClosed) {
controller.add(_sortPosts(firestorePosts));
}
}
},
onError: (_) {
// Graceful fallback to local cache
},
);
}
return controller.stream;
}
List<ClubWallPost> _sortPosts(List<ClubWallPost> posts) {
final copy = List<ClubWallPost>.from(posts);
copy.sort((a, b) {
// Pinned posts always first
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return b.createdAt.compareTo(a.createdAt);
});
return copy;
}
/// Creates a new post on the Club Wall.
Future<ClubWallPost> createPost({
required String clubId,
required String authorUid,
required String authorDisplayName,
String? authorHandle,
String? authorAvatarUrl,
String? authorDisplayRank,
ClubRole authorRole = ClubRole.member,
required String content,
bool isPinned = false,
}) async {
final cleanContent = content.trim();
if (cleanContent.isEmpty) {
throw ArgumentError('Post content cannot be empty.');
}
final post = ClubWallPost(
id: 'post_${DateTime.now().millisecondsSinceEpoch}_$authorUid',
clubId: clubId,
authorUid: authorUid,
authorDisplayName: authorDisplayName,
authorHandle: authorHandle,
authorAvatarUrl: authorAvatarUrl,
authorDisplayRank: authorDisplayRank,
authorRole: authorRole,
content: cleanContent,
createdAt: DateTime.now(),
isPinned: isPinned,
fistBumpCount: 0,
fistBumpUserIds: const [],
comments: const [],
);
// Update local cache & notify stream
final posts = _localPosts.putIfAbsent(clubId, () => _getSeedPosts(clubId));
posts.insert(0, post);
_localPosts[clubId] = posts;
_getController(clubId).add(_sortPosts(posts));
// Try persist to Firestore
final firestore = _firestore;
if (firestore != null) {
try {
await firestore
.collection('clubs')
.doc(clubId)
.collection('wall_posts')
.doc(post.id)
.set(post.toFirestore());
} catch (_) {
// Silently preserve in local cache
}
}
return post;
}
/// Toggles a Fist Bump (kudos) on a post.
Future<void> toggleFistBump({
required String clubId,
required String postId,
required String userUid,
}) async {
final posts = _localPosts[clubId] ?? [];
final index = posts.indexWhere((p) => p.id == postId);
if (index == -1) return;
final target = posts[index];
final alreadyBumped = target.fistBumpUserIds.contains(userUid);
final updatedList = List<String>.from(target.fistBumpUserIds);
if (alreadyBumped) {
updatedList.remove(userUid);
} else {
updatedList.add(userUid);
}
final updatedPost = target.copyWith(
fistBumpCount: updatedList.length,
fistBumpUserIds: updatedList,
);
posts[index] = updatedPost;
_localPosts[clubId] = posts;
_getController(clubId).add(_sortPosts(posts));
// Firestore sync if available
final firestore = _firestore;
if (firestore != null) {
try {
await firestore
.collection('clubs')
.doc(clubId)
.collection('wall_posts')
.doc(postId)
.update({
'fistBumpCount': updatedList.length,
'fistBumpUserIds': updatedList,
});
} catch (_) {}
}
}
/// Adds a comment to a Club Wall post.
Future<void> addComment({
required String clubId,
required String postId,
required String authorUid,
required String authorDisplayName,
String? authorHandle,
String? authorAvatarUrl,
String? authorDisplayRank,
ClubRole authorRole = ClubRole.member,
required String content,
}) async {
final cleanContent = content.trim();
if (cleanContent.isEmpty) return;
final posts = _localPosts[clubId] ?? [];
final index = posts.indexWhere((p) => p.id == postId);
if (index == -1) return;
final comment = ClubWallComment(
id: 'comment_${DateTime.now().millisecondsSinceEpoch}',
postId: postId,
authorUid: authorUid,
authorDisplayName: authorDisplayName,
authorHandle: authorHandle,
authorAvatarUrl: authorAvatarUrl,
authorDisplayRank: authorDisplayRank,
authorRole: authorRole,
content: cleanContent,
createdAt: DateTime.now(),
);
final target = posts[index];
final updatedComments = List<ClubWallComment>.from(target.comments)..add(comment);
final updatedPost = target.copyWith(comments: updatedComments);
posts[index] = updatedPost;
_localPosts[clubId] = posts;
_getController(clubId).add(_sortPosts(posts));
// Firestore sync
final firestore = _firestore;
if (firestore != null) {
try {
await firestore
.collection('clubs')
.doc(clubId)
.collection('wall_posts')
.doc(postId)
.update({
'comments': updatedComments.map((c) => c.toJson()).toList(),
});
} catch (_) {}
}
}
/// Deletes a post from the Club Wall.
Future<void> deletePost({
required String clubId,
required String postId,
}) async {
final posts = _localPosts[clubId] ?? [];
posts.removeWhere((p) => p.id == postId);
_localPosts[clubId] = posts;
_getController(clubId).add(_sortPosts(posts));
final firestore = _firestore;
if (firestore != null) {
try {
await firestore
.collection('clubs')
.doc(clubId)
.collection('wall_posts')
.doc(postId)
.delete();
} catch (_) {}
}
}
}