docs
/
Example apps

Tutorial: the CRM app

Build the CRM example from scratch — capture a lead without duplicating one, work it with actions the server records, look people up and read their journey, and text or call them through the organization's number.

appmint_flutter_crm_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_crm_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 CRM example one file at a time. By the end you will have a field-sales shaped app: a member of staff signs in, captures a lead (and is told if that person is already in the CRM), works it — qualify, assign, follow up, note, convert — with a timeline underneath that the server wrote, looks up the people already in the platform and what they have done, and texts or calls them from the organization's number rather than their own.

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 staff sign-in from the events tutorial, which in turn come from the authentication tutorial. Those parts are not repeated here.

Before you start

Flutter 3.27+, an organization with an app credential from Studio Manager, and a staff account. The lead and contact half needs nothing else. For the phone half, the organization needs at least one number; to actually send a text it needs a Twilio integration; to ring, it needs a device build (Step 8 explains).

Step 1 — The project

flutter create --platforms=android,ios,web --org io.appmint \
  --project-name appmint_flutter_crm_demo \
  appmint_flutter_crm_demo
cd appmint_flutter_crm_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 lib/screens/sign_in_page.dart from the events example; lib/main.dart is the same shape too, with one difference — after sign-in it shows a HomePage with three tabs:

SegmentedButton<int>(
  segments: const [
    ButtonSegment(value: 0, label: Text('Leads'),    icon: Icon(Icons.flag_outlined)),
    ButtonSegment(value: 1, label: Text('Contacts'), icon: Icon(Icons.people_outline)),
    ButtonSegment(value: 2, label: Text('Phone'),    icon: Icon(Icons.phone_outlined)),
  ],
  selected: {_tab},
  onSelectionChanged: (s) => setState(() => _tab = s.first),
),

Step 2 — The CRM API

lib/crm_api.dart is the file this example exists for. One method per request, plain maps in and out, and comments on the four places where the server wants something you would not guess.

Leads

Future<List<Map<String, dynamic>>> listLeads({String? search, String? status}) async {
  final res = await _http.get('/crm/leads/detail', query: {
    if (search != null && search.isNotEmpty) 'search': search,
    if (status != null && status.isNotEmpty) 'status': status,
    'pageSize': '50',
  });
  return _rows(res);
}

Future<Map<String, dynamic>> createLead({
  required String fullName, String? email, String? phone, String? company,
  String source = 'other',
}) async {
  return _map(await _http.post('/crm/leads/detail', body: {
    'fullName': fullName, 'email': ?email, 'phone': ?phone,
    'company': ?company, 'source': source, 'status': 'new',
  }));
}

Leads are not repository records you write yourself — they go through /crm/leads/detail, and the service does work on the way in: it splits fullName into first and last, generates the name (a LEAD… number), defaults status and source, and scores it. Almost nothing is required. The one thing it refuses is a malformed email — "Invalid email format" — because the whole design is to capture partial data now and enrich it later.

search matches name, email and company. status is one of new, contacted, qualified, unqualified, disqualified, converted, lost, nurturing, follow_up. Do not pass sortField for the order you want: the route prefixes it with data., so modifydate becomes a field no lead has and the order goes random. Left out, the list comes most-recently- modified first.

Actions

qualify(id, {notes})        → POST /crm/leads/qualify/:id     {notes}
disqualify(id, {reason})    → POST /crm/leads/disqualify/:id  {reason}
assign(id, toEmail)         → POST /crm/leads/assign/:id      {assignedTo}
scheduleFollowUp(id, when)  → POST /crm/leads/follow-up/:id   {followUpDate, notes}
convert(id, {value})        → POST /crm/leads/convert/:id     {conversionValue}
addNote(id, text)           → POST /crm/leads/activities/:id  {type: 'note', description}

Every action except the note does two things: it changes the lead, and it writes a lead_activity record saying who did it and why — "Lead assigned to [email protected]", "Follow-up scheduled for Thu Sep 17 2026: Send the proposal", "Lead converted to customer with value $1200". That is what makes the timeline honest without anyone typing it up afterwards.

The note is the odd one out: it is stored on the lead, in data.activities, not as a lead_activity record. So the timeline reads both:

Future<List<TimelineEntry>> leadTimeline(String id, Map<String, dynamic> lead) async {
  final res = await _appmint.repository.find(
    'lead_activity',
    filter: {'owner.id': id},      // owner is a root field, not under data
    sort: {'createdate': 1},
    pageSize: 200,
  );
  return [
    for (final r in res.items) TimelineEntry(... r['data']['activityType'] ...),
    for (final a in lead['activities'] ?? []) TimelineEntry(... a['type'] ...),
  ]..sort((a, b) => a.at.compareTo(b.at));
}

lead_activity records carry their lead as owner: {datatype: 'lead', id} at the root of the record, so the filter is owner.id — not data.leadId, which does not exist.

Contacts

Future<List<Map<String, dynamic>>> searchContacts(String keyword) async {
  final res = await _http.post('/repository/search/customer',
      body: {'keyword': keyword, 'pageSize': 30});
  return _rows(res);
}

Contacts are customer records, and text search is POST /repository/search/:datatype with keyword. Tried the other two words first: query makes the server throw a database error, and search is ignored — you get everyone, which looks like it worked. It is a text index, not a prefix match: a partial word can bring back somebody whose record merely resembles it, so read the result before acting on it.

A person's journey is GET /crm/customer-activity/:email/timeline and …/summary: visits, orders, bookings, chats, keyed by email. For somebody who has only ever been a record — typed in, imported, signed up and never came back — both are empty, and the app should say so rather than show a blank.

Who the server thinks you are

One thing bit every phone and lead call while writing this, and it is worth knowing before you write a controller of your own. When a request carries both tokens, AppEngine's middleware puts the app on request.currentUser and the person from x-client-authorization on request.currentCustomer. A handler that reads the first — NestJS's @CurrentUser() — sees the app (app@demo), not the member of staff. The symptoms were odd rather than loud: lead actions logged as performed by the app, GET /phone/user-phones empty for somebody with a number, a voice token minted for app@demo. The leads and phone controllers now use @CurrentCustomerOrUser(), which is what the storefront and community controllers already did. If a handler you call seems not to know who you are, that is the first thing to check.

The phone

myNumbers()   → GET  /phone/user-phones   // assigned to me, directly or via a group
orgNumbers()  → GET  /phone/numbers       // everything the organization owns
voiceToken()  → POST /phone/token {platform: 'web'}

Future<Map<String, dynamic>> sendText({required String to, required String from, required String text}) async {
  return _map(await _http.post('/crm/inbox/update?send=true', body: {
    'datatype': 'message',
    'isNew': true,
    'data': {
      'to': [to], 'from': from, 'text': text,
      'deliveryType': 'sms', 'type': 'message', 'status': 'draft',
    },
  }));
}

A text is a message record — the same one the inbox shows — posted through the inbox route with ?send=true. The record comes back pending; a moment later it is sent, or failed with an error. The example reads it again two seconds later and shows what it found.

One thing to know before you trust a green tick: on a server with no SMS gateway configured, the record still goes to sent. Nothing left the building, and nothing said so. Check the organization's Twilio integration.

Step 3 — Leads: the list and the duplicate check

lib/screens/leads_page.dart is a search box (debounced, 350 ms), a status dropdown, the list, and New lead.

The New lead dialog does one thing most CRMs skip: as you type the name or email, it searches:

void _lookAlike(String _) {
  _debounce?.cancel();
  _debounce = Timer(const Duration(milliseconds: 350), () async {
    final q = _email.text.trim().isNotEmpty ? _email.text.trim() : _name.text.trim();
    if (q.length < 3) return;
    final rows = await widget.state.api.listLeads(search: q, pageSize: 5);
    if (mounted) setState(() => _matches = rows);
  });
}

Matches show under the form as Already in the CRM? with a tap that opens the existing record instead of saving a new one. A duplicate lead splits one person's history across two records and makes both look cold; this is the cheapest place to prevent it.

Step 4 — Leads: one record and the actions

lib/screens/lead_page.dart shows the facts, the reach buttons, the five actions, and the timeline. Every action follows one pattern:

Future<void> _act(Future<void> Function() action) async {
  setState(() => _busy = true);
  try {
    await action();
    await _load();          // the server's answer is the truth
  } on AppmintException catch (e) {
    setState(() => _error = e.message);
  } finally {
    setState(() => _busy = false);
  }
}

Nothing is updated optimistically. After Qualify the status chip reads qualified because the server said so, and the timeline gained a line because the server wrote one.

The prompts (Why?, Staff email, Deal value) are one small StatefulWidget dialog that owns its TextEditingController. The obvious shortcut — create a controller, show a dialog, dispose it when the dialog returns — trips "A TextEditingController was used after being disposed" during the closing animation, and Flutter's web build turns that into a full-screen red Assertion failed. It did, while writing this.

Step 5 — Contacts and journeys

lib/screens/contacts_page.dart is a master–detail: search on the left (empty shows the newest thirty), the person on the right with their journey summary — events, sessions, engagement, first and last seen — and the event list. The empty state is explicit:

No activity recorded. Somebody who has only ever been a record — typed in, imported, or signed up and never came back — looks like this.

Text / call is disabled with No phone on record when there is none, rather than opening a sheet that cannot do anything.

Step 6 — Reaching somebody

lib/screens/reach_sheet.dart opens from a lead or a contact. Its first job is to work out which number the text goes from:

final mine = await api.myNumbers();
final org = await api.orgNumbers();
final sms = org.where((n) => n.d['capabilities']?['sms'] == true).toList();
_from = (mine.isNotEmpty ? mine.first : sms.firstOrNull)?.d['phoneNumber'];

Yours if you have one. Otherwise any number the organization owns that can text — with an amber line saying so, because replies to a shared number land in the shared inbox, not with you. And if there is neither, the sheet says exactly that instead of showing a Send button that does nothing:

No number to send from. None is assigned to you, and the organization has no number that can text. Ask an admin to assign one.

"The Text button does nothing" is, nearly every time, "no number is assigned to you". Say it.

Send text posts the message, waits two seconds, reads it back, and shows Message <id> · status: sent. Call asks for a voice token and reports what came back — and stops there, honestly (Step 8).

Step 7 — The phone tab

lib/screens/phone_page.dart is the phone as the server sees it: your numbers (or the amber None box explaining that nothing rings because nothing is assigned), the organization's numbers with their capabilities and who they are assigned to, and a button that asks POST /phone/token and prints the identity and token length.

Step 8 — What a browser cannot do

Getting a voice token is the server's half of a call. The other half — registering the device, ringing, answering, audio — is the native Twilio Voice SDK, and it needs a device build with the twilio_voice plugin, iOS push credentials, and Android's READ_PHONE_STATE. A browser example cannot show it, and this one does not pretend to.

The working implementation is in Appmint Mobile: appmint_mobile/lib/services/softphone_service.dart registers the device (POST /phone/voice/register-device), keeps it alive (…/heartbeat), and hands the token to the SDK. Two things from that code worth knowing before you start: the push credential must be on the same Twilio sub-account the token is signed on, or inbound calls never ring; and on iPad the calling layer is not registered at all — registration succeeds and nothing rings.

Step 9 — 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. Staff sign-in, then the Leads tab. Three requests in the log before you touch anything: the leads list, and GET /phone/user-phones and /phone/numbers for the Phone tab.
  2. Type a name in the search box — the list narrows on ?search=.
  3. New lead. Type the name of somebody who exists — Already in the CRM? appears under the form. Type a new one and Create; POST /crm/leads/detail returns the record with a score, and the lead opens with one timeline line: New lead "Ada Byron" created from phone.
  4. Qualify with a reason. The chip turns qualified; the timeline gains Budget confirmed on the call (lead_qualified) and a lead_updated line the server adds for the status change.
  5. Assign to yourself, Follow up on a date, Note, Convert with a value — five more requests, five more lines, deal value shown in the facts.
  6. Text / call. The sheet names the From number and whether it is yours. Send — POST /crm/inbox/update?send=true, then status: sent. Call — POST /phone/token, and the honest message about the native half.
  7. Contacts: search, open one, read the journey — or its empty state.
  8. Phone: your numbers or the amber None, and the token probe.

When it does not work

What you seeWhat it means
Invalid email format on CreateThe one validation the leads service does. Fix it or leave the email blank
Lead actions logged as app@demo, Your numbers empty though one is assignedThe server handler is reading the app principal, not the person — see Who the server thinks you are above. Fixed in AppEngine; older deployments show it
Every search returns the same leadsYou are on an old client. repository.find used to send its filter in a field the server ignores; update to 0.1.2 or later
Timeline shows other leads' historySame cause — the owner.id filter was being dropped
Contact search returns everyoneThe body said search; the server wants keyword
No number to send fromNothing assigned to you and no SMS-capable number in the organization. An admin assigns numbers in the phone settings
Text says sent but nobody received itNo SMS gateway on that server. The record is written either way
Full-screen red Assertion failed after a dialogA controller disposed while the dialog was still closing. Own it in a StatefulWidget