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_demoThe 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_demodependencies:
flutter:
sdk: flutter
appmint_flutter_client:
git:
url: https://github.com/JacLight/appmint-client.git
path: appmint_flutter_clientflutter pub getDelete 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:
| Answer | Meaning |
|---|---|
{ 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-secretIn order, you should see:
- Customer sign-in (or create an account), then the feed —
GET /client/community/feed?sort=latestwith both badges lit, and any post you have liked before already shows a filled heart. - Type something with a
#hashtagand Post →POST /client/community/posts; your post appears at the top with the hashtag as a chip the server extracted. - Tap the heart →
POST /client/community/react→added; the count goes to 1 and the heart fills. Tap again →removed; back to 0. - Tap the hashtag chip → the feed reloads with
?hashtag=; clear it with the chip at the top. - Open the post →
GET /posts/:idandGET /posts/:id/comments. Write a comment →POST /posts/:id/comments; the thread reloads with it. - Trending → the feed re-sorts by reaction count.
- 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 see | What it means |
|---|---|
| User not found on Report | The body carried a post id as reportedId. It wants the author's email |
| Report already pending | You already reported this person and nobody has reviewed it. Not an error |
| Heart flips back after a moment | It never flipped — the request failed and the card kept the server's truth. Read the message beside the buttons |
| Images show the broken-image icon | The signed thumbnail URL expired. They are minted per request; reload the feed |
| Post not found opening a post | It was removed. Hiding is immediate, for everyone |
Posts you make are public | No page was given. Post into a page to scope visibility to its members |