Documentation

Lead management (admin)

Operator-side CRUD for leads with pagination, filters, and pull-to-refresh.

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

GET/crm/leads/detailJWT
GET/crm/leads/detail/:idJWT
POST/crm/leads/detailJWT
PUT/crm/leads/detail/:idJWT
DELETE/crm/leads/detail/:idJWT

The 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.