Most EV-charging app tutorials stop at "scan a QR code, call an API, show a spinner". Real chargers are messier: they go offline mid-session, accept a start command and then never start, report the vehicle as suspended, and send meter readings late. This post covers the architecture I use for Flutter charging apps at AIZOTEQ, built so those cases are handled deliberately instead of showing up as bug reports.
The phone does not speak OCPP
OCPP (Open Charge Point Protocol) is the protocol between a charging station and the charging station management system (CSMS, called the Central System in OCPP 1.6). In OCPP-J the charger keeps a WebSocket open to the CSMS and exchanges JSON frames:
[2, "19223201", "StatusNotification", { "connectorId": 1, "status": "Charging", "errorCode": "NoError" }]
The mobile app is not part of that conversation. The flow looks like this:
- The app asks your backend to start charging on a specific charger and connector.
- The backend (acting as CSMS) sends
RemoteStartTransaction(OCPP 1.6) orRequestStartTransaction(OCPP 2.0.1) to the charger. - The charger replies
AcceptedorRejected, and later, once energy actually flows, reports the transaction withStartTransaction(1.6) orTransactionEventwitheventType: Started(2.0.1). - The backend pushes those state changes to the app over MQTT or a WebSocket.
Keeping OCPP on the server means the app never holds charger credentials, never has to deal with the charger's flaky connection directly, and doesn't have to change when you add OCPP 2.0.1 next to 1.6.
"Accepted" is not "charging"
The most common bug in charging apps is treating the response to a remote start as success. Accepted only means the charger agreed to try. After that, the driver may not have plugged in yet, the vehicle may refuse to draw power, or the charger may fault.
In OCPP 1.6 a connector moves through statuses such as Available → Preparing → Charging, with SuspendedEV, SuspendedEVSE, Finishing and Faulted along the way. OCPP 2.0.1 makes connector status coarser (Available, Occupied, Reserved, Unavailable, Faulted) and moves the detail into TransactionEvent.chargingState (EVConnected, Charging, SuspendedEV, SuspendedEVSE, Idle). The app should only show "Charging" when the backend has seen the transaction actually start, not when the command was accepted.
Model the session as a state machine
Dart 3 sealed classes are ideal here, because the compiler forces every screen to handle every state:
sealed class ChargingSession {
const ChargingSession();
}
class Idle extends ChargingSession {
const Idle();
}
class Starting extends ChargingSession {
const Starting({required this.requestedAt});
final DateTime requestedAt;
}
class WaitingForVehicle extends ChargingSession {
const WaitingForVehicle();
}
class Charging extends ChargingSession {
const Charging({required this.transactionId, required this.energyKWh, required this.powerKW});
final String transactionId;
final double energyKWh;
final double powerKW;
}
class Suspended extends ChargingSession {
const Suspended({required this.transactionId, required this.byVehicle});
final String transactionId;
final bool byVehicle; // SuspendedEV vs SuspendedEVSE
}
class Completed extends ChargingSession {
const Completed({required this.energyKWh, required this.duration});
final double energyKWh;
final Duration duration;
}
class Failed extends ChargingSession {
const Failed(this.reason);
final String reason;
}
The UI then becomes an exhaustive switch:
Widget buildSession(ChargingSession session) => switch (session) {
Idle() => const StartChargingButton(),
Starting() => const ConnectingToCharger(),
WaitingForVehicle() => const PlugInPrompt(),
Charging(:final energyKWh, :final powerKW) => LiveStats(energyKWh: energyKWh, powerKW: powerKW),
Suspended(:final byVehicle) => SuspendedNotice(byVehicle: byVehicle),
Completed(:final energyKWh, :final duration) => SessionSummary(energyKWh: energyKWh, duration: duration),
Failed(:final reason) => RetryCard(reason: reason),
};
Add a new state later and every switch that forgets it fails to compile, which is exactly what you want in a payments-adjacent flow. Whether the transitions live in a BLoC, a Cubit or a Riverpod notifier matters much less than having them in one place.
Edge cases worth designing for up front
Timeouts on every waiting state. If the app sits in Starting or WaitingForVehicle forever, users force-quit and retry, and you end up with duplicate sessions. Give each waiting state a deadline (the backend usually knows the charger's ConnectionTimeOut) and move to Failed with a clear message when it passes.
Double taps and retries. Send an idempotency key with the start request so that a retried or double-tapped "Start" maps to the same session on the backend instead of creating two.
Missed pushes. MQTT and WebSocket messages are lost while the app is in the background or switching networks. On every resume or reconnect, fetch the current session snapshot over REST and replace local state with it. Treat pushes as "something changed, here's a hint", not the source of truth.
Late and out-of-order meter values. MeterValues arrive at the charger's configured sample interval and may come after the stop event. Show live energy from the latest reading, but take the final energy and cost from the backend's closed transaction. The client should never calculate what the user pays.
Charger offline mid-session. A charger that loses its connection keeps charging and replays transaction messages when it reconnects. Show "Charger offline: your session continues" rather than ending the session in the UI.
The QR code is an identity, not a command. Encode the charge point ID and connector ID, resolve them to a charger on the backend, and check availability and tariff before showing the start button.
Payments
Authorize or hold the amount before starting, settle against the final metered energy after StopTransaction / TransactionEvent(Ended), and make the receipt screen read from the backend. That keeps refunds, partial sessions and tariff changes in one place.
Takeaways
- Keep OCPP on the server; the app talks to your API and receives pushes.
- Only show "Charging" when the transaction has actually started.
- Model the session as a sealed-class state machine with a timeout on every waiting state.
- Resync from REST on every resume, and treat realtime messages as hints.
- Let the backend own energy totals and money.
I build Flutter apps for EV-charging and IoT products. If you're working on one, get in touch or see the EV charging app case study.