From 94910774973c79cf7e7590e486b40a7717a566a6 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 9 Sep 2026 07:39:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(=C3=A9nergie):=20l'axe=20X=20de=20la=20vue?= =?UTF-8?q?=20Heures=20se=20glisse=20=E2=80=94=20par=20fen=C3=AAtre,=20pas?= =?UTF-8?q?=20par=20d=C3=A9filement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demande de Patrick. Fait par FENÊTRE glissante (minX/maxX) plutôt qu'en faisant défiler le graphe entier : l'échelle en kW reste visible pendant le geste, alors qu'un défilement horizontal l'emporterait hors du champ au moment où on en a besoin. ⚠️ On borne minX/maxX, on ne DÉCOUPE pas les données : découper renumérotrait les index, et l'axe du bas — qui lit `_data[x.round()]` — désignerait alors les mauvaises heures. Les séries restent entières, la fenêtre ne fait que cadrer. 96 points au quart d'heure sur 24 h : tout montrer écrase le détail, et il n'y a alors rien à faire glisser. La fenêtre en montre la moitié. Trois détails qui vont avec : - le geste convertit des pixels en points d'échantillon, donc glisser d'une largeur d'écran déplace d'une fenêtre entière quelle que soit la densité ; - la fenêtre est bornée aux données — glisser dans le vide ferait croire qu'il n'y a rien à cet endroit, alors qu'il n'y a pas d'endroit ; - l'espacement des étiquettes se calcule sur ce qui est VISIBLE : le calculer sur le jeu entier en placerait trois, mal réparties. Le détecteur de geste n'est posé que sur les vues glissables : envelopper un graphe qui ne glisse pas volerait le défilement vertical de la page pour rien. Et l'indice ne sort que là où le geste existe. Les deux graphes partagent le même cadrage — comparer deux fenêtres différentes n'aurait aucun sens. 358 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw --- lib/screens/energy_screen.dart | 104 +++++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 10 deletions(-) diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index 7bcdc13..f333ce1 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -73,6 +73,45 @@ class _EnergyScreenState extends State { /// C'est délibéré : les tuiles disent la journée, les graphes disent la fenêtre. DailyBalance? _bilan; + /// **Le premier point visible** de la fenêtre glissante, en index d'échantillon. + /// + /// La vue « Heures » porte 96 points (24 h au quart d'heure) : tout montrer d'un coup + /// écrase le détail, et il n'y a alors rien à faire glisser. On en montre donc la moitié + /// et on se déplace dedans. + /// + /// ⚠️ **On borne minX/maxX, on ne DÉCOUPE pas les données.** Découper renumérotrait les + /// index, et l'axe du bas — qui lit `_data[x.round()]` — désignerait alors les mauvaises + /// heures. Les séries restent entières, la fenêtre ne fait que cadrer. + double _fenetreDebut = 0; + + /// Nombre de points visibles à la fois. `null` = tout, donc rien à glisser. + int? get _fenetreTaille { + if (!_tabs[_tabIdx].showTime) return null; // seule la vue Heures glisse + return _data.length > 40 ? _data.length ~/ 2 : null; + } + + bool get _glissable => _fenetreTaille != null; + + /// Dernier point visible. Sans fenêtre, c'est tout le jeu de données. + double get _fenetreFin { + final taille = _fenetreTaille; + final dernier = (_data.length - 1).toDouble(); + if (taille == null) return dernier; + return (_fenetreDebut + taille).clamp(0, dernier).toDouble(); + } + + /// Déplace la fenêtre, **bornée aux données** : on ne glisse pas dans le vide. + /// + /// Le vide serait pire qu'une butée — il ferait croire qu'il n'y a rien à cet endroit, + /// alors qu'il n'y a simplement pas d'endroit. + void _glisser(double deltaPoints) { + final taille = _fenetreTaille; + if (taille == null) return; + final maxDebut = (_data.length - 1 - taille).toDouble(); + setState(() => + _fenetreDebut = (_fenetreDebut + deltaPoints).clamp(0, max(0, maxDebut))); + } + /// Vrai quand la box a livré ses Things ET qu'aucun ne porte de batterie. /// /// Les deux moitiés comptent : tant que les Things ne sont pas chargés, « pas de @@ -200,6 +239,9 @@ class _EnergyScreenState extends State { ]); if (!mounted) return; setState(() { + // La fenêtre repart du début à chaque lecture : la garder ferait cadrer une plage + // d'index qui ne désigne plus les mêmes heures. + _fenetreDebut = 0; _data = results[0] as List; _socData = results[1] as List; _conso = results[2] as ConsumerHistory; @@ -330,7 +372,7 @@ class _EnergyScreenState extends State { height: 220, loading: _loading, noData: _noData, - child: _buildLineChart(), + child: _glissant(_buildLineChart()), ), // La note d'absence de batterie va SOUS ce graphe-ci : c'est ici que // le SOC aurait été tracé, donc c'est ici qu'il manque. @@ -358,8 +400,16 @@ class _EnergyScreenState extends State { height: 220, loading: _loading, noData: _noData, - child: _buildConsumerChart(), + child: _glissant(_buildConsumerChart()), ), + // L'indice ne sort que si le geste existe : une aide permanente + // cesse d'être lue, et celle-ci serait fausse sur les autres vues. + if (_glissable) + const _NoteEcran( + icon: Icons.swipe_rounded, + texte: 'Glissez horizontalement sur un graphe pour vous déplacer ' + 'dans la journée. Les deux gardent le même cadrage.', + ), _notesConsommateurs(), const SizedBox(height: 14), @@ -377,7 +427,29 @@ class _EnergyScreenState extends State { // ── ① Line chart : puissances + SOC ──────────────────────────────────────── - Widget _buildLineChart() { + /// Rend un graphe **glissable à l'horizontale** quand la vue s'y prête. + /// + /// Le geste convertit des pixels en points d'échantillon : glisser d'une largeur d'écran + /// déplace d'une fenêtre entière, ce qui rend le mouvement indépendant de la densité des + /// données. Hors vue « Heures », le graphe est rendu tel quel — envelopper un widget + /// non glissable dans un détecteur de geste volerait le défilement vertical de la page + /// pour rien. + Widget _glissant(Widget graphe) { + if (!_glissable) return graphe; + final taille = _fenetreTaille!; + return LayoutBuilder( + builder: (context, c) => GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (d) { + final largeur = c.maxWidth <= 0 ? 1.0 : c.maxWidth; + _glisser(-d.delta.dx / largeur * taille); + }, + child: graphe, + ), + ); + } + + Widget _buildLineChart({bool axeGauche = true}) { if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink(); final prodSpots = []; @@ -440,6 +512,8 @@ class _EnergyScreenState extends State { return LineChart( LineChartData( clipData: const FlClipData.all(), + minX: _fenetreDebut, + maxX: _fenetreFin, minY: 0, maxY: yMax, gridData: FlGridData( @@ -456,8 +530,8 @@ class _EnergyScreenState extends State { // Axe gauche : kW leftTitles: AxisTitles( sideTitles: SideTitles( - showTitles: true, - reservedSize: 46, + showTitles: axeGauche, + reservedSize: axeGauche ? 46 : 0, interval: yInterval, getTitlesWidget: (v, meta) { if (v == meta.max) return const SizedBox.shrink(); // évite label dupliqué au sommet @@ -632,7 +706,7 @@ class _EnergyScreenState extends State { return out; } - Widget _buildConsumerChart() { + Widget _buildConsumerChart({bool axeGauche = true}) { if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink(); if (_conso.estVide) return const SizedBox.shrink(); @@ -705,6 +779,8 @@ class _EnergyScreenState extends State { LineChart( LineChartData( clipData: const FlClipData.all(), + minX: _fenetreDebut, + maxX: _fenetreFin, minY: resteNegatif ? -yMax * 0.25 : 0, maxY: yMax, gridData: FlGridData( @@ -720,8 +796,8 @@ class _EnergyScreenState extends State { rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), leftTitles: AxisTitles( sideTitles: SideTitles( - showTitles: true, - reservedSize: 46, + showTitles: axeGauche, + reservedSize: axeGauche ? 46 : 0, interval: yMax / 4, getTitlesWidget: (v, meta) => v == meta.max ? const SizedBox.shrink() @@ -830,8 +906,16 @@ class _EnergyScreenState extends State { // ── Helpers ─────────────────────────────────────────────────────────────── - double _xInterval() => - _data.length > 8 ? (_data.length / 6).ceilToDouble() : 1.0; + /// L'espacement des étiquettes de l'axe du bas, calculé sur ce qui est **visible**. + /// + /// Le calculer sur le jeu entier donnerait six étiquettes réparties sur 24 h alors que la + /// fenêtre n'en montre que douze : on en verrait trois, mal placées. L'axe doit suivre le + /// cadrage, pas les données. + double _xInterval() { + final visibles = (_fenetreFin - _fenetreDebut).round(); + final n = visibles > 0 ? visibles : _data.length; + return n > 8 ? (n / 6).ceilToDouble() : 1.0; + } /// Les valeurs sont **déjà en kWh** : on descend vers le Wh pour les petites, on ne /// remonte pas. L'ancienne version divisait par mille des kWh — d'où « 78 Wh » pour une