337 lines
10 KiB
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 (_) {}
|
|
}
|
|
}
|
|
}
|