docs
/
Example apps

Tutorial: the events app

Build the events example from scratch — create an event, add a ticket type, issue a ticket, and work the door with check-in, re-scan and check-out against a live server.

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_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 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_demo

Add the client to pubspec.yaml:

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 — 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-secret

In order, you should see:

  1. The staff sign-in. One row in the log — POST /profile/app/key, app token lit.
  2. POST /profile/user/signin, then a code dialog if two-factor is on, then POST /profile/security/challenge/verify.
  3. The events list — GET /crm/events/get with both badges lit.
  4. New event → POST /crm/events/create → a draft row at the top.
  5. Open it. Add a ticket type → PUT /repository/create → General Admission · $25 · 100 left.
  6. Issue → POST /events/…/tickets → Ticket issued. Code: … and 99 left.
  7. Open the door. Paste the code, press Enter → POST /events/checkin → blue CHECKED IN, stats 1 · 1 · 0 · 1.
  8. Paste it again → amber ALREADY CHECKED IN, Ticket already used. Re-entry not allowed., stats 2 · 1 · 1 · 1.
  9. Switch to Check out, paste it → blue CHECKED OUT, stats 3 · 2 · 1 · 1.
  10. 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 status stays checked_in after check-out. Occupancy is the in/out record, not the ticket.

When it does not work

What you seeWhat it means
No organization configuredThe --dart-define values did not reach the build. Web builds need them on flutter build web too
event starttime must be in the futureThe start you picked has passed — the server, not the app, enforces this
E11000 duplicate key error … data.slugTwo 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.nameTwo ticket types with the same name. Use the generated key, keep the display text in title
Ticket type saved, list still emptyIt was written with eventId/quantity/status instead of event/capacity/isActive — the list endpoint cannot see it
This event has no ticket typeThe app refusing before the server would. Add one
The server ignored this create because it was identical to the one before itAppEngine drops an exact repeat of the previous create as a double-tap. Change something in the record
Amber card on a first scanThe ticket was scanned before, from another device. Check the tickets list — its row shows the check mark