The community module powers a social feed: posts, comments, reactions, follows, and a real-time community-chat namespace for group messaging. This recipe is taken from event_app — a community + events + storefront app — and shows the feed and follow patterns end to end.
Endpoints
/client/community/feedJWT/client/community/postsJWT/client/community/posts/:postIdJWT/client/community/posts/:postId/commentsJWT/client/community/posts/:postId/commentsJWT/client/community/reactJWT/client/community/followJWT/client/community/notificationsJWTThe full controller lives in /Users/imzee/projects/appengine/src/community/community-client.controller.ts. Pages, stories, hashtags, bookmarks, and notifications are all on the same prefix.
CommunityService
// lib/services/community_service.dart
import '../models/post_model.dart';
import '../services/base_model.dart';
import 'http_client.dart';
class CommunityService {
final _http = appmintHttp;
Future<List<PostModel>> feed({int page = 1, int pageSize = 20}) async {
final response = await _http.get('/client/community/feed', queryParams: {
'page': page.toString(),
'pageSize': pageSize.toString(),
});
return BaseModel.unwrapList(response)
.map((j) => PostModel.fromJson(j as Map<String, dynamic>))
.toList();
}
Future<PostModel> createPost({
required String text,
List<String> images = const [],
String? pageId,
}) async {
final response = await _http.post('/client/community/posts', body: {
'text': text,
'images': images,
if (pageId != null) 'pageId': pageId,
});
return PostModel.fromJson(response as Map<String, dynamic>);
}
Future<List<CommentModel>> comments(String postId) async {
final response =
await _http.get('/client/community/posts/$postId/comments');
return BaseModel.unwrapList(response)
.map((j) => CommentModel.fromJson(j as Map<String, dynamic>))
.toList();
}
Future<CommentModel> addComment(String postId, String text) async {
final response = await _http.post(
'/client/community/posts/$postId/comments',
body: {'text': text},
);
return CommentModel.fromJson(response as Map<String, dynamic>);
}
Future<void> react(String postId, String type) async {
await _http.post('/client/community/react', body: {
'targetId': postId,
'targetType': 'post',
'type': type, // like, love, fire, etc.
});
}
Future<void> follow(String targetUserId) async {
await _http.post('/client/community/follow',
body: {'targetId': targetUserId});
}
Future<int> unreadNotificationCount() async {
final response =
await _http.get('/client/community/notifications/unread-count');
return (response['count'] ?? 0) as int;
}
}
PostModel
// lib/models/post_model.dart
class PostModel {
final String pk;
final String authorEmail;
final String? authorName;
final String? authorAvatar;
final String text;
final List<String> images;
final int reactionCount;
final int commentCount;
final DateTime createdAt;
final bool reactedByMe;
PostModel({
required this.pk,
required this.authorEmail,
this.authorName,
this.authorAvatar,
required this.text,
required this.images,
required this.reactionCount,
required this.commentCount,
required this.createdAt,
required this.reactedByMe,
});
factory PostModel.fromJson(Map<String, dynamic> json) {
final data = (json['data'] ?? json) as Map<String, dynamic>;
final imgs = data['images'];
return PostModel(
pk: (json['pk'] ?? '').toString(),
authorEmail: (data['authorEmail'] ?? '').toString(),
authorName: data['authorName'] as String?,
authorAvatar: data['authorAvatar'] as String?,
text: (data['text'] ?? '').toString(),
images: imgs is List
? imgs.whereType<String>().toList()
: const [],
reactionCount: (data['reactionCount'] ?? 0) as int,
commentCount: (data['commentCount'] ?? 0) as int,
createdAt: DateTime.tryParse(json['createdate'] ?? '') ?? DateTime.now(),
reactedByMe: data['reactedByMe'] == true,
);
}
}
class CommentModel {
final String pk;
final String authorName;
final String text;
final DateTime createdAt;
CommentModel({
required this.pk,
required this.authorName,
required this.text,
required this.createdAt,
});
factory CommentModel.fromJson(Map<String, dynamic> json) {
final data = (json['data'] ?? json) as Map<String, dynamic>;
return CommentModel(
pk: (json['pk'] ?? '').toString(),
authorName: (data['authorName'] ?? 'Someone').toString(),
text: (data['text'] ?? '').toString(),
createdAt: DateTime.tryParse(json['createdate'] ?? '') ?? DateTime.now(),
);
}
}
Feed screen
A pull-to-refresh list with infinite scroll.
// lib/screens/community/feed_screen.dart
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../../models/post_model.dart';
import '../../services/community_service.dart';
class FeedScreen extends StatefulWidget {
const FeedScreen({super.key});
@override
State<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends State<FeedScreen> {
final _service = CommunityService();
final _scroll = ScrollController();
final _items = <PostModel>[];
int _page = 1;
bool _loading = false;
bool _hasMore = true;
@override
void initState() {
super.initState();
_loadMore();
_scroll.addListener(() {
if (_scroll.position.pixels >=
_scroll.position.maxScrollExtent - 400 &&
!_loading &&
_hasMore) {
_loadMore();
}
});
}
Future<void> _loadMore() async {
if (_loading) return;
setState(() => _loading = true);
try {
final page = await _service.feed(page: _page);
setState(() {
_items.addAll(page);
_hasMore = page.length == 20;
_page += 1;
});
} catch (_) {
// Surface to a snackbar in real code
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _refresh() async {
setState(() {
_items.clear();
_page = 1;
_hasMore = true;
});
await _loadMore();
}
Future<void> _toggleReaction(PostModel post) async {
final type = post.reactedByMe ? 'unlike' : 'like';
await _service.react(post.pk, type);
setState(() {
final i = _items.indexOf(post);
_items[i] = PostModel(
pk: post.pk,
authorEmail: post.authorEmail,
authorName: post.authorName,
authorAvatar: post.authorAvatar,
text: post.text,
images: post.images,
reactionCount: post.reactionCount + (post.reactedByMe ? -1 : 1),
commentCount: post.commentCount,
createdAt: post.createdAt,
reactedByMe: !post.reactedByMe,
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Feed')),
body: RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.all(8),
itemCount: _items.length + (_hasMore ? 1 : 0),
itemBuilder: (_, i) {
if (i == _items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
return PostCard(
post: _items[i],
onReact: () => _toggleReaction(_items[i]),
);
},
),
),
);
}
}
class PostCard extends StatelessWidget {
final PostModel post;
final VoidCallback onReact;
const PostCard({super.key, required this.post, required this.onReact});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 6),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundImage: post.authorAvatar != null
? CachedNetworkImageProvider(post.authorAvatar!)
: null,
child: post.authorAvatar == null
? Text((post.authorName ?? 'U')[0])
: null,
),
const SizedBox(width: 12),
Expanded(
child: Text(
post.authorName ?? post.authorEmail,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
const SizedBox(height: 8),
Text(post.text),
if (post.images.isNotEmpty) ...[
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: post.images.first,
fit: BoxFit.cover,
width: double.infinity,
),
),
],
const SizedBox(height: 8),
Row(
children: [
IconButton(
icon: Icon(post.reactedByMe
? Icons.favorite
: Icons.favorite_border),
onPressed: onReact,
),
Text('${post.reactionCount}'),
const SizedBox(width: 16),
Icon(Icons.chat_bubble_outline,
size: 18, color: Colors.grey.shade600),
const SizedBox(width: 4),
Text('${post.commentCount}'),
],
),
],
),
),
);
}
}
Real-time updates
The community module exposes a community-chat Socket.IO namespace for group messages. New posts in the feed don't currently push over WS — poll on resume, or invalidate the cache when a notification arrives. To listen for chat events on a community page (group chat):
final socket = io.io(
'${EnvironmentConfig.appengineEndpoint}/community-chat',
io.OptionBuilder()
.setTransports(['websocket'])
.setAuth({'token': jwt, 'orgId': orgId})
.build(),
);
socket.on('groupMessage', (data) {
// Render in the chat thread
});
The full pattern lives in event_app/lib/services/community_chat_service.dart.
Following users
Toggle a follow with the /follow endpoint. The server tracks a followers collection internally; reading /client/community/followers and /client/community/following returns the current state for the signed-in user.
await CommunityService().follow('user-pk-123');
Notifications
/client/community/notifications returns a list of in-app notifications scoped to the user (someone replied, someone followed). Pair it with /notifications/unread-count for a badge:
final count = await CommunityService().unreadNotificationCount();
// Show on bottom nav badge
Mark all as read on tap:
await appmintHttp.post('/client/community/notifications/read-all');
Stories
If your app supports stories (24-hour ephemeral posts), the endpoints are:
/client/community/storiesJWT/client/community/storiesJWT/client/community/stories/:storyId/viewJWTThe view endpoint records read receipts; combined with /client/community/stories?author=me you can show "X people viewed your story" on the author's UI.
The next recipe — Events and tickets — covers the events module, which integrates with the storefront for ticket purchase.