Skip to main content
Connecting Flutter to Odoo: A Complete Guide with odoo_rest_ov
10 min read
Flutter

Connecting Flutter to Odoo: A Complete Guide with odoo_rest_ov

A comprehensive guide to using odoo_rest_ov β€” a Dart package that wraps Odoo's JSON-RPC 2.0 API with typed ORM methods, a fluent domain builder, session management, and user-friendly error handling.

View on pub.dev

Introduction

Building a Flutter app that talks to an Odoo ERP server sounds straightforward β€” until you hit the reality. Odoo's JSON-RPC 2.0 API has its own session cookie lifecycle, a non-standard domain filter syntax, an error response format that looks like a success (HTTP 200 with an error payload), and a timezone field that silently corrupts datetime values if you ignore it.

Most developers writing their first Odoo client end up with a tangle of raw http or dio calls, hand-rolled session management, and a growing collection of try/catch blocks that inspect raw strings to figure out what went wrong. It works β€” until it doesn't.

odoo_rest_ov is a Dart package built by the Octivex team to solve this problem properly. It wraps the Odoo JSON-RPC 2.0 API in a typed, ergonomic client with first-class Flutter support. This article walks through every major feature with real code, so you can evaluate whether it fits your project.


Installation

Add the package to your pubspec.yaml:

yaml
dependencies:
  odoo_rest_ov: ^0.1.0

Then run:

bash
dart pub get

The package depends on dio for HTTP, cookie_jar for cookie storage, and dio_cookie_manager to wire them together. All three are pulled in automatically β€” no extra configuration needed for a basic setup.


Connecting to an Odoo Server

Creating a client takes one line:

dart
import 'package:odoo_rest_ov/odoo_rest_ov.dart';

final client = OdooClient(OdooClientOptions(
  baseUrl: 'https://mycompany.odoo.com',
  database: 'mydb',
));

OdooClientOptions accepts additional parameters for fine-tuning behaviour β€” including a session change callback, custom timeout values, and API key injection. The client itself is a long-lived object; create it once and reuse it across your app.


Authentication & Session Management

Username / Password

dart
final session = await client.authenticate('admin', 'admin');

print(session.name);      // "Administrator"
print(session.timezone);  // "Asia/Riyadh" β€” auto-detected from Odoo settings
print(session.uid);       // 2

The session object carries everything Odoo returns after a successful login: the user's display name, UID, timezone, language, company ID, and user type. The timezone is read directly from the user's Odoo preferences, which avoids the common bug where datetime values appear shifted because the client and server disagree on the timezone.

API Key (Odoo 14+)

For server-to-server or CI/CD scenarios where storing a password is unacceptable:

dart
client.setApiKey('your-odoo-api-key-here');
// Use ORM methods directly β€” no authenticate() call needed

Listening for Session Changes

In a real Flutter app, a session expiry should redirect the user to the login screen. Wire that up via the session stream:

dart
client.sessionStream.listen((session) {
  if (session == null) {
    // Session expired or user logged out
    Navigator.pushReplacementNamed(context, '/login');
  }
});

Or pass an onSessionChanged callback at construction time:

dart
OdooClientOptions(
  baseUrl: 'https://mycompany.odoo.com',
  database: 'mydb',
  onSessionChanged: (session) {
    if (session == null) _navigateToLogin();
  },
);

Checking and Refreshing a Session

dart
final isValid = await client.checkSession();  // non-throwing boolean

if (!isValid) {
  await client.authenticate('admin', 'admin');
}

// Or refresh session data without re-authenticating
final updatedSession = await client.refreshSession();

Logout

dart
await client.logout();
client.close(); // release resources when done

ORM Methods

Every common Odoo ORM operation is available as a typed method. The signatures mirror the Python ORM closely, so Odoo developers will feel at home immediately.

searchRead

dart
final partners = await client.searchRead(
  'res.partner',
  [['is_company', '=', true]],
  fields: ['name', 'email', 'phone', 'country_id'],
  limit: 20,
  offset: 0,
  order: 'name asc',
);

search

Returns a list of matching record IDs without fetching field data:

dart
final ids = await client.search(
  'res.partner',
  [['customer_rank', '>', 0]],
  limit: 100,
);

read

Fetch specific fields for a known list of IDs:

dart
final records = await client.read(
  'res.partner',
  [1, 2, 3],
  fields: ['name', 'email'],
);

searchCount

dart
final total = await client.searchCount(
  'res.partner',
  [['active', '=', true]],
);
print('Total active partners: $total');

create

dart
final newId = await client.create('res.partner', {
  'name': 'Acme Corp',
  'email': 'info@acme.com',
  'is_company': true,
});
print('Created partner with ID: $newId');

createMulti

Create several records in one round-trip:

dart
final newIds = await client.createMulti('res.partner', [
  {'name': 'Partner A', 'email': 'a@example.com'},
  {'name': 'Partner B', 'email': 'b@example.com'},
]);

write

dart
await client.write('res.partner', [newId], {
  'phone': '+966 55 000 0000',
  'website': 'https://acme.com',
});

unlink

dart
await client.unlink('res.partner', [newId]);

fieldsGet

Inspect a model's field definitions at runtime β€” useful for building dynamic forms:

dart
final fields = await client.fieldsGet(
  'res.partner',
  attributes: ['string', 'type', 'required', 'selection'],
);
// fields is a Map<String, dynamic> keyed by field name

nameSearch

dart
final results = await client.nameSearch('res.partner', 'Acme');
// Returns List<[int, String]> β€” id and display name pairs

callMethod

Call any model method that isn't covered by the ORM shortcuts:

dart
final result = await client.callMethod(
  'account.move',
  'action_post',
  args: [[invoiceId]],
);

Domain Builder

Raw Odoo domains are lists of tuples β€” readable enough in Python, but awkward in Dart:

dart
// Raw β€” works, but fragile
final domain = [
  ['is_company', '=', true],
  ['customer_rank', '>', 0],
];

The OdooDomain builder gives you a fluent, type-safe alternative:

dart
// AND (default)
final domain = OdooDomain()
    .where('is_company').equals(true)
    .where('customer_rank').greaterThan(0)
    .build();

// OR
final orDomain = OdooDomain()
    .or()
    .where('email').ilike('%@gmail.com')
    .where('email').ilike('%@yahoo.com')
    .build();

// NOT
final notDomain = OdooDomain()
    .not()
    .where('active').equals(false)
    .build();

// Membership check
final inDomain = OdooDomain()
    .where('country_id').isIn([184, 113, 20])
    .build();

The full set of operators matches every operator Odoo supports: equals, notEquals, greaterThan, greaterOrEqual, lessThan, lessOrEqual, like, ilike, notLike, notIlike, isIn, notIn, childOf, parentOf, isSet, and isNotSet.

Raw lists still work if you prefer or need backward compatibility β€” the ORM methods accept either format.


Record Helpers

Records returned from searchRead and read are OdooRecord objects with extension methods that handle the common relational field patterns:

dart
final partner = partners.first;

// Primitive access
partner.id;    // int
partner.name;  // String
partner['email'];  // dynamic β€” any field by name

// Many2one fields come back as [id, name] β€” helpers extract each part
partner.many2oneId('country_id');    // int? β€” 184
partner.many2oneName('country_id');  // String? β€” "Saudi Arabia"

// Many2many / One2many fields come back as List<int>
partner.x2manyIds('tag_ids');  // [3, 7, 12]

These helpers prevent the boilerplate of casting and null-checking [dynamic, dynamic] tuples that Odoo returns for relational fields.


Error Handling

This is where raw Odoo clients tend to fall apart. Odoo returns all errors as HTTP 200 responses with a JSON body containing an error key. Without a wrapper, you're parsing strings and guessing at error types.

odoo_rest_ov maps Odoo's error types to a typed exception hierarchy:

OdooException (base)
β”œβ”€β”€ OdooAccessDeniedException     β€” invalid credentials or locked account
β”œβ”€β”€ OdooSessionExpiredException   β€” session cookie has expired
β”œβ”€β”€ OdooAccessErrorException      β€” permission denied on a record or model
β”œβ”€β”€ OdooValidationException       β€” field validation failed
β”œβ”€β”€ OdooMissingErrorException     β€” record not found (deleted or wrong ID)
β”œβ”€β”€ OdooUserErrorException        β€” business logic rejection
β”œβ”€β”€ OdooNetworkException          β€” connectivity / timeout
└── OdooProtocolException         β€” malformed response

Every exception exposes two messages:

  • message β€” the raw Odoo error, suitable for logging
  • userMessage β€” a clean, human-readable string safe to show in a dialog or snackbar
dart
try {
  await client.write('res.partner', [999999], {'name': 'Ghost'});
} on OdooMissingErrorException catch (e) {
  // Show this in your UI β€” no internal details leak
  showDialog(context: context, builder: (_) => AlertDialog(
    title: const Text('Error'),
    content: Text(e.userMessage), // "Record does not exist or has been deleted."
  ));
  // Log the raw error separately
  logger.error(e.message);
} on OdooAccessErrorException catch (e) {
  showSnackBar(e.userMessage); // "You do not have permission to perform this action."
} on OdooNetworkException catch (e) {
  showSnackBar(e.userMessage); // "Network error. Please check your connection."
} on OdooException catch (e) {
  // Catch-all for any other Odoo error
  showSnackBar(e.userMessage);
}

The separation between message and userMessage matters in production. Internal Odoo errors often contain model names, field names, and SQL fragments that are meaningless to end users and a potential information leak. With typed exceptions, you never accidentally expose those strings in your UI.


Controller Calls

Not everything in Odoo goes through the ORM. Custom modules expose HTTP controllers β€” both JSON-RPC-wrapped endpoints and plain REST endpoints. The callController method handles both:

dart
// JSON-RPC endpoint (Odoo's standard format)
final response = await client.callController(
  '/web/webclient/version_info',
  params: {},
);
print(response.data);       // decoded result
print(response.isSuccess);  // true
print(response.statusCode); // 200

// REST-style GET endpoint (no JSON-RPC wrapper)
final rest = await client.callController(
  '/api/v1/products',
  method: 'GET',
  params: {'limit': '10', 'offset': '0'},
  isJsonRpc: false,
);

// Access result as list or map
final products = rest.dataAsList;

Authentication cookies are forwarded automatically with every request β€” no manual header management needed.


Reports and Binary Fields

Download a PDF Report

dart
final pdfBytes = await client.getReport(
  'account.report_invoice',
  [invoiceId],
);

// Save to file, open with a PDF viewer, or upload somewhere
await File('/tmp/invoice_$invoiceId.pdf').writeAsBytes(pdfBytes);

Upload an Attachment

dart
final imageBytes = await File('/tmp/logo.png').readAsBytes();

await client.uploadBinary(
  'res.partner',
  partnerId,
  'image_1920',
  imageBytes,
  filename: 'logo.png',
);

Download an Attachment

dart
final bytes = await client.downloadBinary(
  'res.partner',
  partnerId,
  'image_1920',
);

Flutter-Specific Setup: Persistent Sessions

On mobile, you want login sessions to survive app restarts. The package ships a OdooFlutter helper that wires up a PersistCookieJar backed by the app's documents directory:

dart
import 'package:odoo_rest_ov/odoo_rest_ov_flutter.dart';
import 'package:path_provider/path_provider.dart';

Future<OdooClient> createOdooClient() async {
  final dir = await getApplicationDocumentsDirectory();
  return OdooFlutter.createClient(
    baseUrl: 'https://mycompany.odoo.com',
    database: 'mydb',
    documentsPath: dir.path,
  );
}

The client returned is a standard OdooClient β€” the only difference is that the cookie jar persists to disk. If the user has a valid session from a previous app launch, the next checkSession() call will return true and no re-authentication is required.

On web, the browser manages cookies automatically with withCredentials: true β€” no extra setup is needed and OdooFlutter is not required.


User Type Detection

After authentication, you can branch UI logic based on who is logged in:

dart
final session = await client.authenticate('admin', 'admin');

// Enum-based check
switch (session.userType) {
  case OdooUserType.internal:
    showFullDashboard();
    break;
  case OdooUserType.portal:
    showPortalView();
    break;
  case OdooUserType.public:
    showGuestView();
    break;
}

// Boolean shortcuts
session.isInternalUser;  // true for employees
session.isPortalUser;    // true for portal customers
session.isPublic;        // true for anonymous users
session.isAdmin;         // true if user has admin access
session.isSystem;        // true if user has system-level access

Real-World Example: Partner List Screen

Putting it all together β€” a Flutter screen that fetches and displays a paginated list of company partners:

dart
class PartnerListScreen extends StatefulWidget {
  const PartnerListScreen({super.key});

  @override
  State<PartnerListScreen> createState() => _PartnerListScreenState();
}

class _PartnerListScreenState extends State<PartnerListScreen> {
  final _client = getIt<OdooClient>(); // from your DI container
  List<OdooRecord> _partners = [];
  bool _loading = true;
  String? _error;

  static const _fields = ['name', 'email', 'phone', 'country_id'];

  @override
  void initState() {
    super.initState();
    _fetchPartners();
  }

  Future<void> _fetchPartners() async {
    setState(() { _loading = true; _error = null; });
    try {
      final domain = OdooDomain()
          .where('is_company').equals(true)
          .where('active').equals(true)
          .build();

      final records = await _client.searchRead(
        'res.partner',
        domain,
        fields: _fields,
        limit: 50,
        order: 'name asc',
      );

      setState(() { _partners = records; _loading = false; });
    } on OdooSessionExpiredException {
      setState(() { _error = 'Session expired. Please log in again.'; _loading = false; });
    } on OdooException catch (e) {
      setState(() { _error = e.userMessage; _loading = false; });
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_loading) return const Center(child: CircularProgressIndicator());
    if (_error != null) return Center(child: Text(_error!));

    return ListView.builder(
      itemCount: _partners.length,
      itemBuilder: (context, index) {
        final p = _partners[index];
        return ListTile(
          title: Text(p.name),
          subtitle: Text(p['email'] ?? ''),
          trailing: Text(p.many2oneName('country_id') ?? ''),
        );
      },
    );
  }
}

The code reads cleanly because the complexity is pushed into the package: domain construction, HTTP transport, cookie management, error mapping, and relational field access are all handled transparently.


Platform Support

PlatformStatusNotes
AndroidSupportedUse OdooFlutter for persistent sessions
iOSSupportedUse OdooFlutter for persistent sessions
WebSupportedBrowser manages cookies automatically
macOSSupportedStandard OdooClient
LinuxSupportedStandard OdooClient
WindowsSupportedStandard OdooClient

Conclusion

odoo_rest_ov handles the parts of Odoo integration that are genuinely painful to get right on your own: the JSON-RPC error envelope, the relational field type coercion, the session cookie lifecycle, and the timezone mismatch. The typed exception hierarchy means your UI code never has to parse raw Odoo error strings, and the domain builder reduces a common source of runtime bugs.

The package is MIT-licensed, published on pub.dev, and open-source on GitHub. If you run into an issue or want to contribute, head to the repository.

Links: