manager-app/lib/Screens/Vr_devices/vr_devices_tab.dart
Thomas Fransolet c8947880ba Ecran VR, section Scene3D et medias immersifs
Canal VR
- L'entree VR affiche VrScreen au lieu de « non configuree » : sous-onglet
  Configuration (AppConfigurationLinkScreen reutilise) et sous-onglet Casques
  (pincode d'appairage, etat, batterie, version, dernier vu).
- Le dialogue de plan SuperAdmin porte aussi les canaux et les add-ons
  (assistant, contenu immersif). Activer un canal cree son
  ApplicationInstance s'il n'existe pas ; le desactiver ne supprime rien.

Medias immersifs
- Types Image360, Video360, Model3D : icones, libelles, extensions, apercu
  dans la Mediatheque et facettes.
- Au depot, la pastille de type bascule Image <-> Image 360 et
  Video <-> Video 360 : rien dans un .jpg ne dit qu'il est
  equirectangulaire. Un .glb est reconnu seul.
- ImageCompressor ne touche plus aux 360 ni aux GLB, sur les deux chemins
  d'upload : ramenee a 2560 px, une equirectangulaire devient illisible dans
  un casque.

Scene3D
- Nouveau type de section (famille des lieux) et son ecran de configuration :
  modele, mode objet/decor, points d'interet.
- Fond immersif d'une configuration, visible si l'instance a l'add-on : le
  type est deduit de la ressource choisie, avec une image de repli.

Client API edite a la main en miroir du backend : SectionScene3DApi,
Scene3DDTO, ImmersiveBackgroundDTO, Position3D, AppType sur les devices,
ResourceType etendu. Libelles FR/EN/NL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 16:54:13 +02:00

317 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:manager_api_new/api.dart';
import 'package:manager_app/Components/common_loader.dart';
import 'package:manager_app/Components/message_notification.dart';
import 'package:manager_app/Models/managerContext.dart';
import 'package:manager_app/Screens/Applications/app_configuration_link_screen.dart';
import 'package:manager_app/Screens/Kiosk_devices/change_device_info_modal.dart';
import 'package:manager_app/app_context.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/l10n/app_localizations.dart';
import 'package:provider/provider.dart';
/// Flotte de casques. Comme pour le kiosk, la grille liste les liens de
/// configuration de l'application VR — un lien existe avant qu'un casque ne s'y
/// soit appairé, et l'appairage se fait depuis le casque avec le code PIN.
class VrDevicesTab extends StatefulWidget {
final ApplicationInstanceDTO applicationInstanceDTO;
const VrDevicesTab({super.key, required this.applicationInstanceDTO});
@override
State<VrDevicesTab> createState() => _VrDevicesTabState();
}
class _VrDevicesTabState extends State<VrDevicesTab> {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final appContext = Provider.of<AppContext>(context);
final ManagerAppContext managerAppContext = appContext.getContext();
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (managerAppContext.pinCode != null)
Padding(
padding: const EdgeInsets.only(bottom: kSpace5),
child: Text(
l.pinCode(managerAppContext.pinCode.toString()),
style: const TextStyle(fontSize: 25.0, fontWeight: FontWeight.w300),
),
),
Padding(
padding: const EdgeInsets.only(bottom: kSpace3),
child: Text(l.headsets),
),
FutureBuilder(
future: getAppConfigurationLink(appContext, widget.applicationInstanceDTO),
builder: (context, AsyncSnapshot<dynamic> snapshot) {
List<AppConfigurationLinkDTO>? links = snapshot.data;
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(height: 160, child: Center(child: CommonLoader()));
}
if (links == null) return Text(l.noData);
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 260,
childAspectRatio: 1.25,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
),
itemCount: links.length,
itemBuilder: (context, index) => _HeadsetCard(link: links[index]),
);
},
),
],
),
);
}
}
class _HeadsetCard extends StatefulWidget {
final AppConfigurationLinkDTO link;
const _HeadsetCard({required this.link});
@override
State<_HeadsetCard> createState() => _HeadsetCardState();
}
class _HeadsetCardState extends State<_HeadsetCard> {
bool _hovered = false;
/// Batterie, version et dernier vu ne sont pas dans `DeviceDTO` : ils ne
/// viennent que du détail, un appel par casque. Acceptable sur une flotte de
/// bornes, qui se compte en unités.
Future<DeviceDetailDTO?>? _detail;
@override
void initState() {
super.initState();
final device = widget.link.device;
if (device?.id != null) {
final ctx = Provider.of<AppContext>(context, listen: false).getContext() as ManagerAppContext;
_detail = ctx.clientAPI!.deviceApi!.deviceGetDetail(device!.id!);
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final device = widget.link.device;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedScale(
scale: _hovered ? 1.03 : 1.0,
duration: const Duration(milliseconds: 150),
child: Container(
decoration: BoxDecoration(
color: kTextLightColor,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: kSecond,
spreadRadius: _hovered ? 1 : 0.5,
blurRadius: _hovered ? 10 : 5,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(12),
child: device == null ? _unpaired(l) : _paired(l, device),
),
),
);
}
Widget _unpaired(AppLocalizations l) {
final title = widget.link.configuration?.label ?? '';
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.visibility_outlined, color: kBodyTextColor.withValues(alpha: 0.5), size: 34),
const SizedBox(height: 10),
Text(
l.noHeadsetPaired,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: kBodyTextColor),
),
if (title.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
title,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: kBodyTextColor.withValues(alpha: 0.6)),
),
],
],
);
}
Widget _paired(AppLocalizations l, DeviceDTO device) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
device.name ?? device.identifier ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
),
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: device.connected == true ? Colors.green : kError,
borderRadius: BorderRadius.circular(25.0),
),
),
],
),
const SizedBox(height: kSpace2),
Text(
device.configuration ?? l.noConfiguration,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13),
),
const Spacer(),
FutureBuilder(
future: _detail,
builder: (context, AsyncSnapshot<DeviceDetailDTO?> snapshot) {
final detail = snapshot.data;
if (detail == null) return const SizedBox();
final silent = _silentFor(detail.lastSeen);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (silent != null) _silentBanner(l, silent),
if (detail.batteryLevel != null)
_infoLine(Icons.battery_std_outlined, '${l.headsetBattery} ${detail.batteryLevel}'),
if (detail.appVersion != null)
_infoLine(Icons.info_outline, '${l.headsetAppVersion} ${detail.appVersion}'),
_infoLine(
Icons.schedule,
detail.lastSeen == null
? l.headsetNeverSeen
: l.headsetLastSeen(DateFormat('dd/MM/yyyy HH:mm').format(detail.lastSeen!)),
),
],
);
},
),
Align(
alignment: Alignment.centerRight,
child: IconButton(
icon: const Icon(Icons.edit),
color: kPrimaryColor,
onPressed: () => _edit(l, device),
),
),
],
);
}
/// Un casque qui bat toutes les 3 minutes et qu'on n'a plus entendu depuis
/// **une heure** n'est pas « en veille » : il est éteint, hors wifi, ou son app
/// est tombée. Le seuil est volontairement large — vingt battements manqués —
/// pour qu'une alerte veuille dire quelque chose.
static const _silenceThreshold = Duration(hours: 1);
Duration? _silentFor(DateTime? lastSeen) {
if (lastSeen == null) return null;
final silence = DateTime.now().difference(lastSeen.toLocal());
return silence >= _silenceThreshold ? silence : null;
}
/// La pastille verte dit « connecté » parce que le serveur l'a écrit au dernier
/// battement — elle ne sait pas qu'il date d'hier. C'est ce mensonge-là que cette
/// bannière corrige.
Widget _silentBanner(AppLocalizations l, Duration silence) {
final label = silence.inHours >= 24
? l.headsetSilentDays(silence.inDays.toString())
: l.headsetSilentHours(silence.inHours.toString());
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: kSpace2, vertical: 2),
decoration: BoxDecoration(
color: kError.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(kRadiusInput),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.warning_amber_rounded, size: 13, color: kError),
const SizedBox(width: 4),
Flexible(
child: Text(label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: kError, fontWeight: FontWeight.w600)),
),
],
),
);
}
Widget _infoLine(IconData icon, String label) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Row(
children: [
Icon(icon, size: 13, color: kInk3),
const SizedBox(width: 4),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, color: kInk3),
),
),
],
),
);
}
void _edit(AppLocalizations l, DeviceDTO device) {
final appContext = Provider.of<AppContext>(context, listen: false);
showChangeInfo(
l.updateHeadsetBtn,
device,
widget.link,
(DeviceDTO outputDevice, AppConfigurationLinkDTO outputLink) async {
final ctx = appContext.getContext() as ManagerAppContext;
await ctx.clientAPI!.deviceApi!.deviceUpdateMainInfos(outputDevice);
outputLink.configurationId = outputDevice.configurationId;
await ctx.clientAPI!.applicationInstanceApi!.applicationInstanceUpdateApplicationLink(outputLink);
if (!mounted) return;
showNotification(kSuccess, kWhite, l.appUpdatedSuccess, context, null);
setState(() {});
},
1,
context,
appContext,
);
}
}