docs
/
Example apps

Tutorial: the community app

Build the community example from scratch — a feed of posts written by customers, a comment thread, reactions that wait for the server, hashtags, and reporting.

appmint_flutter_community_demo

Get the code

Browse it on GitHub · Download the repository as a zip

git clone https://github.com/JacLight/appmint-examples.git
cd appmint-examples/appmint_flutter_community_demo

The example depends on the Flutter client by relative path (../../appmint-client/appmint_flutter_client), so clone appmint-client beside it — or change that one line in pubspec.yaml to the git dependency shown in Step 1.

This tutorial builds the community example one file at a time. By the end you will have a social surface inside an app: a customer signs in, reads a feed of what other people posted, posts something, opens a post and reads the thread, comments, reacts, follows a hashtag, and reports somebody — and sees every request that did it.

Everything on this page was run against a live AppEngine while it was written. Where an endpoint wants something its name does not say, that is written down at the point you would hit it.

Start with authentication. This app reuses the request log and the customer sign-in from the chat tutorial. Those parts are not repeated here.

Before you start

Flutter 3.27+, an organization with an app credential from Studio Manager, and — ideally — a feed that already has something in it. An empty community is a lonely thing to test; the example works either way.

Step 1 — The project

flutter create --platforms=android,ios,web --org io.appmint \
  --project-name appmint_flutter_community_demo \
  appmint_flutter_community_demo
cd appmint_flutter_community_demo
dependencies:
  flutter:
    sdk: flutter

  appmint_flutter_client:
    git:
      url: https://github.com/JacLight/appmint-client.git
      path: appmint_flutter_client
flutter pub get

Delete test/widget_test.dart. Copy lib/call_log.dart and the customer lib/screens/sign_in_page.dart from the chat example. lib/main.dart is the same shape as every other example; after sign-in it shows FeedPage.

Step 2 — The community API

lib/community_api.dart. Everything lives under /client/community, and everything is written by customers — the token on the request is the author.

The feed

Future<List<Map<String, dynamic>>> feed({String sort = 'latest', String? hashtag}) async {
  final res = await _http.get('/client/community/feed', query: {
    'sort': sort,                                   // latest | trending
    'limit': '20',
    if (hashtag != null) 'hashtag': hashtag,
  });
  return _rows(res);
}

Removed posts are already filtered out. And because the request carries the viewer's token, each post comes back with viewerLiked and viewerSaved set for this person — so a like button can start in the right state without a second request per post. trending sorts by reaction count; latest by modification time.

Posting

Future<Map<String, dynamic>> createPost(String content) async =>
    _map(await _http.post('/client/community/posts', body: {'content': content}));

A post is content and not much else. The server pulls #hashtags and @mentions out of the text, stamps author and authorInfo from the token, starts stats at zero, and sets visibility to public unless the post belongs to a page.

Comments

comments(postId)            → GET  /client/community/posts/:id/comments
addComment(postId, content) → POST /client/community/posts/:id/comments {content}

Top-level comments, oldest first. Replies to a comment are a separate request with ?parentComment=.

Reactions

Future<ReactionResult> react({required String target, required String targetType, String type = 'like'}) async {
  final res = _map(await _http.post('/client/community/react',
      body: {'target': target, 'targetType': targetType, 'type': type}));
  return ReactionResult(action: '${res['action']}', type: '${res['type'] ?? res['to']}');
}

One call, three outcomes, and the server decides which:

AnswerMeaning
{ action: 'added', type }You had no reaction on this; now you do
{ action: 'removed', type }Same type again — toggled off
{ action: 'changed', from, to }A different type — swapped

That is the whole reason the like button in Step 4 waits. Sending like twice is a like and then an un-like; the app cannot know which it was until the server says.

Reporting

Future<Map<String, dynamic>> reportAuthor({required String authorEmail, required String reason, required String details, String? postId}) async =>
    _map(await _http.post('/client/community/reports', body: {
      'reportedId': authorEmail,
      'reason': reason,        // spam | harassment | inappropriate | unwanted_contact | other
      'details': details,
      'context': {'source': 'post', 'messageId': ?postId},
    }));

Reports are about people, not posts. reportedId is the author's email (a post id gets "User not found"), the reason is one of five words, and the post goes in context so a moderator can find it. A second pending report on the same person from you is refused — "Report already pending" — and the app shows that sentence as-is.

Hiding is immediate and total: a removed post disappears from the feed and GET /posts/:id answers "Post not found" for everyone. There is no grace period to design around.

Step 3 — The feed page

lib/screens/feed_page.dart is a header with Latest / Trending, a compose box, an optional hashtag chip, and the list. Nothing clever:

Future<void> _post() async {
  await widget.state.api.createPost(_compose.text.trim());
  _compose.clear();
  await _load();          // the new post arrives from the server, with its id and stats
}

Tapping a hashtag chip on any post sets _hashtag and reloads with ?hashtag=; the chip at the top clears it.

Step 4 — The post card

lib/screens/post_widgets.dart is where the community-specific decisions live, because everything on a post card was written by somebody else.

The text is plain. SelectableText(content) — never markdown, never HTML. A post cannot style itself, embed things, or hide a link behind other words. If you later want links to be tappable, detect them yourself and show the destination.

Images are sized. Every media entry is a file object with signed xs / sm / md thumbnails and a full url. The card loads md into a fixed 220 × 160 box with BoxFit.cover and an errorBuilder. The originals in a real feed are 4 MB each; loading those inline is how a feed stalls on the third post.

The like button is honest.

Future<void> _like() async {
  setState(() => _reacting = true);              // button disabled, nothing flips
  try {
    final r = await api.react(target: post.id, targetType: 'post');
    stats['reactions'] = post.reactions + (r.nowReacted ? 1 : -1);
    d['viewerLiked'] = r.nowReacted;
    widget.onChanged({...post, 'data': d});      // now the heart moves
  } on AppmintException catch (e) {
    setState(() => _flash = e.message);          // nothing moved, because nothing happened
  } finally {
    setState(() => _reacting = false);
  }
}

The count is nudged by exactly what the server said happened — added means +1, removed means −1 — not by what we hoped. An optimistic heart that fails leaves a person believing something that did not happen.

Report is in the overflow menu on every post that is not yours. The dialog offers the five reasons and shows the server's refusal verbatim.

Step 5 — The post page

lib/screens/post_page.dart is the same card, the comment list under it, and a composer. Each comment has its own like, with the same rule: the heart waits. A post that has been removed while you were looking at it shows Post not found rather than a stale thread.

Step 6 — Run the whole thing

flutter run -d chrome \
  --dart-define=APPMINT_URL=https://appengine.appmint.io \
  --dart-define=APPMINT_ORG=your-org \
  --dart-define=APPMINT_APP_ID=your-app-id \
  --dart-define=APPMINT_APP_KEY=your-app-key \
  --dart-define=APPMINT_APP_SECRET=your-app-secret

In order, you should see:

  1. Customer sign-in (or create an account), then the feed — GET /client/community/feed?sort=latest with both badges lit, and any post you have liked before already shows a filled heart.
  2. Type something with a #hashtag and Post → POST /client/community/posts; your post appears at the top with the hashtag as a chip the server extracted.
  3. Tap the heart → POST /client/community/react → added; the count goes to 1 and the heart fills. Tap again → removed; back to 0.
  4. Tap the hashtag chip → the feed reloads with ?hashtag=; clear it with the chip at the top.
  5. Open the post → GET /posts/:id and GET /posts/:id/comments. Write a comment → POST /posts/:id/comments; the thread reloads with it.
  6. Trending → the feed re-sorts by reaction count.
  7. On somebody else's post, ⋮ → Report this author → POST /client/community/reports → Reported. A moderator will review it. Do it again → Report already pending.

When it does not work

What you seeWhat it means
User not found on ReportThe body carried a post id as reportedId. It wants the author's email
Report already pendingYou already reported this person and nobody has reviewed it. Not an error
Heart flips back after a momentIt never flipped — the request failed and the card kept the server's truth. Read the message beside the buttons
Images show the broken-image iconThe signed thumbnail URL expired. They are minted per request; reload the feed
Post not found opening a postIt was removed. Hiding is immediate, for everyone
Posts you make are publicNo page was given. Post into a page to scope visibility to its members