Twenty-eight commits landed on the web app and the API since the phone was last touched, and the phone's own README opens by claiming full feature parity. It was not a small drift: a whole tab, two whole cards, and the two settings that decide how a time is read. The scheduler arrives as the third charging tab. One list of tasks covering every charger the account owns, where the charger's own cloud schedule is one window inside one box. A task is a flow — start at 23:00, cap to 10 A at 01:00, stop at 06:30 — on the days and the chargers it names, and naming no charger means all of them, including the ones imported later. The clock is the server's, so the tab only writes tasks and reads back how each one last went, and any step can be fired now to find out whether it will reach the charger before the night it matters. The RFID card comes with it: the list the account holds, a card added by its number or by holding it against the charger's own reader, and the charger's own list read back from the device. Both halves are written by every add and remove and they can still come apart, so when they disagree the card says which list each card is missing from — nothing else on the page would. The charger settings card the phone never had at all goes in whole rather than only its new half. Over Modbus that is the four writable registers; over the cloud it is the charger's whole settings group in sections, drawn from the same block table the web reads, one write per section because the charger takes a command whole and a schedule carrying only its switch is a schedule whose times have just been set to midnight. The clock and the week become settings. format.dart grows formatTime, the weekday order and the short names, with "auto" asking intl's own hour pattern and FIRSTDAYOFWEEK rather than a table here; Settings › Appearance asks both questions beneath the date. Flutter's own picker renders on the device locale, which nothing in this app steers, so TimeField types four digits on whichever clock is in force and keeps the meridiem as its own control — a box reading 13:45 beside a dial saying 01:45 PM is the disagreement the setting exists to end. The smaller ones travel too. The control card says which charger its buttons drive, picture and name, because it follows a serial and not the highlighted row; its two tiles take the names of the readings they actually hold; and the limit slider leaves it wherever a settings card now owns that value. The list's reachability re-asks every thirty seconds while the tab is in front, merged rather than replaced — "we could not ask" is not an answer, and it certainly is not "unknown". A settings frame that answers half a minute late is chased at widening gaps and then given up on. The information card names the fields the service sent under its own names and groups list records under their own, so list[0].* stops being read as one alphabetical run. An inherited integration field shows what it inherited rather than an example. The sign-in fields say nothing until you type. One gap stays open, and deliberately. The task form sends the phone's zone only when Dart reports an IANA name; Android usually answers with an abbreviation like CEST, which is not a zone, so it sends nothing and the server falls back to its own clock. A name the server would misread is worse than no name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
103 lines
4.2 KiB
Dart
103 lines
4.2 KiB
Dart
// 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",
|
|
"rfid": "charging.rfid.title",
|
|
"settings": "charging.modbus.settingsTitle",
|
|
"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));
|
|
});
|
|
}
|