flutter_next_base 0.2.0 copy "flutter_next_base: ^0.2.0" to clipboard
flutter_next_base: ^0.2.0 copied to clipboard

A Frappe/ERPNext REST client for Flutter. Resource CRUD, submit and cancel, permission checks, link search, bulk writes, file upload and typed error handling.

flutter_next_base #

A Frappe / ERPNext REST client for Flutter. Resource CRUD, submit and cancel, permission checks, Link-field search, bulk writes, file upload and typed errors.

pub package

final client = FlutterNextBaseClient(
  baseUrl: 'https://erp.example.com',
  sessionProvider: () async => await flutternext.cookieHeader(),
);

final result = await client.getResourceList(
  'Sales Order',
  options: const QueryOptions(
    filterList: [FrappeFilter.equals('status', 'To Deliver')],
    fields: ['name', 'customer', 'grand_total'],
    orderBy: 'creation desc',
  ),
);

result.when(
  success: (data) => print(data!['data']),
  failure: (error) => print(error.message),
);

Features #

  • CRUD on any DocType through /api/resource
  • Submit and cancel — ERPNext's core workflow
  • Permission checks that work on Frappe v13 through v16
  • Typed filters, sorting, grouping and real pagination
  • Link-field autocomplete
  • Bulk insert and bulk update
  • Private file upload
  • Any whitelisted method, GET or POST
  • Errors carrying Frappe's own exc_type and the message it showed the user
  • Request timeouts on everything

Install #

dependencies:
  flutter_next_base: ^0.2.0

Requires Dart 3.8 / Flutter 3.32 or newer.

Authentication #

One line, and the whole app shares one session:

final client = FlutterNextBaseClient(
  baseUrl: 'https://erp.example.com',
  sessionProvider: () async => await flutternext.cookieHeader(),
);

The provider is called before every request, so a session rotated by a password change is picked up automatically.

With an API key and secret #

final client = FlutterNextBaseClient(
  baseUrl: 'https://erp.example.com',
  apiKey: 'your_api_key',
  apiSecret: 'your_api_secret',
);

Token auth does not expire and is the only scheme that works on Flutter web, where browsers hide Set-Cookie from the app.

Do not ship a key and secret inside a distributed app.

class MyCookieManager implements CookieManager {
  @override
  Future<String> getCookies(String url) async => 'sid=$mySid';

  @override
  Future<void> saveCookies(String url, List<String> cookies) async {}
}

Reading data #

Lists #

final result = await client.getResourceList(
  'Delivery Note',
  options: const QueryOptions(
    filterList: [
      FrappeFilter.equals('status', 'Draft'),
      FrappeFilter.greaterThan('posting_date', '2026-01-01'),
    ],
    fields: ['name', 'customer', 'posting_date'],
    orderBy: 'posting_date desc',
    limitPageLength: 50,
  ),
);

Page length matters. Frappe caps a list at 20 rows when no limit_page_length is sent. QueryOptions always sends one — 100 by default. Use limitPageLength: 0 for no limit.

Typed rows:

final result = await client.getResourceListAs(
  'Item',
  Item.fromJson,
  options: const QueryOptions(fields: ['name', 'item_name', 'stock_uom']),
);
final items = result.data?.data ?? [];

Every page at once:

final all = await client.getAllPages('Item', pageSize: 500, maxRecords: 5000);

Filters #

const QueryOptions(
  filterList: [
    FrappeFilter.equals('status', 'Open'),
    FrappeFilter.like('customer_name', '%acme%'),
    FrappeFilter.isIn('warehouse', ['Stores - A', 'Finished Goods - A']),
    FrappeFilter.between('posting_date', ['2026-01-01', '2026-03-31']),
    FrappeFilter.isSet('project'),
  ],
)

Raw JSON still works: QueryOptions(filters: '[["status","=","Open"]]').

Child tables #

Frappe rejects a child-table query without its parent:

await client.getResourceList(
  'Sales Invoice Item',
  options: const QueryOptions(
    parent: 'Sales Invoice',              // required
    filterList: [FrappeFilter.equals('parent', 'SINV-0001')],
    fields: ['item_code', 'qty', 'rate'],
  ),
);

A single document #

final result = await client.getResource('Sales Order', 'SO-0001');
final doc = result.data?['data'];  // includes child tables

Fields and counts #

await client.getValue('Item', 'ITEM-001', 'item_name');
await client.getValues('Item', 'ITEM-001', ['item_name', 'stock_uom']);
await client.getSingleValue('Stock Settings', 'allow_negative_stock');

final count = await client.getCount(
  'Task',
  options: const QueryOptions(filterList: [FrappeFilter.equals('status', 'Open')]),
);

Writing data #

await client.createResource('Task', {'subject': 'Ship order', 'status': 'Open'});
await client.updateResource('Task', 'TASK-0001', {'status': 'Completed'});
await client.setValue('Task', 'TASK-0001', 'priority', 'High');
await client.setValues('Task', 'TASK-0001', {'priority': 'High', 'status': 'Working'});
await client.deleteResource('Task', 'TASK-0001');
await client.renameDoc('Item', 'OLD-CODE', 'NEW-CODE');

Bulk:

await client.insertMany([
  {'doctype': 'Task', 'subject': 'One'},
  {'doctype': 'Task', 'subject': 'Two'},
]);

await client.bulkUpdate([
  {'doctype': 'Task', 'docname': 'TASK-0001', 'status': 'Completed'},
]);

Submit and cancel #

ERPNext documents are only real once submitted.

final submitted = await client.submit('Sales Invoice', 'SINV-0001');

if (submitted.isError && submitted.error!.isValidationError) {
  showMessage(submitted.error!.message);   // the message Frappe showed
}

await client.cancel('Sales Invoice', 'SINV-0001');

submit() fetches the document first. If you already hold it, use submitDoc(doc) to save a round trip.

Permissions #

final canCreate = await client.hasPermission('Sales Invoice', permType: 'create');

final perms = await client.getDocPermissions('Sales Order', 'SO-0007');
final message = perms.data?['message'] as Map?;
if (message?['submit'] == 1) showSubmitButton();

Prefer these over role names: they respect User Permissions and owner-only rules, and unlike Frappe's roles endpoint they still exist in v16.

final results = await client.searchLink('Customer', 'acme', pageLength: 10);
for (final row in results.data ?? []) {
  print('${row['value']} — ${row['description']}');
}

await client.validateLink('Item', 'ITEM-001', fields: ['item_name']);

Files #

final result = await client.uploadFile(
  fileName: 'contract.pdf',
  bytes: await file.readAsBytes(),
  isPrivate: true,                 // default
  attachToDocType: 'Sales Order',
  attachToName: 'SO-0001',
);
final url = result.data?['message']?['file_url'];

isPrivate: false puts the file under /files/, which Frappe serves to anyone with the URL, with no authentication. Keep it private unless the file is genuinely public.

Custom methods #

await client.callMethod(
  'your_app.api.update_status',
  data: {'name': 'DR-00001', 'status': 'In Transit'},
);

await client.callMethodGet('your_app.api.get_dashboard', queryParams: {'days': '30'});

Error handling #

final result = await client.createResource('Sales Invoice', payload);

if (result.isError) {
  final error = result.error!;

  if (error.isAuthError)            goToLogin();
  else if (error.isTransportError)  showRetry();
  else if (error.isValidationError) showMessage(error.message);
  else if (error.isPermissionError) showMessage('Not permitted');
  else if (error.isNotFound)        showMessage('Not found');
}

error.message is the text frappe.throw() showed the user, extracted from _server_messages and stripped of markup. error.excType carries Frappe's own class name (ValidationError, DuplicateEntryError, LinkExistsError, ...).

Result handling #

result.when(
  success: (data) => render(data!),
  failure: (error) => showError(error.message),
);

final names = result.map((data) => (data['data'] as List).map((r) => r['name']));
final value = result.valueOr(const {});

Logging #

Logger.root.level = Level.INFO;
Logger.root.onRecord.listen((r) => debugPrint('${r.level.name}: ${r.message}'));

Lifecycle #

client.dispose();   // closes the HTTP client

Frappe compatibility #

Verified against frappe/frappe on version-13, version-14, version-15 and develop. Everything this package calls is present on all four, with one caveat: getDoc() uses a desk endpoint that requires the user to be a System User.

License #

MIT — see LICENSE.

1
likes
160
points
82
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Frappe/ERPNext REST client for Flutter. Resource CRUD, submit and cancel, permission checks, link search, bulk writes, file upload and typed error handling.

Repository (GitHub)
View/report issues

Topics

#frappe #erpnext #rest-client #api #doctype

License

MIT (license)

Dependencies

flutter, http, logging

More

Packages that depend on flutter_next_base