- Drawer custom (overlay Stack) avec mode installateur PIN SHA-256 - GoRouter + ShellRoute : navigation préservée entre onglets - 6 providers : NavigationProvider, InstallerModeProvider, AppSettingsProvider, EnergySetupProvider, SchedulerProvider, TariffProvider - Écrans Energy Manager : RoleConfigFlow (3 étapes), Scheduler, Tarifs, Timeline - Écrans Paramètres : Apparence, Écrans actifs, AppSettingsScreen - DrawerMenuButton présent dans les 5 AppBars principaux - Simulation : _thingClasses générées avec interfaces EMS pour filtrage des rôles - Compteur solaire : ajout smartmeter aux interfaces compatibles - Thème ETM (etm_theme.dart), ProLockBadge, widgets PowerBar/RoleCard/TimelineSlotCard - Dépendances : go_router, shared_preferences, crypto, url_launcher Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
410 lines
15 KiB
Dart
410 lines
15 KiB
Dart
import 'dart:async';
|
|
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/app_theme.dart';
|
|
import '../main.dart' show DrawerMenuButton;
|
|
import 'category_overview_screen.dart';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// ThingsScreen — NIVEAU 1
|
|
// Grille 2 colonnes : une tuile par catégorie présente.
|
|
// Chaque tuile affiche l'icône/nom de catégorie et un carousel des things.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
class ThingsScreen extends StatelessWidget {
|
|
const ThingsScreen({super.key});
|
|
|
|
static const _orderedCats = [
|
|
ThingCategory.energy, ThingCategory.solar, ThingCategory.battery,
|
|
ThingCategory.evCharger, ThingCategory.cars, ThingCategory.hvac,
|
|
ThingCategory.lighting, ThingCategory.sensors, ThingCategory.network,
|
|
ThingCategory.notifications, ThingCategory.weather, ThingCategory.media,
|
|
ThingCategory.other,
|
|
];
|
|
|
|
@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();
|
|
|
|
return Scaffold(
|
|
backgroundColor: AppTheme.backgroundGray,
|
|
appBar: _buildAppBar(service, things.length),
|
|
body: !service.isConnected
|
|
? _buildPlaceholder(
|
|
icon: Icons.cloud_off_rounded,
|
|
label: 'Non connecté à nymea',
|
|
)
|
|
: things.isEmpty && !service.thingsLoaded
|
|
? const Center(
|
|
child: CircularProgressIndicator(color: AppTheme.accentTeal))
|
|
: things.isEmpty
|
|
? _buildPlaceholder(
|
|
icon: Icons.devices_other_rounded,
|
|
label: 'Aucun appareil',
|
|
)
|
|
: GridView.builder(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
|
|
gridDelegate:
|
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
crossAxisSpacing: 10,
|
|
mainAxisSpacing: 10,
|
|
childAspectRatio: 0.98,
|
|
),
|
|
// Si les classes ne sont pas encore chargées → 1 tuile "Autres"
|
|
itemCount: cats.isEmpty ? 1 : cats.length,
|
|
itemBuilder: (context, i) {
|
|
final cat = cats.isEmpty ? ThingCategory.other : cats[i];
|
|
final catThings = grouped[cat] ?? things;
|
|
final info = categoryInfoMap[cat]!;
|
|
return _CategoryTile(
|
|
info: info,
|
|
things: catThings,
|
|
thingClasses: classes,
|
|
onTap: () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => CategoryOverviewScreen(
|
|
info: info,
|
|
things: catThings,
|
|
thingClasses: classes,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
AppBar _buildAppBar(NymeaService service, int count) {
|
|
return AppBar(
|
|
backgroundColor: AppTheme.backgroundGray,
|
|
elevation: 0,
|
|
leading: const DrawerMenuButton(),
|
|
leadingWidth: 56,
|
|
title: Row(children: [
|
|
const Text('Things',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: AppTheme.textDark,
|
|
fontSize: 20)),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.accentTeal.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text('$count',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppTheme.accentTeal)),
|
|
),
|
|
]),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh_rounded, color: AppTheme.textDark),
|
|
onPressed: () => service.refresh(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
static Widget _buildPlaceholder(
|
|
{required IconData icon, required String label}) =>
|
|
Center(
|
|
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
|
Icon(icon, size: 64, color: Colors.grey[400]),
|
|
const SizedBox(height: 12),
|
|
Text(label,
|
|
style:
|
|
const TextStyle(color: AppTheme.textLight, fontSize: 16)),
|
|
]),
|
|
);
|
|
|
|
static NymeaThingClass? _classFor(
|
|
NymeaThing t, List<NymeaThingClass> classes) {
|
|
try {
|
|
return classes.firstWhere((c) => c.id == t.thingClassId);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// _CategoryTile — tuile d'une catégorie
|
|
//
|
|
// ┌────────────────────────────┐
|
|
// │ [icône catégorie] │ ← centrée, colorée
|
|
// │ NOM CATÉGORIE (CAPS) │
|
|
// │────────────────────────────│ ← séparateur couleur catégorie
|
|
// │ ● Thing name valeur │ ← carousel auto (3 s) si >1 thing
|
|
// └────────────────────────────┘
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
class _CategoryTile extends StatefulWidget {
|
|
final ThingCategoryInfo info;
|
|
final List<NymeaThing> things;
|
|
final List<NymeaThingClass> thingClasses;
|
|
final VoidCallback onTap;
|
|
|
|
const _CategoryTile({
|
|
required this.info,
|
|
required this.things,
|
|
required this.thingClasses,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
State<_CategoryTile> createState() => _CategoryTileState();
|
|
}
|
|
|
|
class _CategoryTileState extends State<_CategoryTile> {
|
|
int _idx = 0;
|
|
Timer? _timer;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (widget.things.length > 1) {
|
|
_timer = Timer.periodic(const Duration(seconds: 3), (_) {
|
|
if (mounted) {
|
|
setState(
|
|
() => _idx = (_idx + 1) % widget.things.length);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
NymeaThingClass? _cls(NymeaThing t) {
|
|
try {
|
|
return widget.thingClasses.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;
|
|
}
|
|
|
|
String? _primaryValue(NymeaThing t, NymeaThingClass? cls) {
|
|
final p = cls?.primaryStateType;
|
|
if (p == null) return null;
|
|
return p.formatValue(t.stateValue(p.id));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Clamp _idx au cas où la liste shrink entre deux ticks du timer
|
|
final safeIdx = _idx.clamp(0, widget.things.length - 1);
|
|
final current = widget.things[safeIdx];
|
|
final cls = _cls(current);
|
|
final online = _isOnline(current, cls);
|
|
final value = _primaryValue(current, cls);
|
|
final info = widget.info;
|
|
|
|
return Material(
|
|
color: AppTheme.cardWhite,
|
|
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
|
|
child: InkWell(
|
|
onTap: widget.onTap,
|
|
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// ── Haut : icône + nom catégorie ───────────────────────────────
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
width: 52,
|
|
height: 52,
|
|
decoration: BoxDecoration(
|
|
color: info.color.withValues(alpha: 0.13),
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
child: Icon(info.icon, color: info.color, size: 28),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
child: Text(
|
|
info.label.toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w800,
|
|
color: AppTheme.textDark.withValues(alpha: 0.75),
|
|
letterSpacing: 0.7,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// ── Séparateur coloré ───────────────────────────────────────────
|
|
Container(
|
|
height: 1,
|
|
color: info.color.withValues(alpha: 0.25),
|
|
),
|
|
|
|
// ── Bas : carousel des things ───────────────────────────────────
|
|
SizedBox(
|
|
height: 52,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 350),
|
|
transitionBuilder: (child, anim) =>
|
|
FadeTransition(opacity: anim, child: child),
|
|
child: _CarouselRow(
|
|
key: ValueKey(_idx),
|
|
name: current.name,
|
|
value: value,
|
|
count: widget.things.length,
|
|
index: _idx,
|
|
isOnline: online,
|
|
color: info.color,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Ligne carousel ────────────────────────────────────────────────────────────
|
|
|
|
class _CarouselRow extends StatelessWidget {
|
|
final String name;
|
|
final String? value;
|
|
final int count;
|
|
final int index;
|
|
final bool isOnline;
|
|
final Color color;
|
|
|
|
const _CarouselRow({
|
|
super.key,
|
|
required this.name,
|
|
required this.value,
|
|
required this.count,
|
|
required this.index,
|
|
required this.isOnline,
|
|
required this.color,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: [
|
|
// Pastille statut
|
|
Container(
|
|
width: 7,
|
|
height: 7,
|
|
decoration: BoxDecoration(
|
|
color: isOnline ? color : Colors.grey[400],
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
// Nom du thing
|
|
Expanded(
|
|
child: Text(
|
|
name,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppTheme.textDark),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
// Valeur primaire — Flexible pour éviter l'overflow
|
|
Flexible(
|
|
fit: FlexFit.loose,
|
|
child: Text(
|
|
value ?? '—',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.bold,
|
|
color: isOnline ? color : AppTheme.textLight,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// ThingCard — conservé pour compatibilité 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),
|
|
trailing:
|
|
value != null ? Text(value, style: TextStyle(color: categoryInfo.color)) : null,
|
|
onTap: onTap,
|
|
);
|
|
}
|
|
}
|