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),
],
),
];
}