Documentation

Community feed

Render posts, follow users, and listen for new content via the community module.

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

GET/client/community/feedJWT
POST/client/community/postsJWT
GET/client/community/posts/:postIdJWT
POST/client/community/posts/:postId/commentsJWT
GET/client/community/posts/:postId/commentsJWT
POST/client/community/reactJWT
POST/client/community/followJWT
GET/client/community/notificationsJWT

The 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:

GET/client/community/storiesJWT
POST/client/community/storiesJWT
POST/client/community/stories/:storyId/viewJWT

The 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.