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_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 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_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
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-secretIn order, you should see:
- Staff sign-in, then the Leads tab. Three requests in the log before you
touch anything: the leads list, and
GET /phone/user-phonesand/phone/numbersfor the Phone tab. - Type a name in the search box — the list narrows on
?search=. - 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/detailreturns the record with a score, and the lead opens with one timeline line: New lead "Ada Byron" created from phone. - Qualify with a reason. The chip turns
qualified; the timeline gains Budget confirmed on the call (lead_qualified) and alead_updatedline the server adds for the status change. - 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.
- Text / call. The sheet names the From number and whether it is
yours. Send —
POST /crm/inbox/update?send=true, thenstatus: sent. Call —POST /phone/token, and the honest message about the native half. - Contacts: search, open one, read the journey — or its empty state.
- Phone: your numbers or the amber None, and the token probe.
When it does not work
| What you see | What it means |
|---|---|
| Invalid email format on Create | The 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 assigned | The 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 leads | You 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' history | Same cause — the owner.id filter was being dropped |
| Contact search returns everyone | The body said search; the server wants keyword |
| No number to send from | Nothing 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 it | No SMS gateway on that server. The record is written either way |
| Full-screen red Assertion failed after a dialog | A controller disposed while the dialog was still closing. Own it in a StatefulWidget |