flutter_next_base 0.2.0
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.
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_typeand 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 #
With flutter_next_auth (recommended) #
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.
With your own cookie store #
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_lengthis sent.QueryOptionsalways sends one — 100 by default. UselimitPageLength: 0for 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.
Link fields #
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: falseputs 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.