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>
317 lines
11 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|