Design system - lib/theme/etm_tokens.dart : source de vérité couleurs + typo (IBM Plex Sans/Mono) - lib/models/nymea_user.dart : modèle utilisateur nymea avec permissions EtmRole - app_theme.dart : ThemeData migré vers IBM Plex Sans + couleurs EtmTokens Navigation & drawer - DrawerMenuButton : logo vert gradient + ombre - Bottom nav : EtmTokens.green actif, EtmTokens.muted inactif - DrawerPanel 320 px, restyled navy + gradient header + badges rôle Dashboard (01_dashboard.html) - Hero système : status pill + 3 métriques mono + illustration maison CustomPainter - EnergyFlowWidget : 4 nœuds animés CustomPainter (flèches directionnelles) · gridPower > 0 = soutirage → flèche Grid→Home (amber) · gridPower < 0 = injection → flèche Home→Grid (bleu) - EVChargingCard restyled : badge En charge + puissance mono 38px + 3 modes + SOC bar - KPI 2×2 : spark bars, trend line, progress bar - Consommateurs principaux + Décisions d'Héos (chips motifs) - Prévisions placeholder explicite Énergie - KPI 2×2 avec icônes + fond soft + IBM Plex Mono - Sélecteur période vert pill - LineChart double axe : kW (gauche) / SOC % (droite, normalisé) - BarChart bilan énergétique Wh (amber/bleu) - Section Météo & prévision placeholder Things - Grille 2 col à hauteur intrinsèque (pas de childAspectRatio) - Bandeau statut global (simulation / connecté / hors-ligne) - _CategoryCard : header icon+label+count, séparateur coloré, liste tous items - thing_category.dart : couleurs migrées vers EtmTokens A/C — Climatisation / Chauffage - Thermostats pièces EN HAUT : actives expandées, éteintes compactes - Températures actuelle → cible ± avec EtmTokens.mono - Sélecteur mode 4 boutons (Chauf/Clim/Auto/Vent) - Chip "Chauffe au solaire en ce moment" (Héos) - Sources pilotées par Héos EN BAS : · PAC SG-Ready : 4 états (Bloqué grisé / Normal / Recommandé / Forcé) + toggle Auto · Chauffe-eau : Surplus/Éco/Boost + temp eau 52°→60°C · Climatiseur : pré-refroidissement anticipé + info solaire Packages ajoutés : google_fonts, flutter_staggered_grid_view, flutter_secure_storage Asset : assets/house.svg (illustration maison CustomPainter) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
538 lines
18 KiB
Dart
538 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
import '../models/nymea_models.dart';
|
||
import '../models/thing_category.dart';
|
||
import '../services/nymea_service.dart';
|
||
import '../theme/etm_tokens.dart';
|
||
import '../main.dart' show DrawerMenuButton;
|
||
import 'category_overview_screen.dart';
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// ThingsScreen — grille de catégories à hauteur intrinsèque
|
||
//
|
||
// Règle : pas de GridView avec childAspectRatio figé.
|
||
// Layout : Column de Row(Expanded × 2) → hauteur naturelle par catégorie.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class ThingsScreen extends StatefulWidget {
|
||
const ThingsScreen({super.key});
|
||
|
||
@override
|
||
State<ThingsScreen> createState() => _ThingsScreenState();
|
||
}
|
||
|
||
class _ThingsScreenState extends State<ThingsScreen> {
|
||
static const _orderedCats = [
|
||
ThingCategory.solar, ThingCategory.battery,
|
||
ThingCategory.evCharger, ThingCategory.hvac,
|
||
ThingCategory.energy, ThingCategory.cars,
|
||
ThingCategory.lighting, ThingCategory.sensors,
|
||
ThingCategory.network, ThingCategory.weather,
|
||
ThingCategory.media, ThingCategory.notifications,
|
||
ThingCategory.other,
|
||
];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
final service = context.read<NymeaService>();
|
||
if (!service.connected) service.startSimulation();
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final service = context.watch<NymeaService>();
|
||
final things = service.things.where((t) => t.id.isNotEmpty).toList();
|
||
final classes = service.thingClasses;
|
||
|
||
// Groupement par catégorie
|
||
final Map<ThingCategory, List<NymeaThing>> grouped = {};
|
||
for (final t in things) {
|
||
final cls = _classFor(t, classes);
|
||
final cat = cls?.category ?? ThingCategory.other;
|
||
grouped.putIfAbsent(cat, () => []).add(t);
|
||
}
|
||
final cats = _orderedCats.where((c) => grouped.containsKey(c)).toList();
|
||
|
||
// Statut global
|
||
final allOnline = things.isNotEmpty &&
|
||
things.every((t) => _isOnline(t, _classFor(t, classes)));
|
||
final offlineCount = things.where((t) => !_isOnline(t, _classFor(t, classes))).length;
|
||
|
||
return Scaffold(
|
||
backgroundColor: EtmTokens.bg,
|
||
body: CustomScrollView(
|
||
slivers: [
|
||
// AppBar
|
||
SliverAppBar(
|
||
floating: true,
|
||
backgroundColor: EtmTokens.bg,
|
||
elevation: 0,
|
||
leading: const DrawerMenuButton(),
|
||
leadingWidth: 64,
|
||
title: Row(
|
||
children: [
|
||
Text('Things',
|
||
style: EtmTokens.sans(size: 20, weight: FontWeight.w600)),
|
||
const SizedBox(width: 8),
|
||
if (things.isNotEmpty)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.greenSoft,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text('${things.length}',
|
||
style: EtmTokens.mono(size: 12, weight: FontWeight.w700,
|
||
color: EtmTokens.greenDark)),
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 8),
|
||
child: IconButton(
|
||
icon: const Icon(Icons.refresh_rounded,
|
||
color: EtmTokens.muted, size: 20),
|
||
onPressed: service.refresh,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
// Body
|
||
SliverPadding(
|
||
padding: const EdgeInsets.fromLTRB(18, 4, 18, 32),
|
||
sliver: SliverList(
|
||
delegate: SliverChildListDelegate([
|
||
|
||
// Bandeau statut global
|
||
if (service.isConnected || service.isSimulation) ...[
|
||
_StatusBanner(
|
||
allOnline: allOnline,
|
||
offlineCount: offlineCount,
|
||
simulation: service.isSimulation,
|
||
thingCount: things.length,
|
||
),
|
||
const SizedBox(height: 16),
|
||
],
|
||
|
||
// État de chargement
|
||
if (!service.isConnected && !service.isSimulation)
|
||
_Placeholder(
|
||
icon: Icons.cloud_off_rounded,
|
||
label: 'Non connecté à nymea',
|
||
)
|
||
else if (things.isEmpty && !service.thingsLoaded)
|
||
const Padding(
|
||
padding: EdgeInsets.only(top: 60),
|
||
child: Center(
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2, color: EtmTokens.green),
|
||
),
|
||
)
|
||
else if (things.isEmpty)
|
||
_Placeholder(
|
||
icon: Icons.devices_other_rounded,
|
||
label: 'Aucun appareil configuré',
|
||
)
|
||
else
|
||
_CategoryGrid(
|
||
cats: cats,
|
||
grouped: grouped,
|
||
classes: classes,
|
||
),
|
||
]),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
NymeaThingClass? _classFor(NymeaThing t, List<NymeaThingClass> cls) {
|
||
try { return cls.firstWhere((c) => c.id == t.thingClassId); }
|
||
catch (_) { return null; }
|
||
}
|
||
|
||
bool _isOnline(NymeaThing t, NymeaThingClass? cls) {
|
||
final ct = cls?.stateTypes.where((s) => s.name.toLowerCase() == 'connected');
|
||
if (ct != null && ct.isNotEmpty) {
|
||
final v = t.stateValue(ct.first.id);
|
||
return v == true || v == 'true';
|
||
}
|
||
return t.isSetupComplete;
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── Bandeau statut ────────────────────────────────────
|
||
|
||
class _StatusBanner extends StatelessWidget {
|
||
final bool allOnline;
|
||
final int offlineCount;
|
||
final bool simulation;
|
||
final int thingCount;
|
||
|
||
const _StatusBanner({
|
||
required this.allOnline,
|
||
required this.offlineCount,
|
||
required this.simulation,
|
||
required this.thingCount,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final Color dotColor;
|
||
final String text;
|
||
|
||
if (simulation) {
|
||
dotColor = EtmTokens.amber;
|
||
text = 'Mode simulation · $thingCount appareil(s) simulé(s)';
|
||
} else if (allOnline) {
|
||
dotColor = EtmTokens.green;
|
||
text = 'Tous les appareils connectés · dernière synchro à l\'instant';
|
||
} else if (offlineCount > 0) {
|
||
dotColor = EtmTokens.orange;
|
||
text = '$offlineCount appareil(s) hors ligne';
|
||
} else {
|
||
dotColor = EtmTokens.faint;
|
||
text = 'Aucun appareil connecté';
|
||
}
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.card,
|
||
borderRadius: BorderRadius.circular(EtmTokens.radius),
|
||
boxShadow: EtmTokens.cardShadow,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 8, height: 8,
|
||
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(text,
|
||
style: EtmTokens.sans(size: 13, color: EtmTokens.muted)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── Grille intrinsèque ────────────────────────────────
|
||
|
||
class _CategoryGrid extends StatelessWidget {
|
||
final List<ThingCategory> cats;
|
||
final Map<ThingCategory, List<NymeaThing>> grouped;
|
||
final List<NymeaThingClass> classes;
|
||
|
||
const _CategoryGrid({
|
||
required this.cats,
|
||
required this.grouped,
|
||
required this.classes,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final rows = <Widget>[];
|
||
for (int i = 0; i < cats.length; i += 2) {
|
||
rows.add(
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start, // hauteur intrinsèque
|
||
children: [
|
||
Expanded(
|
||
child: _CategoryCard(
|
||
cat: cats[i],
|
||
things: grouped[cats[i]] ?? [],
|
||
classes: classes,
|
||
onTap: () => _openCategory(context, cats[i], grouped[cats[i]] ?? [], classes),
|
||
),
|
||
),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: i + 1 < cats.length
|
||
? _CategoryCard(
|
||
cat: cats[i + 1],
|
||
things: grouped[cats[i + 1]] ?? [],
|
||
classes: classes,
|
||
onTap: () => _openCategory(
|
||
context, cats[i + 1], grouped[cats[i + 1]] ?? [], classes),
|
||
)
|
||
: const SizedBox(),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (i + 2 < cats.length) rows.add(const SizedBox(height: 14));
|
||
}
|
||
return Column(children: rows);
|
||
}
|
||
|
||
void _openCategory(BuildContext context, ThingCategory cat,
|
||
List<NymeaThing> things, List<NymeaThingClass> classes) {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => CategoryOverviewScreen(
|
||
info: categoryInfoMap[cat]!,
|
||
things: things,
|
||
thingClasses: classes,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── Carte catégorie ───────────────────────────────────
|
||
|
||
class _CategoryCard extends StatelessWidget {
|
||
final ThingCategory cat;
|
||
final List<NymeaThing> things;
|
||
final List<NymeaThingClass> classes;
|
||
final VoidCallback onTap;
|
||
|
||
const _CategoryCard({
|
||
required this.cat,
|
||
required this.things,
|
||
required this.classes,
|
||
required this.onTap,
|
||
});
|
||
|
||
NymeaThingClass? _classFor(NymeaThing t) {
|
||
try { return classes.firstWhere((c) => c.id == t.thingClassId); }
|
||
catch (_) { return null; }
|
||
}
|
||
|
||
bool _isOnline(NymeaThing t) {
|
||
final cls = _classFor(t);
|
||
final ct = cls?.stateTypes.where((s) => s.name.toLowerCase() == 'connected');
|
||
if (ct != null && ct.isNotEmpty) {
|
||
final v = t.stateValue(ct.first.id);
|
||
return v == true || v == 'true';
|
||
}
|
||
return t.isSetupComplete;
|
||
}
|
||
|
||
String? _value(NymeaThing t) {
|
||
final cls = _classFor(t);
|
||
final p = cls?.primaryStateType;
|
||
if (p == null) return null;
|
||
return p.formatValue(t.stateValue(p.id));
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final info = categoryInfoMap[cat]!;
|
||
final count = things.length;
|
||
|
||
// Container en dehors de Material pour que l'ombre soit visible
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.card,
|
||
borderRadius: BorderRadius.circular(EtmTokens.radius),
|
||
boxShadow: EtmTokens.cardShadow,
|
||
),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
borderRadius: BorderRadius.circular(EtmTokens.radius),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(EtmTokens.radius),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// ── En-tête ────────────────────────────────────────────────────
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 10),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 36, height: 36,
|
||
decoration: BoxDecoration(
|
||
color: info.color.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Icon(info.icon, color: info.color, size: 20),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
info.label.toUpperCase(),
|
||
style: EtmTokens.sans(
|
||
size: 9,
|
||
weight: FontWeight.w700,
|
||
color: EtmTokens.navy.withValues(alpha: 0.6),
|
||
letterSpacing: 0.6,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
Text(
|
||
'$count appareil${count > 1 ? 's' : ''}',
|
||
style: EtmTokens.sans(size: 11, color: EtmTokens.muted),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// ── Séparateur coloré ──────────────────────────────────────────
|
||
Container(height: 1, color: info.color.withValues(alpha: 0.20)),
|
||
|
||
// ── Liste des things ───────────────────────────────────────────
|
||
...things.map((t) => _ThingRow(
|
||
thing: t,
|
||
value: _value(t),
|
||
isOnline: _isOnline(t),
|
||
color: info.color,
|
||
isLast: t == things.last,
|
||
)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
); // closes InkWell
|
||
} // closes build
|
||
} // closes _CategoryCard
|
||
|
||
// ─────────────────────────── Ligne thing ───────────────────────────────────────
|
||
|
||
class _ThingRow extends StatelessWidget {
|
||
final NymeaThing thing;
|
||
final String? value;
|
||
final bool isOnline;
|
||
final Color color;
|
||
final bool isLast;
|
||
|
||
const _ThingRow({
|
||
required this.thing,
|
||
required this.value,
|
||
required this.isOnline,
|
||
required this.color,
|
||
required this.isLast,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.fromLTRB(14, 9, 14, 9),
|
||
decoration: BoxDecoration(
|
||
border: isLast
|
||
? null
|
||
: Border(bottom: BorderSide(color: EtmTokens.line)),
|
||
borderRadius: isLast
|
||
? const BorderRadius.vertical(bottom: Radius.circular(EtmTokens.radius))
|
||
: null,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 7, height: 7,
|
||
decoration: BoxDecoration(
|
||
color: isOnline ? color : EtmTokens.faint,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
thing.name,
|
||
style: EtmTokens.sans(size: 12, weight: FontWeight.w500),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
if (value != null)
|
||
Text(
|
||
value!,
|
||
style: EtmTokens.mono(
|
||
size: 12,
|
||
weight: FontWeight.w700,
|
||
color: isOnline ? color : EtmTokens.muted,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
)
|
||
else
|
||
Text(
|
||
isOnline ? 'OK' : 'Veille',
|
||
style: EtmTokens.sans(
|
||
size: 12,
|
||
color: isOnline ? color : EtmTokens.faint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── Placeholder ───────────────────────────────────────
|
||
|
||
class _Placeholder extends StatelessWidget {
|
||
final IconData icon;
|
||
final String label;
|
||
const _Placeholder({required this.icon, required this.label});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(top: 80),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 56, color: EtmTokens.faint),
|
||
const SizedBox(height: 14),
|
||
Text(label, style: EtmTokens.sans(size: 15, color: EtmTokens.muted)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── ThingCard (compat externe) ────────────────────────
|
||
|
||
class ThingCard extends StatelessWidget {
|
||
final NymeaThing thing;
|
||
final NymeaThingClass? thingClass;
|
||
final ThingCategoryInfo categoryInfo;
|
||
final VoidCallback onTap;
|
||
|
||
const ThingCard({
|
||
super.key,
|
||
required this.thing,
|
||
required this.thingClass,
|
||
required this.categoryInfo,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final primary = thingClass?.primaryStateType;
|
||
final value = primary?.formatValue(thing.stateValue(primary.id));
|
||
return ListTile(
|
||
leading: Icon(categoryInfo.icon, color: categoryInfo.color),
|
||
title: Text(thing.name, style: EtmTokens.sans(size: 14)),
|
||
trailing: value != null
|
||
? Text(value, style: EtmTokens.mono(size: 13, color: categoryInfo.color))
|
||
: null,
|
||
onTap: onTap,
|
||
);
|
||
}
|
||
}
|