The chargers you own, on the phone as well

The web's Charging page grew a real home half while the phone kept a demo one.
There, a charger is a record imported from a connected service; here it was a
hardcoded row called "Home charger", and the only real thing on the tab was a
single OCPP control card. Modbus had been a working transport for a while, and
the phone had no way to give it the address it needs.

The home tab is now the four cards the web shows, about whichever charger is
picked, and the list of the ones you have imported. Control acts on the charger
and offers what the transport actually has — boost on Modbus, clear-limit and
reset on OCPP. Connection asks for a serial or an address depending on which,
and falls back to a text box for a serial the account does not list. Readings
render the Modbus snapshot the way it gets asked about: the per-phase matrix,
what the charger is doing, what it is set to, what it is, and any alarm word.
Information stands without a control mode at all, because what a charger is is
known either way; beside it the service's own view of whether it is reachable,
asked for when the tab is opened rather than on every build.

Rearranging is the one place the two apps differ, for the reason the car's view
picker already differs: the web drags the tab bar and the card headings, and on
a touch screen the bar owns that gesture and a heading is the fold toggle. Both
arrangements are made in a sheet with handles instead, and still saved to
chargerTabOrder / chargerCardOrder on the profile — so an arrangement made in
either app shows up in the other. Which cards are folded stays on the device.

Settings groups its integrations into the same categories the API Server panel
does, says Online as well as Offline, and shows firmware in a charger's line.
Shared strings are copied out of the web's i18n files rather than retyped, per
TRANSLATIONS.md; only the arrange sheet's own three are written here.

The readings and information cards look up some sixty keys by name at render
time, so models_format_test now guards those the way it guards the car's — an
enum value is deliberately left out, since a charger may report a number this
release has never heard of and falling back to it is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-09-02 08:27:16 +02:00
1 parent 3c64d6e84c
commit 641f427db4
11 files changed
+2961 -339

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
// The charging page's home half.
//
// Nothing here can talk to a server, so what is worth guarding is what the page
// says without one: that both tabs render, that the home tab degrades to the
// "import one" invitation rather than a blank column, and that the arrange sheet
// offers exactly the tabs and cards the profile can store — those key sets are
// shared with the web app and the server, so a card added here and forgotten
// there would arrange into nothing.
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
import "package:intl/date_symbol_data_local.dart";
import "package:shared_preferences/shared_preferences.dart";
import "package:drivervault_phone/i18n.dart";
import "package:drivervault_phone/main.dart";
import "package:drivervault_phone/screens/charging_screen.dart";
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
await initializeDateFormatting();
await loadTranslations();
appSettings.locale = "en-GB";
appSettings.dateFormat = "DMY_NUM";
});
setUp(() {
// The page reads its folded cards and its last serial from here.
SharedPreferences.setMockInitialValues({});
});
// A surface tall enough that the page's lazy list builds the home cards as
// well as the header above them.
Future<void> pump(WidgetTester tester) async {
tester.view.physicalSize = const Size(800, 3000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(const MaterialApp(home: ChargingScreen()));
await tester.pumpAndSettle();
}
testWidgets("both tabs render, and the home half invites an import", (tester) async {
await pump(tester);
expect(find.text(t("charging.tabs.public")), findsWidgets);
expect(find.text(t("charging.tabs.home")), findsWidgets);
// The page opens on the public half; the tab that is not shown is not built,
// so the home cards are only findable once it is.
expect(find.textContaining(t("charging.stations.heading")), findsWidgets);
await tester.tap(find.text(t("charging.tabs.home")));
await tester.pumpAndSettle();
// With no chargers and no control mode, the home tab is the information card
// saying there is nothing yet plus the list saying what it is for.
expect(find.text(t("charging.info.title")), findsOneWidget);
expect(find.text(t("charging.info.empty")), findsOneWidget);
expect(find.text(t("charging.home.empty")), findsOneWidget);
// No charger service answered, so importing is not on offer — the hint is.
expect(find.text(t("charging.home.connectFirst")), findsOneWidget);
expect(find.text(t("charging.home.import")), findsNothing);
// Control is off, so the cards that act on a charger are absent and the
// information card says why.
expect(find.text(t("charging.control.title")), findsNothing);
expect(find.text(t("charging.control.connectionTitle")), findsNothing);
expect(find.text(t("charging.modbus.title")), findsNothing);
expect(find.text(t("charging.stations.noControlHint")), findsOneWidget);
});
testWidgets("the arrange sheet offers every tab and every card", (tester) async {
await pump(tester);
await tester.tap(find.byIcon(Icons.swap_vert));
await tester.pumpAndSettle();
expect(find.text(t("charging.arrange.title")), findsOneWidget);
for (final key in kChargerTabKeys) {
expect(find.text(t("charging.tabs.$key")), findsWidgets,
reason: "the $key tab is not on offer");
}
// Named by their own headings, so the sheet reads like the page.
const cardLabels = {
"control": "charging.control.title",
"connection": "charging.control.connectionTitle",
"readings": "charging.modbus.title",
"info": "charging.info.title",
};
expect(cardLabels.keys.toSet(), kChargerCardKeys.toSet());
for (final key in kChargerCardKeys) {
expect(find.text(t(cardLabels[key]!)), findsWidgets,
reason: "the $key card is not on offer");
}
// One drag handle per row: both lists rearrange, not just the first.
expect(find.byIcon(Icons.drag_handle),
findsNWidgets(kChargerTabKeys.length + kChargerCardKeys.length));
});
}
+46
View File
@@ -17,6 +17,7 @@ import "package:drivervault_phone/main.dart";
import "package:drivervault_phone/models.dart";
import "package:drivervault_phone/screens/car_form_sheet.dart";
import "package:drivervault_phone/screens/car_view_sheet.dart";
import "package:drivervault_phone/screens/charging_screen.dart";
import "package:drivervault_phone/service_parts.dart";
void main() {
@@ -498,6 +499,51 @@ void main() {
expectLabelled("car.provider.metrics.$key");
}
});
test("the charging page's tabs and cards", () {
for (final key in kChargerTabKeys) {
expectLabelled("charging.tabs.$key");
}
// The cards are named by their own headings rather than by their keys, so
// the arrange sheet reads like the page. Those headings are the labels.
for (final key in ["charging.control.title", "charging.control.connectionTitle",
"charging.modbus.title", "charging.info.title"]) {
expectLabelled(key);
}
});
test("everything the charger information card names", () {
// Every row is drawn every time, so a missing label is a raw key path in
// the middle of the card rather than a row that quietly stays away.
for (final key in ["vendor", "model", "firmware", "serial", "site", "siteId", "sources",
"power", "connector", "state", "chargePower", "ocpp", "providerId",
"added", "online", "offline", "refresh", "title", "empty"]) {
expectLabelled("charging.info.$key");
}
// The service's own word for how a charger is registered. Mirrors the
// sources a chargerSource reports in the API's chargerproviders.go; an
// unlisted one falls back to the slug, which is why these are named.
for (final key in ["standalone", "site", "bound"]) {
expectLabelled("charging.info.sourceNames.$key");
}
});
test("every Modbus reading the card can show", () {
// Mirrors the fields of ModbusStatus in the API's ankersolix/modbus.go
// that the readings card asks for by name. The enum values below it are
// deliberately not here: an unlisted number falls back to itself, because
// the charger may report one this release has never heard of.
for (final key in ["title", "phases", "live", "settings", "device", "alarms", "phase",
"voltage", "current", "activePower", "reactivePower", "apparentPower",
"lineToLine", "power", "sessionDuration", "cpSignal", "cpVoltage",
"phaseMode", "relayTemps", "pwm", "maxCurrentSet", "timeout",
"phaseSetting", "boostSet", "lastCommand", "chargingMode",
"loadBalancing", "solarBalancing", "ledBrightness", "model", "serial",
"firmware", "hardware", "productNumber", "ratedPower", "currentRange",
"ocppLink", "mqttLink", "alarmsHint"]) {
expectLabelled("charging.modbus.$key");
}
});
});
test("no key is left behind in a translation", () {