The appmint_mobile admin app is built around lead management. Operators see a paginated list of leads, filter by status or owner, drill into a lead, edit fields, and create new ones. This recipe assembles those pieces — it's the operator's daily-driver screen.
This is a staff workflow. Sign in via /profile/user/signin (not /profile/customer/signin). See the admin mobile overview for the operator/customer distinction.
Endpoints
/crm/leads/detailJWT/crm/leads/detail/:idJWT/crm/leads/detailJWT/crm/leads/detail/:idJWT/crm/leads/detail/:idJWTThe list endpoint accepts page and limit plus arbitrary filters (status, owner, source, etc.) as query params. Every record comes back as a BaseModel<Lead>.
GET /crm/leads/detail?page=1&limit=20&status=new
Authorization: Bearer <staff-jwt>
orgid: my-org
200 OK
{
"data": [
{
"pk": "lead-...",
"sk": "my-org/lead",
"data": {
"name": "Acme Corp",
"email": "[email protected]",
"phone": "+15551234567",
"status": "new",
"owner": "[email protected]",
"source": "website",
"notes": "..."
}
}
],
"total": 142,
"page": 1
}
LeadModel
// lib/models/lead_model.dart
class LeadModel {
final String? pk;
final String name;
final String? email;
final String? phone;
final String status;
final String? owner;
final String? source;
final String? notes;
final DateTime? modifiedAt;
LeadModel({
this.pk,
required this.name,
this.email,
this.phone,
required this.status,
this.owner,
this.source,
this.notes,
this.modifiedAt,
});
factory LeadModel.fromJson(Map<String, dynamic> json) {
final data = (json['data'] ?? json) as Map<String, dynamic>;
return LeadModel(
pk: json['pk'] as String?,
name: (data['name'] ?? '').toString(),
email: data['email'] as String?,
phone: data['phone'] as String?,
status: (data['status'] ?? 'new').toString(),
owner: data['owner'] as String?,
source: data['source'] as String?,
notes: data['notes'] as String?,
modifiedAt: DateTime.tryParse(json['modifydate'] ?? ''),
);
}
Map<String, dynamic> toJson() => {
'name': name,
if (email != null) 'email': email,
if (phone != null) 'phone': phone,
'status': status,
if (owner != null) 'owner': owner,
if (source != null) 'source': source,
if (notes != null) 'notes': notes,
};
}
LeadsService
// lib/services/leads_service.dart
import '../models/lead_model.dart';
import '../services/base_model.dart';
import 'http_client.dart';
class LeadsService {
final _http = appmintHttp;
Future<LeadPage> list({
int page = 1,
int limit = 20,
Map<String, String>? filters,
}) async {
final response = await _http.get('/crm/leads/detail', queryParams: {
'page': page.toString(),
'limit': limit.toString(),
if (filters != null) ...filters,
});
return LeadPage(
items: BaseModel.unwrapList(response)
.map((j) => LeadModel.fromJson(j as Map<String, dynamic>))
.toList(),
total: BaseModel.totalOf(response),
page: page,
);
}
Future<LeadModel> getById(String id) async {
final response = await _http.get('/crm/leads/detail/$id');
return LeadModel.fromJson(response as Map<String, dynamic>);
}
Future<LeadModel> create(LeadModel lead) async {
final response = await _http.post('/crm/leads/detail', body: lead.toJson());
return LeadModel.fromJson(response as Map<String, dynamic>);
}
Future<LeadModel> update(String id, LeadModel lead) async {
final response = await _http.put('/crm/leads/detail/$id', body: lead.toJson());
return LeadModel.fromJson(response as Map<String, dynamic>);
}
Future<void> delete(String id) => _http.delete('/crm/leads/detail/$id');
}
class LeadPage {
final List<LeadModel> items;
final int total;
final int page;
LeadPage({required this.items, required this.total, required this.page});
}
LeadsProvider
A Provider holds the list, the active filter, and the loading state.
// lib/providers/leads_provider.dart
import 'package:flutter/foundation.dart';
import '../models/lead_model.dart';
import '../services/leads_service.dart';
class LeadsProvider extends ChangeNotifier {
final LeadsService _service = LeadsService();
final List<LeadModel> _items = [];
int _page = 1;
bool _hasMore = true;
bool _loading = false;
String? _error;
String? _statusFilter;
List<LeadModel> get items => _items;
bool get loading => _loading;
bool get hasMore => _hasMore;
String? get error => _error;
String? get statusFilter => _statusFilter;
Future<void> loadMore() async {
if (_loading || !_hasMore) return;
_loading = true;
notifyListeners();
try {
final page = await _service.list(
page: _page,
filters: _statusFilter != null ? {'status': _statusFilter!} : null,
);
_items.addAll(page.items);
_hasMore = _items.length < page.total;
_page += 1;
_error = null;
} catch (e) {
_error = e.toString();
} finally {
_loading = false;
notifyListeners();
}
}
Future<void> refresh() async {
_items.clear();
_page = 1;
_hasMore = true;
_error = null;
notifyListeners();
await loadMore();
}
void setStatusFilter(String? value) {
_statusFilter = value;
refresh();
}
Future<LeadModel> create(LeadModel input) async {
final created = await _service.create(input);
_items.insert(0, created);
notifyListeners();
return created;
}
Future<void> updateInPlace(String id, LeadModel input) async {
final updated = await _service.update(id, input);
final i = _items.indexWhere((l) => l.pk == id);
if (i >= 0) {
_items[i] = updated;
notifyListeners();
}
}
Future<void> delete(String id) async {
await _service.delete(id);
_items.removeWhere((l) => l.pk == id);
notifyListeners();
}
}
Leads list screen
Pull-to-refresh, filter chips, infinite scroll, and a floating action button to create a new lead.
// lib/screens/leads/leads_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/lead_model.dart';
import '../../providers/leads_provider.dart';
import 'lead_detail_screen.dart';
import 'lead_form_screen.dart';
class LeadsScreen extends StatefulWidget {
const LeadsScreen({super.key});
@override
State<LeadsScreen> createState() => _LeadsScreenState();
}
class _LeadsScreenState extends State<LeadsScreen> {
final _scroll = ScrollController();
static const _statuses = ['new', 'contacted', 'qualified', 'lost', 'won'];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<LeadsProvider>().loadMore();
});
_scroll.addListener(() {
final p = context.read<LeadsProvider>();
if (_scroll.position.pixels >=
_scroll.position.maxScrollExtent - 400 &&
!p.loading &&
p.hasMore) {
p.loadMore();
}
});
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final provider = context.watch<LeadsProvider>();
return Scaffold(
appBar: AppBar(
title: const Text('Leads'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(48),
child: SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
ChoiceChip(
label: const Text('All'),
selected: provider.statusFilter == null,
onSelected: (_) => provider.setStatusFilter(null),
),
const SizedBox(width: 6),
for (final s in _statuses) ...[
ChoiceChip(
label: Text(s),
selected: provider.statusFilter == s,
onSelected: (_) => provider.setStatusFilter(s),
),
const SizedBox(width: 6),
],
],
),
),
),
),
body: RefreshIndicator(
onRefresh: provider.refresh,
child: provider.items.isEmpty && provider.loading
? const Center(child: CircularProgressIndicator())
: provider.items.isEmpty && provider.error != null
? Center(child: Text(provider.error!))
: provider.items.isEmpty
? const Center(child: Text('No leads yet.'))
: ListView.builder(
controller: _scroll,
itemCount: provider.items.length +
(provider.hasMore ? 1 : 0),
itemBuilder: (_, i) {
if (i == provider.items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator()));
}
final lead = provider.items[i];
return ListTile(
title: Text(lead.name),
subtitle: Text(lead.email ?? lead.phone ?? '—'),
trailing: _StatusBadge(status: lead.status),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
LeadDetailScreen(leadId: lead.pk!),
),
),
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const LeadFormScreen()),
),
child: const Icon(Icons.add),
),
);
}
}
class _StatusBadge extends StatelessWidget {
final String status;
const _StatusBadge({required this.status});
@override
Widget build(BuildContext context) {
final color = switch (status) {
'new' => Colors.blue,
'contacted' => Colors.orange,
'qualified' => Colors.purple,
'won' => Colors.green,
'lost' => Colors.red,
_ => Colors.grey,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(status, style: TextStyle(color: color, fontSize: 12)),
);
}
}
Lead form
Same form for create and edit. Pass an existing lead to start in edit mode.
// lib/screens/leads/lead_form_screen.dart
class LeadFormScreen extends StatefulWidget {
final LeadModel? existing;
const LeadFormScreen({super.key, this.existing});
@override
State<LeadFormScreen> createState() => _LeadFormScreenState();
}
class _LeadFormScreenState extends State<LeadFormScreen> {
final _formKey = GlobalKey<FormState>();
late final _name = TextEditingController(text: widget.existing?.name);
late final _email = TextEditingController(text: widget.existing?.email);
late final _phone = TextEditingController(text: widget.existing?.phone);
late final _notes = TextEditingController(text: widget.existing?.notes);
String _status = 'new';
bool _busy = false;
@override
void initState() {
super.initState();
if (widget.existing != null) _status = widget.existing!.status;
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _busy = true);
final draft = LeadModel(
pk: widget.existing?.pk,
name: _name.text.trim(),
email: _email.text.trim().isEmpty ? null : _email.text.trim(),
phone: _phone.text.trim().isEmpty ? null : _phone.text.trim(),
status: _status,
notes: _notes.text.trim().isEmpty ? null : _notes.text.trim(),
);
try {
final p = context.read<LeadsProvider>();
if (widget.existing == null) {
await p.create(draft);
} else {
await p.updateInPlace(widget.existing!.pk!, draft);
}
if (mounted) Navigator.of(context).pop();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
// ... form widgets follow the standard TextFormField + DropdownButtonFormField pattern.
}
Search
For free-text search across the leads collection (or any collection), use /repository/search:
final response = await appmintHttp.post('/repository/search', body: {
'query': searchText,
'type': 'lead',
});
final matches = BaseModel.unwrapList(response);
Wire it into the app bar with a debounced TextField and replace the list contents with the search results when the query is non-empty.
Caching this list
For operators who flip between Leads and other tabs all day, layer the offline cache on top of LeadsService.list() — the list-screen experience improves dramatically with a 5-minute TTL.
The next page in the recipes section is the start of the Examples — short walkthroughs of each reference app's architecture.