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