The card beside it had boxes, so this one gets boxes

Charger information was one long list of everything the service knows;
the readings card next to it had been splitting its fields into a box
per group all along. Same treatment here: Device, Status, Network and
On the account, plus the service's own fields and the per-charger views,
each in its own sunken section under a heading. Both clients, since the
web and the phone draw the same card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-09-02 22:18:45 +02:00
1 parent 1a7f04cba0
commit fb42791f8d
8 files changed
+234 -166

No files matched your search

+6
View File
@@ -310,6 +310,12 @@
"relatedBy": "Kan nås via", "relatedBy": "Kan nås via",
"timeZone": "Tidszone", "timeZone": "Tidszone",
"linked": "Tilknyttet", "linked": "Tilknyttet",
"groups": {
"device": "Enhed",
"status": "Status",
"network": "Netværk",
"account": "På kontoen"
},
"views": { "views": {
"station": "Stationspost", "station": "Stationspost",
"totals": "Ladetotaler", "totals": "Ladetotaler",
+6
View File
@@ -310,6 +310,12 @@
"relatedBy": "Reachable by", "relatedBy": "Reachable by",
"timeZone": "Time zone", "timeZone": "Time zone",
"linked": "Linked", "linked": "Linked",
"groups": {
"device": "Device",
"status": "Status",
"network": "Network",
"account": "On the account"
},
"views": { "views": {
"station": "Station record", "station": "Station record",
"totals": "Charging totals", "totals": "Charging totals",
+6
View File
@@ -312,6 +312,12 @@
"relatedBy": "Dostępna przez", "relatedBy": "Dostępna przez",
"timeZone": "Strefa czasowa", "timeZone": "Strefa czasowa",
"linked": "Powiązano", "linked": "Powiązano",
"groups": {
"device": "Urządzenie",
"status": "Status",
"network": "Sieć",
"account": "Na koncie"
},
"views": { "views": {
"station": "Rekord stacji", "station": "Rekord stacji",
"totals": "Sumy ładowania", "totals": "Sumy ładowania",
+119 -112
View File
@@ -2342,83 +2342,74 @@ class _HomeTabState extends State<_HomeTab> {
children: [ children: [
if (charger != null) ...[ if (charger != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Row(children: [
padding: const EdgeInsets.all(12), // The product shot the app shows for this model, when the account
decoration: BoxDecoration( // sent one. Decorative: the name beside it says everything the
color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50, // picture does.
borderRadius: BorderRadius.circular(DriverVault.radiusControl), if ((_liveFor(charger)?.imageUrl ?? "").isNotEmpty) ...[
Image.network(_liveFor(charger)!.imageUrl,
width: 28,
height: 28,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
const SizedBox(width: 8),
],
Expanded(
child: Text(charger.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
), ),
child: Column( // Reachability, said either way. A charger the service says nothing
crossAxisAlignment: CrossAxisAlignment.start, // about stays silent: unknown is not offline.
children: [ if (_liveFor(charger)?.online == true) ...[
Row(children: [ _StatusBadge(label: t("charging.info.online"), ok: true),
// The product shot the app shows for this model, when the const SizedBox(width: 6),
// account sent one. Decorative: the name beside it says ] else if (_liveFor(charger)?.online == false) ...[
// everything the picture does. _StatusBadge(label: t("charging.info.offline"), ok: false),
if ((_liveFor(charger)?.imageUrl ?? "").isNotEmpty) ...[ const SizedBox(width: 6),
Image.network(_liveFor(charger)!.imageUrl, ],
width: 28, if (charger.provider.isNotEmpty)
height: 28, _NeutralBadge(label: _providerLabel(charger.provider)),
fit: BoxFit.contain, ]),
errorBuilder: (_, __, ___) => const SizedBox.shrink()), const SizedBox(height: 8),
const SizedBox(width: 8), // A box per group, the way the readings card draws its own: one list
], // of everything the service knows is harder to find a field in than
Expanded( // several short ones under headings.
child: Text(charger.name, for (final (heading, rows) in _infoGroups(charger)) ...[
maxLines: 1, _ReadingSection(heading: heading, child: _PairList(rows: rows, breakLong: true)),
overflow: TextOverflow.ellipsis, const SizedBox(height: 8),
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), ],
), // The rest of what the service knows, in the service's own words. It
// Reachability, said either way. A charger the service says // appears only when there is something in it, so a charger the cloud
// nothing about stays silent: unknown is not offline. // says nothing more about stays quiet.
if (_liveFor(charger)?.online == true) ...[ if (_attrRows(charger).isNotEmpty) ...[
_StatusBadge(label: t("charging.info.online"), ok: true), _ReadingSection(
const SizedBox(width: 6), heading: t("charging.info.rawTitle"),
] else if (_liveFor(charger)?.online == false) ...[ child: Column(
_StatusBadge(label: t("charging.info.offline"), ok: false), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(width: 6), children: [
],
if (charger.provider.isNotEmpty)
_NeutralBadge(label: _providerLabel(charger.provider)),
]),
const SizedBox(height: 8),
_PairList(rows: _infoRows(charger), breakLong: true),
// The rest of what the service knows, in the service's own
// words. It appears only when there is something in it, so a
// charger the cloud says nothing more about stays quiet.
if (_attrRows(charger).isNotEmpty) ...[
const SizedBox(height: 10),
Text(
t("charging.info.rawTitle"),
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted)
.copyWith(letterSpacing: 1.4),
),
const SizedBox(height: 4),
_PairList(rows: _attrRows(charger), breakLong: true), _PairList(rows: _attrRows(charger), breakLong: true),
const SizedBox(height: 4), const SizedBox(height: 6),
Text(t("charging.info.rawHint"), style: TextStyle(fontSize: 11, color: muted)), Text(t("charging.info.rawHint"), style: TextStyle(fontSize: 11, color: muted)),
], ],
// The views that answer per charger rather than per account. ),
// Each says what it knows, or why it could not be read — an
// account that is not the charger's owner cannot read the
// cards, which is a fact about the account rather than a
// failure.
for (final view in _detailViews(charger)) ...[
const SizedBox(height: 10),
Text(
t("charging.info.views.${view.id}"),
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted)
.copyWith(letterSpacing: 1.4),
),
const SizedBox(height: 4),
if (view.error.isNotEmpty)
Text(view.error, style: TextStyle(fontSize: 11, color: muted))
else
_PairList(rows: view.rows, breakLong: true),
],
],
), ),
), const SizedBox(height: 8),
],
// The views that answer per charger rather than per account. Each says
// what it knows, or why it could not be read — an account that is not
// the charger's owner cannot read the cards, which is a fact about the
// account rather than a failure.
for (final view in _detailViews(charger)) ...[
_ReadingSection(
heading: t("charging.info.views.${view.id}"),
child: view.error.isNotEmpty
? Text(view.error, style: TextStyle(fontSize: 11, color: muted))
: _PairList(rows: view.rows, breakLong: true),
),
const SizedBox(height: 8),
],
], ],
if (_homeChargers.isEmpty) if (_homeChargers.isEmpty)
Padding( Padding(
@@ -2437,49 +2428,65 @@ class _HomeTabState extends State<_HomeTab> {
); );
} }
/// Everything the card can say about one charger. Every row is drawn every /// Everything the card can say about one charger, in groups: what the box is,
/// time, a field nothing supplied included: which fields a charger has an /// what it is doing, how it is connected, and how it sits on the account. Each
/// answer for is itself worth seeing, and a row that comes and goes with the /// group gets a box of its own, the way the readings card draws its groups.
/// data makes two chargers impossible to read against each other. Nothing to /// Every row is drawn every time, a field nothing supplied included: which
/// say is said with a dash. /// fields a charger has an answer for is itself worth seeing, and a row that
List<(String, String)> _infoRows(HomeCharger c) { /// comes and goes with the data makes two chargers impossible to read against
/// each other. Nothing to say is said with a dash.
List<(String, List<(String, String)>)> _infoGroups(HomeCharger c) {
final live = _liveFor(c); final live = _liveFor(c);
final pairs = <(String, String)>[ final groups = <(String, List<(String, String)>)>[
("vendor", c.vendor), ("device", [
("model", c.model), ("vendor", c.vendor),
("firmware", live?.firmware ?? ""), ("model", c.model),
("serial", c.serial), ("firmware", live?.firmware ?? ""),
("site", c.siteName.isNotEmpty ? c.siteName : (live?.siteName ?? "")), ("serial", c.serial),
("siteId", live?.siteId ?? ""), ("power", c.powerKw > 0 ? "${c.powerKw} kW" : ""),
("sources", _sourcesLabel(live?.sources ?? const [])), ("connector", c.connector),
("power", c.powerKw > 0 ? "${c.powerKw} kW" : ""), ]),
("connector", c.connector), ("status", [
("state", _stateLabel(live?.status ?? "")), ("state", _stateLabel(live?.status ?? "")),
// Relayed as the service words it — the unit is upstream's, so putting one // Relayed as the service words it — the unit is upstream's, so putting
// on it here would be inventing it. // one on it here would be inventing it.
("chargePower", live?.power ?? ""), ("chargePower", live?.power ?? ""),
("ocpp", live?.ocppLabel ?? ""), ("ocpp", live?.ocppLabel ?? ""),
// The box on the wall, as opposed to the charging: the networks it is on, ]),
// where it thinks it is, and when the account first saw it. // The box on the wall, as opposed to the charging: the networks it is on.
("wifiName", live?.wifiName ?? ""), ("network", [
("wifiMac", live?.wifiMac ?? ""), ("wifiName", live?.wifiName ?? ""),
("signal", live?.wifiRssi == null ? "" : "${live!.wifiRssi} dBm"), ("wifiMac", live?.wifiMac ?? ""),
("bleMac", live?.bleMac ?? ""), ("signal", live?.wifiRssi == null ? "" : "${live!.wifiRssi} dBm"),
("relatedBy", (live?.relatedBy ?? const []).join(" · ")), ("bleMac", live?.bleMac ?? ""),
("timeZone", live?.timeZone ?? ""), ("relatedBy", (live?.relatedBy ?? const []).join(" · ")),
( ]),
"linked", // Where it thinks it is, and how the account came to know it.
live?.linkedAt == null ("account", [
? "" ("site", c.siteName.isNotEmpty ? c.siteName : (live?.siteName ?? "")),
: formatDateTime( ("siteId", live?.siteId ?? ""),
DateTime.fromMillisecondsSinceEpoch((live!.linkedAt! * 1000).round())) ("sources", _sourcesLabel(live?.sources ?? const [])),
), ("timeZone", live?.timeZone ?? ""),
("providerId", c.providerChargerId), (
("added", c.created.isEmpty ? "" : formatDateTime(DateTime.tryParse(c.created)?.toLocal())), "linked",
live?.linkedAt == null
? ""
: formatDateTime(
DateTime.fromMillisecondsSinceEpoch((live!.linkedAt! * 1000).round()))
),
("providerId", c.providerChargerId),
("added", c.created.isEmpty ? "" : formatDateTime(DateTime.tryParse(c.created)?.toLocal())),
]),
]; ];
return [ return [
for (final (key, value) in pairs) for (final (id, pairs) in groups)
(t("charging.info.$key"), value.isEmpty ? "—" : value), (
t("charging.info.groups.$id"),
[
for (final (key, value) in pairs)
(t("charging.info.$key"), value.isEmpty ? "—" : value),
],
),
]; ];
} }
+6
View File
@@ -270,6 +270,12 @@
"relatedBy": "Kan nås via", "relatedBy": "Kan nås via",
"timeZone": "Tidszone", "timeZone": "Tidszone",
"linked": "Tilknyttet", "linked": "Tilknyttet",
"groups": {
"device": "Enhed",
"status": "Status",
"network": "Netværk",
"account": "På kontoen"
},
"views": { "views": {
"station": "Stationspost", "station": "Stationspost",
"totals": "Ladetotaler", "totals": "Ladetotaler",
+6
View File
@@ -256,6 +256,12 @@
"relatedBy": "Reachable by", "relatedBy": "Reachable by",
"timeZone": "Time zone", "timeZone": "Time zone",
"linked": "Linked", "linked": "Linked",
"groups": {
"device": "Device",
"status": "Status",
"network": "Network",
"account": "On the account"
},
"views": { "views": {
"station": "Station record", "station": "Station record",
"totals": "Charging totals", "totals": "Charging totals",
+6
View File
@@ -272,6 +272,12 @@
"relatedBy": "Dostępna przez", "relatedBy": "Dostępna przez",
"timeZone": "Strefa czasowa", "timeZone": "Strefa czasowa",
"linked": "Powiązano", "linked": "Powiązano",
"groups": {
"device": "Urządzenie",
"status": "Status",
"network": "Sieć",
"account": "Na koncie"
},
"views": { "views": {
"station": "Rekord stacji", "station": "Rekord stacji",
"totals": "Sumy ładowania", "totals": "Sumy ładowania",
+79 -54
View File
@@ -868,44 +868,59 @@ function homeChargerSubtitle(c) {
return [c.serial, c.model, c.siteName].filter(Boolean).join(" · "); return [c.serial, c.model, c.siteName].filter(Boolean).join(" · ");
} }
// Everything the card can say about one charger, as label/value rows. Every row // Everything the card can say about one charger, in groups: what the box is,
// is drawn every time, a field nothing supplied included: which fields a charger // what it is doing, how it is connected, and how it sits on the account. Four
// has an answer for is itself worth seeing, and a row that comes and goes with // short lists under headings, one box each the way the readings card draws its
// the data makes two chargers side by side impossible to read against each // own, beat one long list nobody can find a field in. Every row is drawn every
// other. Nothing to say is said with a dash. // time, a field nothing supplied included: which fields a charger has an answer
function chargerInfoRows(c) { // for is itself worth seeing, and a row that comes and goes with the data makes
// two chargers side by side impossible to read against each other. Nothing to
// say is said with a dash.
function chargerInfoGroups(c) {
const live = liveFor(c) || {}; const live = liveFor(c) || {};
const rows = [ const groups = [
["vendor", c.vendor], ["device", [
["model", c.model], ["vendor", c.vendor],
["firmware", live.firmware], ["model", c.model],
["serial", c.serial], ["firmware", live.firmware],
["site", c.siteName || live.siteName], ["serial", c.serial],
["siteId", live.siteId], ["power", c.powerKw ? `${c.powerKw} kW` : ""],
["sources", sourcesLabel(live.sources)], ["connector", c.connector],
["power", c.powerKw ? `${c.powerKw} kW` : ""], ]],
["connector", c.connector], ["status", [
["state", chargerStateLabel(live.status)], ["state", chargerStateLabel(live.status)],
// Relayed as the service words it — the unit is upstream's, so putting one // Relayed as the service words it — the unit is upstream's, so putting one
// on it here would be inventing it. // on it here would be inventing it.
["chargePower", live.power], ["chargePower", live.power],
["ocpp", ocppStatusLabel(live)], ["ocpp", ocppStatusLabel(live)],
// The box on the wall, as opposed to the charging: the networks it is on, ]],
// where it thinks it is, and when the account first saw it. // The box on the wall, as opposed to the charging: the networks it is on.
["wifiName", live.wifiName], ["network", [
["wifiMac", live.wifiMac], ["wifiName", live.wifiName],
["signal", isSet(live.wifiRssi) ? `${live.wifiRssi} dBm` : ""], ["wifiMac", live.wifiMac],
["bleMac", live.bleMac], ["signal", isSet(live.wifiRssi) ? `${live.wifiRssi} dBm` : ""],
["relatedBy", (live.relatedBy || []).join(" · ")], ["bleMac", live.bleMac],
["timeZone", live.timeZone], ["relatedBy", (live.relatedBy || []).join(" · ")],
["linked", live.linkedAt ? formatDateTime(new Date(live.linkedAt * 1000)) : ""], ]],
["providerId", c.providerChargerId], // Where it thinks it is, and how the account came to know it.
["added", c.created ? formatDateTime(c.created) : ""], ["account", [
["site", c.siteName || live.siteName],
["siteId", live.siteId],
["sources", sourcesLabel(live.sources)],
["timeZone", live.timeZone],
["linked", live.linkedAt ? formatDateTime(new Date(live.linkedAt * 1000)) : ""],
["providerId", c.providerChargerId],
["added", c.created ? formatDateTime(c.created) : ""],
]],
]; ];
return rows.map(([key, value]) => ({ return groups.map(([id, rows]) => ({
key, id,
label: t(`charging.info.${key}`), label: t(`charging.info.groups.${id}`),
value: value === "" || value == null ? "—" : value, rows: rows.map(([key, value]) => ({
key,
label: t(`charging.info.${key}`),
value: value === "" || value == null ? "—" : value,
})),
})); }));
} }
@@ -1865,8 +1880,11 @@ onMounted(async () => {
</div> </div>
<div v-show="isOpen('info')"> <div v-show="isOpen('info')">
<!-- The charger picked in the list beside this card, and only it. --> <!-- The charger picked in the list beside this card, and only it. Its
<div v-if="selectedHomeCharger" class="mt-3 rounded-control bg-sunken p-3"> fields are split into a box each, the way the readings card draws
its groups: one list of everything the service knows is harder to
find a field in than several short ones under headings. -->
<div v-if="selectedHomeCharger" class="mt-3 flex flex-col gap-2">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
<!-- The product shot the app shows for this model, when the <!-- The product shot the app shows for this model, when the
@@ -1894,41 +1912,48 @@ onMounted(async () => {
</span> </span>
</div> </div>
</div> </div>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1"> <section
<template v-for="row in chargerInfoRows(selectedHomeCharger)" :key="row.key"> v-for="group in chargerInfoGroups(selectedHomeCharger)"
<dt class="text-[11px] text-muted">{{ row.label }}</dt> :key="group.id"
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd> class="rounded-control bg-sunken p-3"
</template> >
</dl> <h4 class="eyebrow">{{ group.label }}</h4>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in group.rows" :key="row.key">
<dt class="text-[11px] text-muted">{{ row.label }}</dt>
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
</template>
</dl>
</section>
<!-- The rest of what the service knows, in the service's own words. <!-- The rest of what the service knows, in the service's own words.
It only appears when there is something in it, which is also how It only appears when there is something in it, which is also how
a charger the cloud says nothing more about stays quiet. --> a charger the cloud says nothing more about stays quiet. -->
<template v-if="chargerAttrRows(selectedHomeCharger).length"> <section v-if="chargerAttrRows(selectedHomeCharger).length" class="rounded-control bg-sunken p-3">
<p class="eyebrow mt-3">{{ t("charging.info.rawTitle") }}</p> <h4 class="eyebrow">{{ t("charging.info.rawTitle") }}</h4>
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1"> <dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in chargerAttrRows(selectedHomeCharger)" :key="row.key"> <template v-for="row in chargerAttrRows(selectedHomeCharger)" :key="row.key">
<dt class="data break-all text-[11px] text-muted">{{ row.key }}</dt> <dt class="data break-all text-[11px] text-muted">{{ row.key }}</dt>
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd> <dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
</template> </template>
</dl> </dl>
<p class="mt-1 text-[11px] text-muted">{{ t("charging.info.rawHint") }}</p> <p class="mt-2 text-[11px] text-muted">{{ t("charging.info.rawHint") }}</p>
</template> </section>
<!-- The views that answer per charger rather than per account. Each <!-- The views that answer per charger rather than per account. Each
says what it knows, or why it could not be read — an account says what it knows, or why it could not be read — an account
that is not the charger's owner cannot read the cards, which is that is not the charger's owner cannot read the cards, which is
a fact about the account rather than a failure. --> a fact about the account rather than a failure. -->
<template v-for="view in chargerDetailViews" :key="view.id"> <section v-for="view in chargerDetailViews" :key="view.id" class="rounded-control bg-sunken p-3">
<p class="eyebrow mt-3">{{ t(`charging.info.views.${view.id}`) }}</p> <h4 class="eyebrow">{{ t(`charging.info.views.${view.id}`) }}</h4>
<p v-if="view.error" class="mt-1 text-[11px] text-muted">{{ view.error }}</p> <p v-if="view.error" class="mt-2 text-[11px] text-muted">{{ view.error }}</p>
<dl v-else class="mt-1 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1"> <dl v-else class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in view.rows" :key="row.key"> <template v-for="row in view.rows" :key="row.key">
<dt class="data break-all text-[11px] text-muted">{{ row.key }}</dt> <dt class="data break-all text-[11px] text-muted">{{ row.key }}</dt>
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd> <dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
</template> </template>
</dl> </dl>
</template> </section>
</div> </div>
<p v-if="homeChargers.length === 0" class="mt-2 text-xs text-muted"> <p v-if="homeChargers.length === 0" class="mt-2 text-xs text-muted">