appmint_flutter_events_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_events_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 events example one file at a time. By the end you will have an app in which a member of staff signs in, makes an event, gives it a ticket type, issues a ticket to somebody, and then stands at the door and scans it — and sees, in three colours, what the server decided and why.
Everything on this page was run against a live AppEngine while it was written. Where the server wants something the error message does not tell you about, that is written down at the point you would hit it.
Start with authentication. This app reuses the request log and the sign-in shape from the authentication tutorial. Those parts are shown here only as far as they differ.
Before you start
Flutter 3.27+, an organization with an app credential from Studio Manager, and a staff account in that organization. Issuing tickets and working the door are staff actions — a customer account can hold a ticket, but not scan one.
Step 1 — The project
flutter create --platforms=android,ios,web --org io.appmint \
--project-name appmint_flutter_events_demo \
appmint_flutter_events_demo
cd appmint_flutter_events_demoAdd the client to pubspec.yaml:
dependencies:
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 — it references a MyApp class this project
will not have.
Step 2 — Credentials and state
The authentication example asks for credentials on a Connect screen. This one takes them at build time instead, so a scanner can be handed to somebody who should never see an app secret:
lib/main.dart:
const config = AppmintConfig(
baseUrl: String.fromEnvironment('APPMINT_URL'),
orgId: String.fromEnvironment('APPMINT_ORG'),
appId: String.fromEnvironment('APPMINT_APP_ID'),
appKey: String.fromEnvironment('APPMINT_APP_KEY'),
appSecret: String.fromEnvironment('APPMINT_APP_SECRET'),
logRequests: true,
);Nothing secret is in the repository; flutter run supplies the values with
--dart-define (Step 8). When they are missing the app shows the command to
run rather than a blank screen.
The state is the same three things as before — a client, the person signed into it, a log — plus the events API from Step 4:
class DemoState extends ChangeNotifier {
DemoState() {
appmint = Appmint(config);
appmint.http.onCall = log.add; // every request, reported
api = EventsApi(appmint);
_sub = appmint.auth.changes.listen((u) {
user = u;
notifyListeners();
});
appmint.auth.restore(); // a stored session, if there is one
}
late final Appmint appmint;
late final EventsApi api;
final log = CallLog();
AppmintUser? user;
StreamSubscription<AppmintUser?>? _sub;
bool get configured => config.baseUrl.isNotEmpty && config.appKey.isNotEmpty;
}Route on what you have:
if (!state.configured) {
body = const _NotConfigured();
} else if (state.user == null) {
body = SignInPage(state: state);
} else {
body = EventsPage(state: state);
}auth.restore() is why a scanner that was signed in yesterday opens straight
onto the events list today.
Copy lib/call_log.dart from the authentication example unchanged. The
DemoScaffold that puts the log beside the page is also the same, with one
line that matters once a page is a scroll view rather than a column:
body: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, // ← without this the door
children: [ // page floats mid-window
Expanded(child: SafeArea(child: child)),
const VerticalDivider(width: 1),
SizedBox(width: 380, child: CallLogPanel(log: state.log)),
],
),Step 3 — Sign in as staff
lib/screens/sign_in_page.dart has no Customer/Staff switch. It calls
appmint.staff.signIn and handles the three endings, exactly as the
authentication tutorial's Step 5 — the sealed SignInResult will not compile
with one missing:
final result = await widget.state.appmint.staff.signIn(email, password);
switch (result) {
case SignedIn():
break; // auth.changes rebuilds onto EventsPage
case NeedsVerification(:final challenge):
final finished = await showDialog<SignInResult>(
context: context,
barrierDismissible: false,
builder: (_) => _CodeDialog(challenge: challenge),
);
if (finished is SignInRejected) setState(() => _error = finished.message);
case SignInRejected(:final message):
setState(() => _error = message);
}_CodeDialog is the verification sheet reduced to a dialog: a six-digit field,
challenge.verify(code), "Send it again" when challenge.method.canResend,
and Cancel calling challenge.cancel(). If your organization has two-factor
turned on for staff, you will see it on the first run.
Step 4 — The events API
lib/events_api.dart is the file this example exists for. It is thin — one
request per method — and the comments are the point, because the events
endpoints have four habits the error messages do not explain.
class EventsApi {
EventsApi(this._appmint);
final Appmint _appmint;
AppmintHttp get _http => _appmint.http;
Future<List<Map<String, dynamic>>> listEvents() async =>
_rows(await _http.get('/crm/events/get'));Creating an event
Future<Map<String, dynamic>> createEvent({
required String title,
required DateTime start,
required DateTime end,
int? capacity,
}) async {
final slug = _slugify(title, start);
final res = await _http.post('/crm/events/create', body: {
'datatype': 'event',
'isNew': true,
'data': {
'name': slug,
'title': title,
'slug': slug,
'startTime': start.toUtc().toIso8601String(),
'startDate': start.toUtc().toIso8601String(),
'endTime': end.toUtc().toIso8601String(),
'endDate': end.toUtc().toIso8601String(),
'capacity': ?capacity,
},
});
return Map<String, dynamic>.from(res as Map);
}Four things here were found by calling the server, not by reading about it:
Events are not created through the repository. PUT /repository/create
with datatype: event is refused. They go through POST /crm/events/create.
The body needs isNew: true at the root. Without it: "create => Not a
new metrics, please use update or set the new property" — a sentence that
says nothing about what is missing.
Supply a slug. There is a unique index on it. The second event created
without one collides with the first: "E11000 duplicate key error … data.slug:
null". The example builds one from the title and the start time.
Send both startTime and startDate. The service validates startTime
and rejects a start in the past ("event starttime must be in the future");
the SDK model requires startDate. Sending only one fails one of the two
checks.
A new event comes back as draft whatever status you pass. Publishing is a
separate call and this example never needs it — a draft event issues tickets
and scans them just fine.
Ticket types
Ticket types are ordinary records, so they go through the repository:
Future<Map<String, dynamic>> createTicketType({
required String eventId,
required String name,
required num price,
required int capacity,
}) async {
return _appmint.repository.create('event_ticket_type', {
'event': eventId,
'name': '${_slugify(name, DateTime.now())}-${eventId.substring(eventId.length - 6)}',
'title': name,
'price': price,
'currency': 'USD',
'capacity': capacity,
'soldCount': 0,
'isActive': true,
});
}The field names are the model's, not the obvious ones, and GET /events/:id/ticket-types filters on exactly these: the link to the event is
event (not eventId), the limit is capacity (not quantity), the
switch is isActive (not status). A record written with the obvious
names saves without complaint and is then invisible to the list endpoint —
which looks exactly like "I added a ticket type and nothing happened".
name is the record's ID: unique across the organization, URL-safe, and what
the server uses to spot duplicates. title is what people see. Send
"General Admission" as the name and the second event in your account to
have one fails with a raw "E11000 duplicate key error … data.name".
capacity − soldCount is what the server checks before issuing. A type with no
capacity is unlimited.
Tickets
Future<Map<String, dynamic>> issueTicket({
required String eventId,
required String ticketTypeId,
required String holderName,
required String holderEmail,
}) async {
final res = await _http.post('/events/$eventId/tickets', body: {
'ticketTypeId': ticketTypeId,
'holderName': holderName,
'holderEmail': holderEmail,
'quantity': 1,
});
return Map<String, dynamic>.from(res as Map);
}The response carries data.code — a string like
K4LAB019XJ43:1789491881520:ed5bf65a66ba6010. That is what a QR code encodes,
what gets scanned, and what a person types in when their phone is dead.
The door
Future<ScanResult> checkIn(String code, {String? zone}) async {
final res = await _http.post('/events/checkin', body: {
'code': code,
if (zone != null && zone.isNotEmpty) 'zone': zone,
});
return ScanResult.from(res, checkedIn: true);
}
Future<ScanResult> checkOut(String code, {String? zone}) async {
final res = await _http.post('/events/checkout', body: {...});
return ScanResult.from(res, checkedIn: false);
}
Future<Map<String, dynamic>> stats(String eventId) async =>
Map<String, dynamic>.from(await _http.get('/events/$eventId/checkin-stats') as Map);Note that checkin and checkout are not under an event — the code alone
identifies the ticket, and the server works out the event. The response is
either { success: true, checkIn: {...}, ticket: {...} } or { success: false, reason: "...", ticket?: {...} }. It is a 200 both ways; a refused scan is an
answer, not an error.
ScanResult turns that into three outcomes the door needs to tell apart:
enum ScanOutcome { admitted, alreadyUsed, denied }
factory ScanResult.from(dynamic res, {required bool checkedIn}) {
final map = Map<String, dynamic>.from(res as Map);
if (map['success'] == true) {
return ScanResult(outcome: ScanOutcome.admitted, ...);
}
final reason = (map['reason'] ?? 'Denied').toString();
return ScanResult(
outcome: reason.toLowerCase().contains('already')
? ScanOutcome.alreadyUsed
: ScanOutcome.denied,
message: reason,
...
);
}The distinction matters at a real door. "Ticket already used. Re-entry not allowed." is not a fake ticket — it is almost always somebody who was scanned in thirty seconds ago and walked back out for a cigarette. Staff misread a red card; they do not misread an amber one.
Step 5 — The events list
lib/screens/events_page.dart lists what listEvents() returns, newest first,
and has a New event button. The dialog asks for a title, a date and a
capacity:
await widget.state.api.createEvent(
title: _title.text.trim(),
start: _start,
end: _start.add(const Duration(hours: 4)),
capacity: int.tryParse(_capacity.text),
);The date picker's firstDate is today, because the server refuses a start in
the past, and the error it gives when you manage it anyway is shown verbatim
in the dialog. Each row shows the start and the status — you will see draft
on everything you make here.
Step 6 — Ticket types and tickets
lib/screens/event_page.dart is one event: its ticket types, its tickets, and
the way to the door.
Add opens a dialog with a name, price and capacity, then calls
createTicketType and reloads. Each type renders as
General Admission · $25 · 100 left, the "left" being capacity − soldCount.
Issue first refuses if there is nothing to issue against:
if (_types.isEmpty) {
setState(() => _error =
'This event has no ticket type. Add one first — nothing can be issued without it.');
return;
}That is the single most common reason issuing "does nothing", and the server's own wording for it is oblique enough to be worth pre-empting. Otherwise the dialog is a ticket-type dropdown, a holder name and an email; on success the page shows Ticket issued. Code: … in a selectable line, the ticket appears in the list with its code underneath, and the type's "left" count drops by one.
Open the door pushes the door page for this event.
Step 7 — The door
lib/screens/door_page.dart takes a code typed or pasted. A phone camera reads
the same code off a QR; taking it typed means the example runs in a browser,
and keeps the interesting part — what comes back — in view.
Check in and check out are the same scanner in a different mode:
SegmentedButton<bool>(
segments: const [
ButtonSegment(value: true, label: Text('Check in'), icon: Icon(Icons.login)),
ButtonSegment(value: false, label: Text('Check out'), icon: Icon(Icons.logout)),
],
selected: {_checkingIn},
onSelectionChanged: (v) => setState(() => _checkingIn = v.first),
),Above the code field is an optional zone. A scan sent with no zone still admits the person but counts toward no zone's occupancy — fine for one door, useless for a venue with three.
The scan itself:
final result = _checkingIn
? await widget.state.api.checkIn(code, zone: zone)
: await widget.state.api.checkOut(code, zone: zone);
setState(() => _last = result.withTicketType(_typeTitles[result.ticketType]));
_code.clear();
await _refreshStats();The check-in answer names the ticket type by id, so the page loads the event's types once and swaps in the title. The result card is three colours — blue CHECKED IN / CHECKED OUT, amber ALREADY CHECKED IN, red DENIED — with the holder's name, the ticket type, and the server's reason when there is one.
Under it, four numbers from checkin-stats, re-read after every scan:
Scans, Admitted, Denied, People.
Step 8 — 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:
- The staff sign-in. One row in the log —
POST /profile/app/key, app token lit. POST /profile/user/signin, then a code dialog if two-factor is on, thenPOST /profile/security/challenge/verify.- The events list —
GET /crm/events/getwith both badges lit. - New event →
POST /crm/events/create→ adraftrow at the top. - Open it. Add a ticket type →
PUT /repository/create→ General Admission · $25 · 100 left. - Issue →
POST /events/…/tickets→ Ticket issued. Code: … and 99 left. - Open the door. Paste the code, press Enter →
POST /events/checkin→ blue CHECKED IN, stats1 · 1 · 0 · 1. - Paste it again → amber ALREADY CHECKED IN, Ticket already used.
Re-entry not allowed., stats
2 · 1 · 1 · 1. - Switch to Check out, paste it → blue CHECKED OUT, stats
3 · 2 · 1 · 1. - Type anything that is not a code → red DENIED, No ticket found for this code.
What the numbers mean
Read from a live server, because the stats are easy to misread:
- Scans is every record the server wrote for this event — a check-out is a scan too.
- Admitted counts successful check-ins and check-outs. After one person goes in and out it reads 2.
- Denied counts refused scans of a known ticket — a re-scan, a wrong zone, a type this door does not accept. A code the server cannot resolve at all is refused but belongs to no event, so it appears nowhere.
- People is distinct ticket holders admitted, so a re-scan does not inflate it.
- A ticket's own
statusstayschecked_inafter check-out. Occupancy is the in/out record, not the ticket.
When it does not work
| What you see | What it means |
|---|---|
| No organization configured | The --dart-define values did not reach the build. Web builds need them on flutter build web too |
| event starttime must be in the future | The start you picked has passed — the server, not the app, enforces this |
| E11000 duplicate key error … data.slug | Two events with the same slug; the example derives one from title + start time, so this means the same title created twice in one second |
| E11000 duplicate key error … data.name | Two ticket types with the same name. Use the generated key, keep the display text in title |
| Ticket type saved, list still empty | It was written with eventId/quantity/status instead of event/capacity/isActive — the list endpoint cannot see it |
| This event has no ticket type | The app refusing before the server would. Add one |
| The server ignored this create because it was identical to the one before it | AppEngine drops an exact repeat of the previous create as a double-tap. Change something in the record |
| Amber card on a first scan | The ticket was scanned before, from another device. Check the tickets list — its row shows the check mark |