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>
This commit is contained in:
Thomas Fransolet 2026-09-13 16:54:13 +02:00
parent 0c3bc31b5e
commit c8947880ba
43 changed files with 3420 additions and 64 deletions

View File

@ -19,6 +19,14 @@ IconData getResourceIcon(elementType) {
return Icons.file_present_outlined; return Icons.file_present_outlined;
case ResourceType.JsonUrl: case ResourceType.JsonUrl:
return Icons.file_present_rounded; return Icons.file_present_rounded;
// Sans ces trois-, un média immersif s'affichait avec le point
// d'exclamation du cas par défaut — ce qui se lit comme une erreur.
case ResourceType.Image360:
return Icons.panorama_photosphere;
case ResourceType.Video360:
return Icons.threesixty;
case ResourceType.Model3D:
return Icons.view_in_ar;
} }
return Icons.announcement; return Icons.announcement;
} }

View File

@ -17,6 +17,7 @@ String getSectionTypeName(AppLocalizations l, SectionType? type) {
case SectionType.Weather: return l.sectionTypeWeather; case SectionType.Weather: return l.sectionTypeWeather;
case SectionType.Event: return l.sectionTypeEvent; case SectionType.Event: return l.sectionTypeEvent;
case SectionType.Parcours: return l.sectionTypeParcours; case SectionType.Parcours: return l.sectionTypeParcours;
case SectionType.Scene3D: return l.sectionTypeScene3D;
default: return l.sectionTypeDefault; default: return l.sectionTypeDefault;
} }
} }
@ -38,6 +39,7 @@ String getSectionTypeDescription(AppLocalizations l, SectionType? type) {
case SectionType.Weather: return l.sectionTypeDescWeather; case SectionType.Weather: return l.sectionTypeDescWeather;
case SectionType.Event: return l.sectionTypeDescEvent; case SectionType.Event: return l.sectionTypeDescEvent;
case SectionType.Parcours: return l.sectionTypeDescParcours; case SectionType.Parcours: return l.sectionTypeDescParcours;
case SectionType.Scene3D: return l.sectionTypeDescScene3D;
default: return ""; default: return "";
} }
} }
@ -70,6 +72,8 @@ IconData getSectionIcon(elementType) {
return Icons.event; return Icons.event;
case SectionType.Parcours: case SectionType.Parcours:
return Icons.route; return Icons.route;
case SectionType.Scene3D:
return Icons.view_in_ar;
} }
return Icons.question_mark; return Icons.question_mark;
} }

View File

@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:manager_api_new/api.dart';
import 'package:manager_app/Components/resource_input_container.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/l10n/app_localizations.dart';
/// Le fond immersif d'un lieu, tel que le gestionnaire le choisit.
///
/// **Le type n'est pas demandé, il est déduit** de la ressource choisie : une
/// `Image360` donne un panorama, une `Video360` une vidéo, un `Model3D` une scène.
/// Demander « panorama ou vidéo ? » après avoir fait choisir une vidéo, c'est poser
/// une question dont la réponse est déjà sur l'écran — et ouvrir la possibilité d'y
/// répondre faux, ce qui donnerait un fond noir sans explication.
///
/// Le repli est un champ séparé, et il est proposé à côté plutôt que caché : trois
/// canaux sur quatre ne rendent pas un panorama, et c'est lui qu'ils afficheront.
class ImmersiveBackgroundPicker extends StatefulWidget {
const ImmersiveBackgroundPicker({
super.key,
required this.value,
required this.onChanged,
required this.title,
required this.hint,
});
final ImmersiveBackgroundDTO? value;
final ValueChanged<ImmersiveBackgroundDTO?> onChanged;
final String title;
final String hint;
@override
State<ImmersiveBackgroundPicker> createState() => _ImmersiveBackgroundPickerState();
}
class _ImmersiveBackgroundPickerState extends State<ImmersiveBackgroundPicker> {
static const _immersiveTypes = [
ResourceType.Image360,
ResourceType.Video360,
ResourceType.Model3D,
];
static ImmersiveBackgroundKind _kindOf(ResourceType? type) {
if (type == ResourceType.Video360) return ImmersiveBackgroundKind.Video360;
if (type == ResourceType.Model3D) return ImmersiveBackgroundKind.Scene3D;
return ImmersiveBackgroundKind.Pano;
}
String _kindLabel(AppLocalizations l, ImmersiveBackgroundKind? kind) {
if (kind == ImmersiveBackgroundKind.Video360) return l.backgroundKindVideo360;
if (kind == ImmersiveBackgroundKind.Scene3D) return l.backgroundKindScene3D;
return l.backgroundKindPano;
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final value = widget.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.title,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: kSpace2),
Text(widget.hint, style: kTextHint),
const SizedBox(height: kSpace4),
ResourceInputContainer(
label: l.backgroundResourceLabel,
initialValue: value?.resourceId,
inResourceTypes: _immersiveTypes,
onChanged: (ResourceDTO resource) {
final updated = ImmersiveBackgroundDTO(
resourceId: resource.id,
kind: _kindOf(resource.type),
fallbackResourceId: value?.fallbackResourceId,
);
setState(() {});
widget.onChanged(updated);
},
),
if (value?.resourceId != null) ...[
const SizedBox(height: kSpace2),
Row(
children: [
const Icon(Icons.info_outline, size: 14, color: kInk3),
const SizedBox(width: kSpace2),
Expanded(
child: Text(_kindLabel(l, value!.kind), style: kTextHint),
),
],
),
const SizedBox(height: kSpace5),
ResourceInputContainer(
label: l.backgroundFallbackLabel,
initialValue: value.fallbackResourceId,
inResourceTypes: const [ResourceType.Image],
onChanged: (ResourceDTO resource) {
final updated = ImmersiveBackgroundDTO(
resourceId: value.resourceId,
kind: value.kind,
fallbackResourceId: resource.id,
);
setState(() {});
widget.onChanged(updated);
},
),
const SizedBox(height: kSpace2),
Text(l.backgroundFallbackHint, style: kTextHint),
const SizedBox(height: kSpace4),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () {
setState(() {});
widget.onChanged(null);
},
icon: const Icon(Icons.close, size: 16),
label: Text(l.backgroundRemove),
),
),
],
],
);
}
}

View File

@ -11,7 +11,7 @@ import 'package:manager_app/l10n/app_localizations.dart';
import 'package:manager_api_new/api.dart'; import 'package:manager_api_new/api.dart';
const List<String> kAcceptedExtensions = [ const List<String> kAcceptedExtensions = [
'jpg', 'jpeg', 'png', 'gif', 'mp3', 'mp4', 'webm', 'pdf', 'json', 'jpg', 'jpeg', 'png', 'gif', 'mp3', 'mp4', 'webm', 'pdf', 'json', 'glb',
]; ];
/// Une entrée en attente d'envoi : soit un fichier local, soit une URL. /// Une entrée en attente d'envoi : soit un fichier local, soit une URL.
@ -22,10 +22,14 @@ const List<String> kAcceptedExtensions = [
class PickedResource { class PickedResource {
final PlatformFile? file; final PlatformFile? file;
final String? url; final String? url;
final ResourceType? type;
const PickedResource.file(this.file, this.type) : url = null; /// Modifiable : l'extension donne le type de départ, mais rien dans un `.jpg`
const PickedResource.url(this.url, this.type) : file = null; /// ne dit qu'il est équirectangulaire — c'est le gestionnaire qui le déclare,
/// avant l'envoi, en touchant la pastille.
ResourceType? type;
PickedResource.file(this.file, this.type) : url = null;
PickedResource.url(this.url, this.type) : file = null;
bool get isUrl => url != null; bool get isUrl => url != null;
String get name => isUrl ? url! : file!.name; String get name => isUrl ? url! : file!.name;
@ -306,6 +310,65 @@ class _ResourcePickerState extends State<ResourcePicker> {
); );
} }
/// Bascule entre un média plat et sa version 360°.
///
/// Aucune extension ne dit qu'une photo est équirectangulaire : un panorama 360
/// est un `.jpg` comme un autre. C'est donc au gestionnaire de le déclarer, et
/// ça change deux choses la ressource n'est plus compressée (une 360 ramenée
/// à 2560 px devient une bouillie dans un casque), et le casque sait qu'il peut
/// l'afficher en skybox.
static const Map<ResourceType, ResourceType> _immersiveOf = {
ResourceType.Image: ResourceType.Image360,
ResourceType.Image360: ResourceType.Image,
ResourceType.Video: ResourceType.Video360,
ResourceType.Video360: ResourceType.Video,
};
Widget _typeChip(AppLocalizations l, PickedResource entry) {
final toggle = _immersiveOf[entry.type];
final isImmersive = entry.type == ResourceType.Image360 ||
entry.type == ResourceType.Video360;
final chip = Container(
padding: const EdgeInsets.symmetric(horizontal: kSpace2, vertical: 2),
decoration: BoxDecoration(
color: isImmersive ? kBrandSoft : kSurface3,
borderRadius: BorderRadius.circular(kRadiusInput),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(resourceTypeLabel(l, entry.type),
style: TextStyle(
fontSize: 10.5,
color: isImmersive ? kInk : kInk2,
fontWeight: isImmersive ? FontWeight.w600 : FontWeight.w400)),
// Sans ce chevron, rien ne distingue une pastille cliquable d'un simple
// libellé : le basculement 360° n'était découvrable qu'en survolant.
if (toggle != null) ...[
const SizedBox(width: 3),
Icon(Icons.swap_horiz,
size: 12, color: isImmersive ? kInk : kInk3),
],
],
),
);
if (toggle == null) return chip;
return Tooltip(
message: l.mediaToggle360Hint,
child: InkWell(
borderRadius: BorderRadius.circular(kRadiusInput),
onTap: () {
setState(() => entry.type = toggle);
widget.onChanged();
},
child: chip,
),
);
}
Widget _list(AppLocalizations l) { Widget _list(AppLocalizations l) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -335,16 +398,7 @@ class _ResourcePickerState extends State<ResourcePicker> {
), ),
), ),
const SizedBox(width: kSpace3), const SizedBox(width: kSpace3),
Container( _typeChip(l, entry),
padding: const EdgeInsets.symmetric(
horizontal: kSpace2, vertical: 2),
decoration: BoxDecoration(
color: kSurface3,
borderRadius: BorderRadius.circular(kRadiusInput),
),
child: Text(resourceTypeLabel(l, entry.type),
style: const TextStyle(fontSize: 10.5, color: kInk2)),
),
const SizedBox(width: kSpace2), const SizedBox(width: kSpace2),
IconButton( IconButton(
icon: const Icon(Icons.close, size: 15), icon: const Icon(Icons.close, size: 15),

View File

@ -1,6 +1,7 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:manager_api_new/api.dart';
/// Compression des images à l'upload. /// Compression des images à l'upload.
/// ///
@ -25,7 +26,18 @@ class ImageCompressor {
/// L'original est renvoyé tel quel si le format n'est pas gérable, si le /// L'original est renvoyé tel quel si le format n'est pas gérable, si le
/// décodage échoue, ou si la compression n'a rien gagné — une image déjà /// décodage échoue, ou si la compression n'a rien gagné — une image déjà
/// petite et bien encodée peut grossir en repassant par un -encodage. /// petite et bien encodée peut grossir en repassant par un -encodage.
static CompressedImage compress(Uint8List bytes, String? extension, String fallbackMimeType) { ///
/// [type] décide aussi : une image 360 n'est **jamais** compressée. Ramenée à
/// 2560 px de côté long, une équirectangulaire de 8192×4096 perd les trois
/// quarts de sa définition sur un écran ça se voit à peine, dans un casque
/// elle couvre tout le champ de vision et devient une bouillie.
static CompressedImage compress(Uint8List bytes, String? extension, String fallbackMimeType,
{ResourceType? type}) {
if (type == ResourceType.Image360 || type == ResourceType.Video360 ||
type == ResourceType.Model3D) {
return CompressedImage(bytes, fallbackMimeType, false);
}
if (!handles(extension)) { if (!handles(extension)) {
return CompressedImage(bytes, fallbackMimeType, false); return CompressedImage(bytes, fallbackMimeType, false);
} }

View File

@ -0,0 +1,430 @@
import 'dart:convert';
import 'dart:html' as html;
import 'dart:math' as math;
import 'dart:ui_web' as ui_web;
import 'package:flutter/material.dart';
import 'package:manager_api_new/api.dart';
import 'package:manager_app/Components/message_notification.dart';
import 'package:manager_app/Components/resource_input_container.dart';
import 'package:manager_app/Models/managerContext.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';
/// Configuration d'une section **maquette 3D** — item E7 du lot XR-4.
///
/// Deux choses : choisir le modèle dans la médiathèque, et **poser ses points
/// d'intérêt dessus**. Le second se fait dans le viewer 3D, affiché ici en iframe.
///
/// <b>Pourquoi une iframe et pas un rendu Flutter.</b> Le viewer existe déjà, en
/// TypeScript/three.js (`vr-app/viewer`, décision D3) : il charge un GLB, laisse
/// déplacer des objets à la souris, et sert aussi à la piste Scène 3D. Le refaire en
/// Flutter demanderait un moteur 3D et rejouerait le même travail. Le manager sait
/// déjà monter une iframe `policy_screen`, `web_view` et `pdf_web_viewer` le font.
///
/// <b>Le protocole n'est pas inventé ici</b> : le viewer parle en **manifeste de
/// scène** (`bridge.ts`), le même objet que lit le casque (`SceneManifest.cs`). On lui
/// envoie donc un manifeste minimal le modèle comme décor, les points comme
/// *hotspots* et il renvoie les positions dès qu'on en déplace un. Un hotspot porte
/// un `geoPointId` précisément pour ce va-et-vient.
class Scene3DConfig extends StatefulWidget {
final Scene3DDTO initialValue;
final ValueChanged<Scene3DDTO> onChanged;
const Scene3DConfig({
Key? key,
required this.initialValue,
required this.onChanged,
}) : super(key: key);
@override
State<Scene3DConfig> createState() => _Scene3DConfigState();
}
class _Scene3DConfigState extends State<Scene3DConfig> {
/// Le repère du manifeste, vérifié littéralement des deux côtés. Un miroir d'axes
/// est invisible sur une maquette symétrique et se paie très cher découvert tard.
static const _coordinateSystem = 'gltf/y-up/right-handed/meters';
/// Le viewer est servi à côté du manager. En développement il tourne sur son
/// propre port (`npm run dev`), d'où la variable de compilation.
static const _viewerUrl =
String.fromEnvironment('VIEWER_URL', defaultValue: '/viewer/index.html');
late Scene3DDTO _dto;
late final String _viewType;
html.IFrameElement? _frame;
bool _viewerReady = false;
@override
void initState() {
super.initState();
_dto = widget.initialValue;
_viewType = 'model3d-viewer-${DateTime.now().microsecondsSinceEpoch}';
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int _) {
final frame = html.IFrameElement()
..src = _viewerUrl
..style.border = 'none'
..style.width = '100%'
..style.height = '100%';
_frame = frame;
return frame;
});
html.window.onMessage.listen(_onViewerMessage);
}
void _onViewerMessage(html.MessageEvent event) {
final message = _asMap(event.data);
if (message == null) return;
switch (message['type']) {
case 'ready':
// Le viewer prévient quand il est prêt : envoyer avant qu'il ne le soit,
// c'est un manifeste perdu sans erreur.
_viewerReady = true;
_sendManifest();
break;
case 'changed':
_readHotspots(message['hotspots']);
break;
case 'error':
debugPrint('[Model3D] viewer : ${message['message']}');
break;
}
}
Map<String, dynamic>? _asMap(dynamic data) {
if (data is Map) return data.cast<String, dynamic>();
if (data is String) {
try {
final decoded = jsonDecode(data);
if (decoded is Map) return decoded.cast<String, dynamic>();
} catch (_) {}
}
return null;
}
/// Reporte sur les points ce que le gestionnaire vient de déplacer, **et
/// l'enregistre**.
///
/// L'écriture part tout de suite plutôt qu'au bouton « Enregistrer » de la
/// section : déplacer un point est un geste continu, et personne ne pense à
/// sauvegarder après avoir bougé une bille dans une maquette.
void _readHotspots(dynamic raw) {
if (raw is! List) return;
var changed = false;
for (final entry in raw) {
final hotspot = _asMap(entry);
final transform = hotspot == null ? null : _asMap(hotspot['transform']);
final position = transform?['position'];
if (hotspot == null || position is! List || position.length < 3) continue;
final geoPointId = (hotspot['geoPointId'] as num?)?.toInt();
final point = (_dto.points ?? []).firstWhere(
(p) => p.id == geoPointId,
orElse: () => GeoPointDTO(),
);
if (point.id == null) continue;
point.localTransform = Position3D(
x: (position[0] as num).toDouble(),
y: (position[1] as num).toDouble(),
z: (position[2] as num).toDouble(),
rotationY: _yawFromQuaternion(transform?['rotation']),
);
changed = true;
_savePoint(point);
}
if (changed) widget.onChanged(_dto);
}
Future<void> _savePoint(GeoPointDTO point) async {
try {
await _api.sectionScene3DUpdatePoint(point);
} catch (e) {
debugPrint('[Model3D] position non enregistrée : $e');
if (mounted) {
showNotification(kError, kWhite,
AppLocalizations.of(context)!.model3DPointSaveError, context, null);
}
}
}
SectionScene3DApi get _api =>
(Provider.of<AppContext>(context, listen: false).getContext() as ManagerAppContext)
.clientAPI!
.sectionScene3DApi!;
Future<void> _addPoint() async {
final l = AppLocalizations.of(context)!;
final sectionId = _dto.id;
if (sectionId == null) return;
try {
final created = await _api.sectionScene3DCreatePoint(
sectionId,
GeoPointDTO(
title: [TranslationDTO(language: 'FR', value: l.model3DNewPoint)],
description: [],
contents: [],
// À l'origine du modèle : c'est que l'éditeur le montrera, à portée
// de souris, pour qu'on le pose au bon endroit.
localTransform: Position3D(),
),
);
if (created == null) return;
setState(() => (_dto.points ??= []).add(created));
widget.onChanged(_dto);
_sendManifest();
} catch (e) {
debugPrint('[Model3D] création de point : $e');
if (mounted) {
showNotification(kError, kWhite, l.model3DPointSaveError, context, null);
}
}
}
Future<void> _deletePoint(GeoPointDTO point) async {
if (point.id == null) return;
try {
await _api.sectionScene3DDeletePoint(point.id!);
setState(() => _dto.points?.removeWhere((p) => p.id == point.id));
widget.onChanged(_dto);
_sendManifest();
} catch (e) {
debugPrint('[Model3D] suppression de point : $e');
}
}
/// Le manifeste ne stocke qu'un quaternion ; nous n'avons besoin que du lacet
/// un panneau posé sur une maquette ne bascule pas.
double? _yawFromQuaternion(dynamic rotation) {
if (rotation is! List || rotation.length < 4) return null;
final x = (rotation[0] as num).toDouble();
final y = (rotation[1] as num).toDouble();
final z = (rotation[2] as num).toDouble();
final w = (rotation[3] as num).toDouble();
final yaw = math.atan2(2 * (w * y + x * z), 1 - 2 * (y * y + x * x));
return yaw * 180 / math.pi;
}
List<double> _quaternionFromYaw(double? degrees) {
if (degrees == null || degrees == 0) return [0, 0, 0, 1];
final half = degrees * math.pi / 180 / 2;
return [0, math.sin(half), 0, math.cos(half)];
}
/// Construit le manifeste minimal que le viewer sait lire : le modèle est le
/// décor, les points sont les hotspots. Tout le reste est comblé par ses valeurs
/// par défaut c'est une maquette, pas une scène immersive.
void _sendManifest() {
final frame = _frame;
final url = _dto.model3DSource;
if (frame?.contentWindow == null || !_viewerReady || url == null) return;
const assetId = 'model';
final manifest = {
'manifestVersion': 1,
'sceneId': _dto.id ?? '',
'instanceId': _dto.instanceId ?? '',
'configurationId': _dto.configurationId ?? '',
'version': 1,
'coordinateSystem': _coordinateSystem,
'world': {
'kind': 'mesh',
'assetId': assetId,
'transform': {
'position': [0, 0, 0],
'rotation': [0, 0, 0, 1],
'scale': [1, 1, 1],
},
},
'assets': [
{
'id': assetId,
'resourceId': _dto.model3DResourceId,
'url': url,
'mimeType': 'model/gltf-binary',
},
],
'hotspots': (_dto.points ?? []).map((point) {
final position = point.localTransform;
return {
'id': 'poi-${point.id}',
'geoPointId': point.id,
'transform': {
'position': [position?.x ?? 0, position?.y ?? 0, position?.z ?? 0],
'rotation': _quaternionFromYaw(position?.rotationY),
'scale': [1, 1, 1],
},
'title': _localized(point.title),
'description': _localized(point.description),
'contents': const [],
};
}).toList(),
};
frame!.contentWindow!.postMessage(
{'type': 'load', 'manifest': manifest, 'mode': 'edit'},
'*',
);
}
List<Map<String, String>> _localized(List<TranslationDTO>? translations) =>
(translations ?? [])
.map((t) => {'language': t.language ?? '', 'value': t.value ?? ''})
.toList();
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ResourceInputContainer(
label: l.model3DResourceLabel,
initialValue: _dto.model3DResourceId,
inResourceTypes: const [ResourceType.Model3D],
onChanged: (ResourceDTO resource) {
setState(() {
_dto.model3DResourceId = resource.id;
_dto.model3DSource = resource.url;
});
widget.onChanged(_dto);
_sendManifest();
},
),
const SizedBox(height: kSpace5),
// Ce que le visiteur fera de la scène : manipuler un objet, ou être dedans.
// Aucun fichier ne peut le deviner même GLB, même points, deux usages.
Text(l.scene3DModeTitle,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: kSpace2),
SegmentedButton<Scene3DMode>(
segments: [
ButtonSegment(
value: Scene3DMode.Asset,
icon: const Icon(Icons.view_in_ar, size: 16),
label: Text(l.scene3DModeAsset),
),
ButtonSegment(
value: Scene3DMode.Scene,
icon: const Icon(Icons.panorama_photosphere, size: 16),
label: Text(l.scene3DModeScene),
),
],
selected: {_dto.mode ?? Scene3DMode.Asset},
onSelectionChanged: (selection) {
setState(() => _dto.mode = selection.first);
widget.onChanged(_dto);
_sendManifest();
},
),
const SizedBox(height: kSpace2),
Text(
(_dto.mode ?? Scene3DMode.Asset) == Scene3DMode.Asset
? l.scene3DModeAssetHint
: l.scene3DModeSceneHint,
style: kTextHint,
),
const SizedBox(height: kSpace5),
if (_dto.model3DResourceId == null)
Text(l.model3DPickModelFirst, style: kTextHint)
else ...[
Text(l.model3DPlacePoints, style: kTextHint),
const SizedBox(height: kSpace3),
// Hauteur fixe : une iframe n'a pas de taille intrinsèque, et sans
// contrainte elle s'effondre à zéro dans une colonne.
SizedBox(
height: 480,
child: ClipRRect(
borderRadius: BorderRadius.circular(kRadiusCard),
child: HtmlElementView(viewType: _viewType),
),
),
const SizedBox(height: kSpace5),
_pointList(l),
],
],
);
}
/// La liste double le viewer, elle ne le remplace pas : on ajoute et on supprime
/// ici, on place -bas. Elle sert aussi de repère quand un point s'est retrouvé
/// derrière la maquette et qu'on ne le voit plus.
Widget _pointList(AppLocalizations l) {
final points = _dto.points ?? [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(l.model3DPointsTitle(points.length.toString()),
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
const Spacer(),
TextButton.icon(
onPressed: _addPoint,
icon: const Icon(Icons.add, size: 16),
label: Text(l.model3DAddPoint),
),
],
),
if (points.isEmpty)
Text(l.model3DNoPoint, style: kTextHint)
else
...points.map((point) {
final position = point.localTransform;
final placed = position != null &&
(position.x != 0 || position.y != 0 || position.z != 0);
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(placed ? Icons.place : Icons.place_outlined,
size: 18, color: placed ? kPrimaryColor : kInk3),
title: Text(
ConfigurationExportTitle(point.title) ?? l.model3DNewPoint,
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
placed
? '${position.x.toStringAsFixed(2)} · ${position.y.toStringAsFixed(2)} · ${position.z.toStringAsFixed(2)}'
: l.model3DPointNotPlaced,
style: kTextHint,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 18),
color: kInk3,
onPressed: () => _deletePoint(point),
),
);
}),
],
);
}
/// Le titre du point dans la langue d'édition, ou le premier disponible.
String? ConfigurationExportTitle(List<TranslationDTO>? title) {
if (title == null || title.isEmpty) return null;
final fr = title.firstWhere((t) => t.language == 'FR',
orElse: () => title.first);
return (fr.value ?? '').isEmpty ? null : fr.value;
}
}

View File

@ -42,6 +42,7 @@ import 'dart:html' as html;
import 'SubSection/Weather/weather_config.dart'; import 'SubSection/Weather/weather_config.dart';
import 'SubSection/Event/event_config.dart'; import 'SubSection/Event/event_config.dart';
import 'SubSection/SectionParcours/section_parcours_config.dart'; import 'SubSection/SectionParcours/section_parcours_config.dart';
import 'SubSection/Scene3D/scene3d_config.dart';
class SectionDetailScreen extends StatefulWidget { class SectionDetailScreen extends StatefulWidget {
final String id; final String id;
@ -516,6 +517,13 @@ class _SectionDetailScreenState extends State<SectionDetailScreen> {
sectionDetailDTO = updatedParcours; sectionDetailDTO = updatedParcours;
}, },
); );
case SectionType.Scene3D:
return Scene3DConfig(
initialValue: sectionDetailDTO as Scene3DDTO,
onChanged: (Scene3DDTO updatedModel) {
sectionDetailDTO = updatedModel;
},
);
} }
} }
@ -563,6 +571,9 @@ class _SectionDetailScreenState extends State<SectionDetailScreen> {
case SectionType.Parcours: case SectionType.Parcours:
sectionDetailDTO = ParcoursDTO.fromJson(rawSectionData)!; sectionDetailDTO = ParcoursDTO.fromJson(rawSectionData)!;
break; break;
case SectionType.Scene3D:
sectionDetailDTO = Scene3DDTO.fromJson(rawSectionData)!;
break;
} }
} }

View File

@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:manager_app/Components/check_input_container.dart'; import 'package:manager_app/Components/check_input_container.dart';
import 'package:manager_app/Components/confirmation_dialog.dart'; import 'package:manager_app/Components/confirmation_dialog.dart';
import 'package:manager_app/Components/immersive_background_picker.dart';
import 'package:manager_app/Components/resource_input_container.dart'; import 'package:manager_app/Components/resource_input_container.dart';
import 'package:manager_app/Components/common_loader.dart'; import 'package:manager_app/Components/common_loader.dart';
import 'package:manager_app/Components/message_notification.dart'; import 'package:manager_app/Components/message_notification.dart';
@ -125,6 +126,10 @@ class _ConfigurationDetailScreenState extends State<ConfigurationDetailScreen> {
], ],
rail: [ rail: [
_cardImages(config, l), _cardImages(config, l),
// Seulement pour les lieux qui ont l'add-on immersif : ailleurs, ce
// serait un réglage qu'on ne peut ni remplir ni voir.
if (managerCtx.instanceDTO?.hasImmersiveContent == true)
_cardBackground(config, l),
], ],
), ),
), ),
@ -261,6 +266,21 @@ class _ConfigurationDetailScreenState extends State<ConfigurationDetailScreen> {
); );
} }
// Card Fond immersif
Widget _cardBackground(ConfigurationDTO config, AppLocalizations l) {
return Pane(
icon: Icons.panorama_photosphere,
title: l.backgroundTitle,
child: ImmersiveBackgroundPicker(
value: config.immersiveBackground,
title: l.backgroundTitle,
hint: l.backgroundHintConfiguration,
onChanged: (value) => setState(() => config.immersiveBackground = value),
),
);
}
// Card Sections // Card Sections
Widget _cardSections(ConfigurationDTO config, AppContext appContext) { Widget _cardSections(ConfigurationDTO config, AppContext appContext) {

View File

@ -23,6 +23,7 @@ const Map<String, List<SectionType>> _sectionFamilies = {
SectionType.Map, SectionType.Map,
SectionType.Parcours, SectionType.Parcours,
SectionType.Event, SectionType.Event,
SectionType.Scene3D,
], ],
"interactive": [ "interactive": [
SectionType.Quiz, SectionType.Quiz,

View File

@ -12,6 +12,7 @@ import 'package:manager_app/Screens/ApiKeys/api_keys_screen.dart';
import 'package:manager_app/Screens/Audit/audit_screen.dart'; import 'package:manager_app/Screens/Audit/audit_screen.dart';
import 'package:manager_app/Screens/Configurations/configurations_screen.dart'; import 'package:manager_app/Screens/Configurations/configurations_screen.dart';
import 'package:manager_app/Screens/Kiosk_devices/kiosk_screen.dart'; import 'package:manager_app/Screens/Kiosk_devices/kiosk_screen.dart';
import 'package:manager_app/Screens/Vr_devices/vr_screen.dart';
import 'package:manager_app/Screens/Resources/resources_screen.dart'; import 'package:manager_app/Screens/Resources/resources_screen.dart';
import 'package:manager_app/Screens/Statistics/statistics_screen.dart'; import 'package:manager_app/Screens/Statistics/statistics_screen.dart';
import 'package:manager_app/Screens/GuideIa/guide_ia_screen.dart'; import 'package:manager_app/Screens/GuideIa/guide_ia_screen.dart';
@ -138,6 +139,16 @@ class _MainScreenState extends State<MainScreen> {
final planList = plans ?? []; final planList = plans ?? [];
String? selectedPlanId = instanceDetail?.subscriptionPlanId; String? selectedPlanId = instanceDetail?.subscriptionPlanId;
// Canaux et add-ons : ils vivent dans le même dialogue que le plan parce que
// c'est la même décision commerciale, prise au même moment. Les séparer
// obligerait à ouvrir deux écrans pour vendre une offre.
bool isMobile = instanceDetail?.isMobile ?? false;
bool isTablet = instanceDetail?.isTablet ?? false;
bool isWeb = instanceDetail?.isWeb ?? false;
bool isVR = instanceDetail?.isVR ?? false;
bool isAssistant = instanceDetail?.isAssistant ?? false;
bool hasImmersiveContent = instanceDetail?.hasImmersiveContent ?? false;
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => StatefulBuilder( builder: (dialogContext) => StatefulBuilder(
@ -145,29 +156,80 @@ class _MainScreenState extends State<MainScreen> {
title: Text(AppLocalizations.of(context)!.planDialogTitle(inst.name ?? '')), title: Text(AppLocalizations.of(context)!.planDialogTitle(inst.name ?? '')),
content: SizedBox( content: SizedBox(
width: 360, width: 360,
child: planList.isEmpty child: SingleChildScrollView(
? Text(AppLocalizations.of(context)!.noPlansAvailable) child: Column(
: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
RadioListTile<String?>( if (planList.isEmpty)
title: Text(AppLocalizations.of(context)!.noPlan), Text(AppLocalizations.of(context)!.noPlansAvailable)
value: null, else ...[
groupValue: selectedPlanId, RadioListTile<String?>(
onChanged: (v) => setDialogState(() => selectedPlanId = v), title: Text(AppLocalizations.of(context)!.noPlan),
value: null,
groupValue: selectedPlanId,
onChanged: (v) => setDialogState(() => selectedPlanId = v),
),
...planList.map((plan) => RadioListTile<String?>(
title: Text(plan.name),
subtitle: Text(
'${_formatQuotaBytes(plan.storageQuotaBytes, unlimitedLabel: AppLocalizations.of(dialogContext)!.unlimitedStorage)} · ${plan.aiTokensPerMonth == 0 ? AppLocalizations.of(dialogContext)!.unlimitedAI : AppLocalizations.of(dialogContext)!.aiTokensPerMonth(_formatTokens(plan.aiTokensPerMonth))}',
style: const TextStyle(fontSize: 11),
), ),
...planList.map((plan) => RadioListTile<String?>( value: plan.id,
title: Text(plan.name), groupValue: selectedPlanId,
subtitle: Text( onChanged: (v) => setDialogState(() => selectedPlanId = v),
'${_formatQuotaBytes(plan.storageQuotaBytes, unlimitedLabel: AppLocalizations.of(dialogContext)!.unlimitedStorage)} · ${plan.aiTokensPerMonth == 0 ? AppLocalizations.of(dialogContext)!.unlimitedAI : AppLocalizations.of(dialogContext)!.aiTokensPerMonth(_formatTokens(plan.aiTokensPerMonth))}', )),
style: const TextStyle(fontSize: 11), ],
), const Divider(height: 24),
value: plan.id, Text(AppLocalizations.of(dialogContext)!.channelsSectionTitle,
groupValue: selectedPlanId, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
onChanged: (v) => setDialogState(() => selectedPlanId = v), SwitchListTile(
)), dense: true,
], title: Text(AppLocalizations.of(dialogContext)!.menuMobile),
value: isMobile,
onChanged: (v) => setDialogState(() => isMobile = v),
), ),
SwitchListTile(
dense: true,
title: Text(AppLocalizations.of(dialogContext)!.menuKiosk),
value: isTablet,
onChanged: (v) => setDialogState(() => isTablet = v),
),
SwitchListTile(
dense: true,
title: Text(AppLocalizations.of(dialogContext)!.menuWeb),
value: isWeb,
onChanged: (v) => setDialogState(() => isWeb = v),
),
SwitchListTile(
dense: true,
title: Text(AppLocalizations.of(dialogContext)!.menuVr),
subtitle: Text(AppLocalizations.of(dialogContext)!.channelVrHint,
style: const TextStyle(fontSize: 11)),
value: isVR,
onChanged: (v) => setDialogState(() => isVR = v),
),
const Divider(height: 24),
Text(AppLocalizations.of(dialogContext)!.addonsSectionTitle,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
SwitchListTile(
dense: true,
title: Text(AppLocalizations.of(dialogContext)!.addonAssistant),
value: isAssistant,
onChanged: (v) => setDialogState(() => isAssistant = v),
),
SwitchListTile(
dense: true,
title: Text(AppLocalizations.of(dialogContext)!.addonImmersive),
subtitle: Text(AppLocalizations.of(dialogContext)!.addonImmersiveHint,
style: const TextStyle(fontSize: 11)),
value: hasImmersiveContent,
onChanged: (v) => setDialogState(() => hasImmersiveContent = v),
),
],
),
),
), ),
actions: [ actions: [
TextButton( TextButton(
@ -186,19 +248,38 @@ class _MainScreenState extends State<MainScreen> {
isPushNotification: instanceDetail?.isPushNotification, isPushNotification: instanceDetail?.isPushNotification,
hasStats: instanceDetail?.hasStats, hasStats: instanceDetail?.hasStats,
hasAdvancedStats: instanceDetail?.hasAdvancedStats, hasAdvancedStats: instanceDetail?.hasAdvancedStats,
isMobile: instanceDetail?.isMobile, isMobile: isMobile,
isTablet: instanceDetail?.isTablet, isTablet: isTablet,
isWeb: instanceDetail?.isWeb, isWeb: isWeb,
isVR: instanceDetail?.isVR, isVR: isVR,
isAssistant: instanceDetail?.isAssistant, isAssistant: isAssistant,
hasImmersiveContent: hasImmersiveContent,
aiTokensThisMonth: instanceDetail?.aiTokensThisMonth, aiTokensThisMonth: instanceDetail?.aiTokensThisMonth,
aiUsageMonthKey: instanceDetail?.aiUsageMonthKey, aiUsageMonthKey: instanceDetail?.aiUsageMonthKey,
subscriptionPlanId: selectedPlanId ?? '', subscriptionPlanId: selectedPlanId ?? '',
), ),
); );
// Activer le drapeau ne suffit pas : sans `ApplicationInstance`, le
// menu du canal s'affiche mais l'écran annonce « non configurée ».
// C'est exactement ce qui obligeait à passer par deux appels d'API
// à la main pour ouvrir le canal VR.
final created = await _ensureApplicationInstances(
managerCtx,
inst.id,
instanceDetail,
isMobile: isMobile,
isTablet: isTablet,
isWeb: isWeb,
isVR: isVR,
);
onSaved?.call(); onSaved?.call();
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.planUpdated))); final message = created > 0
? AppLocalizations.of(context)!.channelApplicationCreated
: AppLocalizations.of(context)!.planUpdated;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
@ -214,6 +295,56 @@ class _MainScreenState extends State<MainScreen> {
); );
} }
/// Crée les `ApplicationInstance` des canaux qu'on vient d'activer, et seulement
/// celles-. Renvoie combien ont é créées.
///
/// **On ne supprime rien en désactivant un canal.** Une `ApplicationInstance`
/// porte les langues, les liens de configuration et l'historique des appareils :
/// la détruire parce qu'on a décoché une case ferait disparaître le travail du
/// client. Décocher masque le canal, ça ne l'efface pas — et le recocher le
/// retrouve intact.
Future<int> _ensureApplicationInstances(
ManagerAppContext managerCtx,
String instanceId,
InstanceDTO? detail, {
required bool isMobile,
required bool isTablet,
required bool isWeb,
required bool isVR,
}) async {
final existing = detail?.applicationInstanceDTOs ?? [];
final wanted = <AppType, bool>{
AppType.Mobile: isMobile,
AppType.Tablet: isTablet,
AppType.Web: isWeb,
AppType.VR: isVR,
};
var created = 0;
for (final entry in wanted.entries) {
if (!entry.value) continue;
if (existing.any((ai) => ai.appType == entry.key)) continue;
try {
await managerCtx.clientAPI!.applicationInstanceApi!.applicationInstanceCreate(
ApplicationInstanceDTO(
instanceId: instanceId,
appType: entry.key,
// Le français comme langue de départ : le manager est livré en FR/EN/NL
// et une application sans aucune langue n'affiche rien.
languages: ['FR'],
),
);
created++;
} catch (e) {
debugPrint('[SuperAdmin] création ApplicationInstance ${entry.key} : $e');
}
}
return created;
}
static String _formatQuotaBytes(int? bytes, {required String unlimitedLabel}) { static String _formatQuotaBytes(int? bytes, {required String unlimitedLabel}) {
if (bytes == null || bytes == 0) return unlimitedLabel; if (bytes == null || bytes == 0) return unlimitedLabel;
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} MB'; if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} MB';
@ -741,7 +872,7 @@ class _MainScreenState extends State<MainScreen> {
if (applicationInstanceVR == null) return Center(child: Text(AppLocalizations.of(context)!.vrAppNotConfigured)); if (applicationInstanceVR == null) return Center(child: Text(AppLocalizations.of(context)!.vrAppNotConfigured));
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Text(AppLocalizations.of(context)!.vrAppNotConfigured) child: VrScreen(applicationInstanceDTO: applicationInstanceVR)
); );
case 'configurations' : case 'configurations' :
return Padding( return Padding(

View File

@ -11,6 +11,10 @@ import 'package:flutter/material.dart';
getElementForResource(BuildContext context, dynamic resourceDTO, AppContext appContext) { getElementForResource(BuildContext context, dynamic resourceDTO, AppContext appContext) {
switch(resourceDTO.type) { switch(resourceDTO.type) {
// Une 360 est une image equirectangulaire : l'apercu plat est deforme, mais
// il montre la bonne ressource. Sans ce cas, le panneau de la mediatheque
// restait vide sans rien dire.
case ResourceType.Image360:
case ResourceType.Image: case ResourceType.Image:
return Image.network( return Image.network(
resourceDTO.url, resourceDTO.url,
@ -31,7 +35,6 @@ getElementForResource(BuildContext context, dynamic resourceDTO, AppContext appC
); );
}, },
); );
break;
case ResourceType.ImageUrl: case ResourceType.ImageUrl:
return Image.network( return Image.network(
resourceDTO.url, resourceDTO.url,
@ -52,7 +55,6 @@ getElementForResource(BuildContext context, dynamic resourceDTO, AppContext appC
); );
}, },
); );
break;
case ResourceType.Audio: case ResourceType.Audio:
return AudioPlayerFloatingContainer(audioBytes: null, resourceURl: resourceDTO.url, isAuto: true); return AudioPlayerFloatingContainer(audioBytes: null, resourceURl: resourceDTO.url, isAuto: true);
/*return FutureBuilder( /*return FutureBuilder(
@ -82,6 +84,7 @@ getElementForResource(BuildContext context, dynamic resourceDTO, AppContext appC
} }
);*/ );*/
//return Text("Fichier audio - aucune visualisation possible"); //return Text("Fichier audio - aucune visualisation possible");
case ResourceType.Video360:
case ResourceType.Video: case ResourceType.Video:
if(resourceDTO.url == null) { if(resourceDTO.url == null) {
return Center(child: Text(AppLocalizations.of(context)!.errorLoadingVideo)); return Center(child: Text(AppLocalizations.of(context)!.errorLoadingVideo));
@ -107,6 +110,11 @@ getElementForResource(BuildContext context, dynamic resourceDTO, AppContext appC
case ResourceType.JsonUrl: case ResourceType.JsonUrl:
return SelectableText(resourceDTO.url!); return SelectableText(resourceDTO.url!);
case ResourceType.Model3D:
return Center(
child: Text(AppLocalizations.of(context)!.mediaModel3DNoPreview,
textAlign: TextAlign.center));
} }
} }

View File

@ -15,6 +15,9 @@ const Map<ResourceType, String> _extensionByType = {
ResourceType.Pdf: '.pdf', ResourceType.Pdf: '.pdf',
ResourceType.Json: '.json', ResourceType.Json: '.json',
ResourceType.JsonUrl: '.json', ResourceType.JsonUrl: '.json',
ResourceType.Image360: '.jpg',
ResourceType.Video360: '.mp4',
ResourceType.Model3D: '.glb',
}; };
/// Type d'une ressource locale, déduit de son extension. Un seul endroit : le /// Type d'une ressource locale, déduit de son extension. Un seul endroit : le
@ -37,6 +40,10 @@ ResourceType? resourceTypeForExtension(String? extension) {
return ResourceType.Pdf; return ResourceType.Pdf;
case 'json': case 'json':
return ResourceType.Json; return ResourceType.Json;
// Un .glb n'est jamais autre chose qu'un modèle 3D contrairement à un .jpg,
// qui peut être plat ou équirectangulaire sans que le fichier le dise.
case 'glb':
return ResourceType.Model3D;
default: default:
return null; return null;
} }
@ -61,6 +68,8 @@ String mimeTypeForExtension(String? extension) {
return 'application/pdf'; return 'application/pdf';
case 'json': case 'json':
return 'application/json'; return 'application/json';
case 'glb':
return 'model/gltf-binary';
default: default:
return 'application/octet-stream'; return 'application/octet-stream';
} }
@ -77,6 +86,9 @@ String resourceTypeLabel(AppLocalizations l, ResourceType? type) {
case ResourceType.Pdf: return l.resourceTypePdf; case ResourceType.Pdf: return l.resourceTypePdf;
case ResourceType.Json: return l.resourceTypeJson; case ResourceType.Json: return l.resourceTypeJson;
case ResourceType.JsonUrl: return l.resourceTypeJsonUrl; case ResourceType.JsonUrl: return l.resourceTypeJsonUrl;
case ResourceType.Image360: return l.resourceTypeImage360;
case ResourceType.Video360: return l.resourceTypeVideo360;
case ResourceType.Model3D: return l.resourceTypeModel3D;
default: return ''; default: return '';
} }
} }

View File

@ -283,7 +283,8 @@ class _ResourcesScreenState extends State<ResourcesScreen> {
setState(() => isUploading = true); setState(() => isUploading = true);
try { try {
final compressed = ImageCompressor.compress( final compressed = ImageCompressor.compress(
file.bytes!, file.extension, mimeTypeForExtension(file.extension)); file.bytes!, file.extension, mimeTypeForExtension(file.extension),
type: resource.type);
final storage = FirebaseStorage.instance; final storage = FirebaseStorage.instance;
final ref = storage final ref = storage
@ -408,7 +409,8 @@ Future<void> create(
// se fait sur sizeBytes à la création, il doit voir la taille réellement // se fait sur sizeBytes à la création, il doit voir la taille réellement
// téléversée et non celle du fichier d'origine. // téléversée et non celle du fichier d'origine.
final compressed = ImageCompressor.compress(platformFile.bytes!, final compressed = ImageCompressor.compress(platformFile.bytes!,
platformFile.extension, mimeTypeForExtension(platformFile.extension)); platformFile.extension, mimeTypeForExtension(platformFile.extension),
type: entry.type);
final resourceDTO = ResourceDTO( final resourceDTO = ResourceDTO(
label: platformFile.name, label: platformFile.name,

View File

@ -0,0 +1,316 @@
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,
);
}
}

View File

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:manager_api_new/api.dart';
import 'package:manager_app/Screens/Applications/app_configuration_link_screen.dart';
import 'package:manager_app/Screens/Vr_devices/vr_devices_tab.dart';
import 'package:manager_app/Components/immersive_background_picker.dart';
import 'package:manager_app/Components/message_notification.dart';
import 'package:manager_app/app_context.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/l10n/app_localizations.dart';
/// Le canal VR a les deux faces des autres canaux ce qu'on diffuse et sur quoi
/// on le diffuse mais contrairement au kiosk la flotte porte des informations
/// propres au casque (batterie, version, dernier vu) qui n'ont pas leur place
/// dans l'écran de configuration.
class VrScreen extends StatefulWidget {
final ApplicationInstanceDTO applicationInstanceDTO;
const VrScreen({super.key, required this.applicationInstanceDTO});
@override
State<VrScreen> createState() => _VrScreenState();
}
class _VrScreenState extends State<VrScreen> {
int _tab = 0;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_tabs(l),
const SizedBox(height: kSpace7),
Expanded(
child: _tab == 0
? Column(
children: [
Expanded(
child: AppConfigurationLinkScreen(
applicationInstanceDTO: widget.applicationInstanceDTO,
),
),
_backgroundCard(l),
],
)
: VrDevicesTab(
applicationInstanceDTO: widget.applicationInstanceDTO,
),
),
],
);
}
/// Le fond du menu d'accueil du casque. Il vit ici et pas dans l'écran de
/// configuration partagé : celui-ci sert aussi au mobile, au web et au kiosk, qui
/// n'ont pas de menu à décorer.
///
/// Enregistré à chaque changement, comme l'image principale juste au-dessus — c'est
/// la convention de cet écran, et deux comportements d'enregistrement sur la même
/// page se paient en contenus perdus.
Widget _backgroundCard(AppLocalizations l) {
final appContext = Provider.of<AppContext>(context, listen: false);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(kSpace5),
decoration: BoxDecoration(
color: kSurface,
border: Border(top: BorderSide(color: kLine)),
),
child: ImmersiveBackgroundPicker(
value: widget.applicationInstanceDTO.immersiveBackground,
title: l.backgroundTitle,
hint: l.backgroundHintMenu,
onChanged: (value) async {
setState(() => widget.applicationInstanceDTO.immersiveBackground = value);
final updated = await updateApplicationInstance(
appContext, widget.applicationInstanceDTO);
if (updated != null && mounted) {
showNotification(kSuccess, kWhite, l.appUpdatedSuccess, context, null);
}
},
),
);
}
Widget _tabs(AppLocalizations l) {
return Container(
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: kLine))),
child: Row(
children: [
_tabButton(l.vrTabConfig, 0),
_tabButton(l.vrTabHeadsets, 1),
],
),
);
}
Widget _tabButton(String label, int index) {
final selected = _tab == index;
return InkWell(
onTap: () => setState(() => _tab = index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? kBrand : Colors.transparent,
width: 2,
),
),
),
child: Text(
label,
style: TextStyle(
fontSize: 14,
color: selected ? kInk : kInk3,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
),
);
}
}

View File

@ -32,6 +32,9 @@ class Client {
SectionMapApi? _sectionMapApi; SectionMapApi? _sectionMapApi;
SectionMapApi? get sectionMapApi => _sectionMapApi; SectionMapApi? get sectionMapApi => _sectionMapApi;
SectionScene3DApi? _sectionScene3DApi;
SectionScene3DApi? get sectionScene3DApi => _sectionScene3DApi;
SectionQuizApi? _sectionQuizApi; SectionQuizApi? _sectionQuizApi;
SectionQuizApi? get sectionQuizApi => _sectionQuizApi; SectionQuizApi? get sectionQuizApi => _sectionQuizApi;
@ -76,6 +79,7 @@ class Client {
_resourceApi = ResourceApi(_apiClient); _resourceApi = ResourceApi(_apiClient);
_deviceApi = DeviceApi(_apiClient); _deviceApi = DeviceApi(_apiClient);
_sectionMapApi = SectionMapApi(_apiClient); _sectionMapApi = SectionMapApi(_apiClient);
_sectionScene3DApi = SectionScene3DApi(_apiClient);
_sectionQuizApi = SectionQuizApi(_apiClient); _sectionQuizApi = SectionQuizApi(_apiClient);
_sectionAgendaApi = SectionAgendaApi(_apiClient); _sectionAgendaApi = SectionAgendaApi(_apiClient);
_sectionEventApi = SectionEventApi(_apiClient); _sectionEventApi = SectionEventApi(_apiClient);

View File

@ -203,6 +203,12 @@ const List<ResourceType> resource_types = [
ResourceType.Pdf, ResourceType.Pdf,
ResourceType.Json, ResourceType.Json,
ResourceType.JsonUrl, ResourceType.JsonUrl,
// Les médias immersifs sont dans la même liste que les autres : c'est elle qui
// alimente les facettes de la Médiathèque. Absents d'ici, ils existaient en base
// mais restaient introuvables pas de filtre « mes 360 », pas de compteur.
ResourceType.Image360,
ResourceType.Video360,
ResourceType.Model3D,
]; ];
/* /*

View File

@ -1005,7 +1005,7 @@
"@_mediatheque_dialogue": {}, "@_mediatheque_dialogue": {},
"mediaAddTitle": "Add a resource", "mediaAddTitle": "Add a resource",
"mediaDropZone": "Drop your files here or click to browse", "mediaDropZone": "Drop your files here or click to browse",
"mediaDropFormats": "JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON", "mediaDropFormats": "JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON, GLB",
"mediaOrPasteUrl": "or paste a URL", "mediaOrPasteUrl": "or paste a URL",
"mediaUrlHint": "https://… image, YouTube video or JSON file", "mediaUrlHint": "https://… image, YouTube video or JSON file",
"mediaUrlInvalid": "This URL is not valid", "mediaUrlInvalid": "This URL is not valid",
@ -1052,10 +1052,59 @@
"menuKiosk": "Kiosk", "menuKiosk": "Kiosk",
"menuWeb": "Web", "menuWeb": "Web",
"menuVr": "VR", "menuVr": "VR",
"channelsSectionTitle": "Channels",
"channelVrHint": "Also creates the VR application if missing",
"addonsSectionTitle": "Add-ons",
"addonAssistant": "AI assistant",
"backgroundTitle": "Immersive background",
"backgroundHintConfiguration": "The setting this visit takes place in, inside the headset. Without it, content floats in the dark.",
"backgroundHintMenu": "The setting of the headset's home menu, before the visitor has chosen anything.",
"backgroundResourceLabel": "Background media",
"backgroundKindPano": "360° panorama: shown all around the visitor, in the headset.",
"backgroundKindVideo360": "360° video: looped and muted. ⚠️ On a kiosk, the decoder runs all day.",
"backgroundKindScene3D": "3D setting: in the contract, not yet shown as a background by the headset.",
"backgroundFallbackLabel": "Fallback image",
"backgroundFallbackHint": "Shown by web, mobile and tablets, which cannot render a 360. Without it, those channels show no background at all.",
"backgroundRemove": "Remove background",
"addonImmersive": "Immersive content",
"addonImmersiveHint": "360° images, 360° video and 3D models. Turning it on adds 100 GB to the plan's storage quota, and turning it off removes them — a 5-minute 360° video weighs gigabytes.",
"channelApplicationCreated": "Channel enabled and application created",
"noAppConfigured": "No application configured on this instance", "noAppConfigured": "No application configured on this instance",
"mobileAppNotConfigured": "Mobile application not configured", "mobileAppNotConfigured": "Mobile application not configured",
"kioskAppNotConfigured": "Kiosk application not configured", "kioskAppNotConfigured": "Kiosk application not configured",
"vrAppNotConfigured": "VR application not configured", "vrAppNotConfigured": "VR application not configured",
"vrTabConfig": "Configuration",
"vrTabHeadsets": "Headsets",
"headsets": "Headsets",
"noHeadsetPaired": "No headset paired",
"headsetBattery": "Battery",
"headsetAppVersion": "Version",
"headsetLastSeen": "Seen on {date}",
"@headsetLastSeen": {
"placeholders": {
"date": {
"type": "String"
}
}
},
"headsetNeverSeen": "Never seen",
"headsetSilentHours": "Silent for {count} h",
"@headsetSilentHours": {
"placeholders": {
"count": {
"type": "String"
}
}
},
"headsetSilentDays": "Silent for {count} d",
"@headsetSilentDays": {
"placeholders": {
"count": {
"type": "String"
}
}
},
"updateHeadsetBtn": "Update headset",
"titleColonLabel": "Title:", "titleColonLabel": "Title:",
"titleModalLabel": "Title", "titleModalLabel": "Title",
"descriptionColonLabel": "Description:", "descriptionColonLabel": "Description:",
@ -1386,6 +1435,34 @@
"resourceTypePdf": "PDF", "resourceTypePdf": "PDF",
"resourceTypeJson": "JSON", "resourceTypeJson": "JSON",
"resourceTypeJsonUrl": "JSON (URL)", "resourceTypeJsonUrl": "JSON (URL)",
"resourceTypeImage360": "360° image",
"resourceTypeVideo360": "360° video",
"resourceTypeModel3D": "3D model",
"model3DResourceLabel": "3D model",
"model3DPickModelFirst": "Pick a 3D model from the media library first.",
"model3DPlacePoints": "Drag the points of interest onto the model. Their position is saved with the section.",
"scene3DModeTitle": "What the visitor does with the scene",
"scene3DModeAsset": "Object",
"scene3DModeScene": "Place",
"scene3DModeAssetHint": "An object placed in front of the visitor, examined from every side — a sword, a collection piece. Points turn with the object.",
"scene3DModeSceneHint": "A place the visitor stands in and looks around — a reconstructed room. Points stay where they are.",
"model3DPointsTitle": "Points of interest ({count})",
"@model3DPointsTitle": {
"placeholders": {
"count": {
"type": "String"
}
}
},
"model3DAddPoint": "Add a point",
"model3DNoPoint": "No point on this model yet.",
"model3DNewPoint": "New point",
"model3DPointNotPlaced": "Not placed yet",
"model3DPointSaveError": "The position could not be saved",
"sectionTypeScene3D": "3D scene",
"sectionTypeDescScene3D": "A 3D object or setting the visitor explores, with points of interest placed on it. Shown in the VR headset.",
"mediaModel3DNoPreview": "No preview here: a 3D model is viewed in a 3D scene section, and in the headset.",
"mediaToggle360Hint": "Tap to switch between flat and 360° media — a 360° image is shown immersively in the headset and is never compressed",
"minutesAbbr": "min", "minutesAbbr": "min",
"secondsAbbr": "sec", "secondsAbbr": "sec",
"metersAbbr": "m", "metersAbbr": "m",

View File

@ -1005,7 +1005,7 @@
"@_mediatheque_dialogue": {}, "@_mediatheque_dialogue": {},
"mediaAddTitle": "Ajouter une ressource", "mediaAddTitle": "Ajouter une ressource",
"mediaDropZone": "Glissez vos fichiers ici ou cliquez pour parcourir", "mediaDropZone": "Glissez vos fichiers ici ou cliquez pour parcourir",
"mediaDropFormats": "JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON", "mediaDropFormats": "JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON, GLB",
"mediaOrPasteUrl": "ou coller une URL", "mediaOrPasteUrl": "ou coller une URL",
"mediaUrlHint": "https://… image, vidéo YouTube ou fichier JSON", "mediaUrlHint": "https://… image, vidéo YouTube ou fichier JSON",
"mediaUrlInvalid": "Cette URL n'est pas valide", "mediaUrlInvalid": "Cette URL n'est pas valide",
@ -1052,10 +1052,59 @@
"menuKiosk": "Kiosk", "menuKiosk": "Kiosk",
"menuWeb": "Web", "menuWeb": "Web",
"menuVr": "VR", "menuVr": "VR",
"channelsSectionTitle": "Canaux",
"channelVrHint": "Crée aussi l'application VR si elle n'existe pas",
"addonsSectionTitle": "Add-ons",
"addonAssistant": "Assistant IA",
"backgroundTitle": "Fond immersif",
"backgroundHintConfiguration": "Le décor dans lequel cette visite se déroule dans le casque. Sans lui, les contenus flottent dans le noir.",
"backgroundHintMenu": "Le décor du menu d'accueil du casque, avant que le visiteur ait choisi quoi que ce soit.",
"backgroundResourceLabel": "Média du fond",
"backgroundKindPano": "Panorama 360° : affiché tout autour du visiteur, dans le casque.",
"backgroundKindVideo360": "Vidéo 360° : jouée en boucle et sans son. ⚠️ Sur une borne, le décodeur tourne toute la journée.",
"backgroundKindScene3D": "Décor 3D : prévu par le contrat, pas encore affiché en fond par le casque.",
"backgroundFallbackLabel": "Image de repli",
"backgroundFallbackHint": "Affichée par le web, le mobile et les tablettes, qui ne savent pas rendre une 360. Sans elle, ces canaux n'affichent aucun fond.",
"backgroundRemove": "Retirer le fond",
"addonImmersive": "Contenu immersif",
"addonImmersiveHint": "Image 360, vidéo 360 et modèles 3D. L'activation ajoute 100 Go au quota de stockage du plan, et les retire à la désactivation — une vidéo 360 de 5 min pèse des Go.",
"channelApplicationCreated": "Canal activé et application créée",
"noAppConfigured": "Aucune application configurée sur cette instance", "noAppConfigured": "Aucune application configurée sur cette instance",
"mobileAppNotConfigured": "Application mobile non configurée", "mobileAppNotConfigured": "Application mobile non configurée",
"kioskAppNotConfigured": "Application kiosk non configurée", "kioskAppNotConfigured": "Application kiosk non configurée",
"vrAppNotConfigured": "Application VR non configurée", "vrAppNotConfigured": "Application VR non configurée",
"vrTabConfig": "Configuration",
"vrTabHeadsets": "Casques",
"headsets": "Casques",
"noHeadsetPaired": "Aucun casque appairé",
"headsetBattery": "Batterie",
"headsetAppVersion": "Version",
"headsetLastSeen": "Vu le {date}",
"@headsetLastSeen": {
"placeholders": {
"date": {
"type": "String"
}
}
},
"headsetNeverSeen": "Jamais vu",
"headsetSilentHours": "Muet depuis {count} h",
"@headsetSilentHours": {
"placeholders": {
"count": {
"type": "String"
}
}
},
"headsetSilentDays": "Muet depuis {count} j",
"@headsetSilentDays": {
"placeholders": {
"count": {
"type": "String"
}
}
},
"updateHeadsetBtn": "Mettre à jour le casque",
"titleColonLabel": "Titre :", "titleColonLabel": "Titre :",
"titleModalLabel": "Titre", "titleModalLabel": "Titre",
"descriptionColonLabel": "Description :", "descriptionColonLabel": "Description :",
@ -1386,6 +1435,34 @@
"resourceTypePdf": "PDF", "resourceTypePdf": "PDF",
"resourceTypeJson": "JSON", "resourceTypeJson": "JSON",
"resourceTypeJsonUrl": "JSON (URL)", "resourceTypeJsonUrl": "JSON (URL)",
"resourceTypeImage360": "Image 360°",
"resourceTypeVideo360": "Vidéo 360°",
"resourceTypeModel3D": "Modèle 3D",
"model3DResourceLabel": "Modèle 3D",
"model3DPickModelFirst": "Choisissez d'abord un modèle 3D dans la médiathèque.",
"model3DPlacePoints": "Faites glisser les points d'intérêt sur le modèle. Leur position est enregistrée avec la section.",
"scene3DModeTitle": "Ce que le visiteur fait de la scène",
"scene3DModeAsset": "Objet",
"scene3DModeScene": "Décor",
"scene3DModeAssetHint": "Un objet posé devant le visiteur, qu'il regarde sous toutes ses faces — une épée, une pièce de collection. Les points tournent avec l'objet.",
"scene3DModeSceneHint": "Un lieu dans lequel le visiteur se trouve et regarde autour — une salle reconstituée. Les points restent où ils sont.",
"model3DPointsTitle": "Points d'intérêt ({count})",
"@model3DPointsTitle": {
"placeholders": {
"count": {
"type": "String"
}
}
},
"model3DAddPoint": "Ajouter un point",
"model3DNoPoint": "Aucun point sur ce modèle 3D.",
"model3DNewPoint": "Nouveau point",
"model3DPointNotPlaced": "Pas encore placé",
"model3DPointSaveError": "La position n'a pas pu être enregistrée",
"sectionTypeScene3D": "Scène 3D",
"sectionTypeDescScene3D": "Un objet ou un décor en 3D que le visiteur explore, avec des points d'intérêt posés dessus. Affiché dans le casque VR.",
"mediaModel3DNoPreview": "Pas d'aperçu ici : le modèle 3D se visualise dans une section Scène 3D, et dans le casque.",
"mediaToggle360Hint": "Toucher pour basculer entre média plat et 360° — une image 360° est affichée en immersif dans le casque et n'est pas compressée",
"minutesAbbr": "min", "minutesAbbr": "min",
"secondsAbbr": "sec", "secondsAbbr": "sec",
"metersAbbr": "m", "metersAbbr": "m",

View File

@ -4147,7 +4147,7 @@ abstract class AppLocalizations {
/// No description provided for @mediaDropFormats. /// No description provided for @mediaDropFormats.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
/// **'JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON'** /// **'JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON, GLB'**
String get mediaDropFormats; String get mediaDropFormats;
/// No description provided for @mediaOrPasteUrl. /// No description provided for @mediaOrPasteUrl.
@ -4240,6 +4240,108 @@ abstract class AppLocalizations {
/// **'VR'** /// **'VR'**
String get menuVr; String get menuVr;
/// No description provided for @channelsSectionTitle.
///
/// In fr, this message translates to:
/// **'Canaux'**
String get channelsSectionTitle;
/// No description provided for @channelVrHint.
///
/// In fr, this message translates to:
/// **'Crée aussi l\'application VR si elle n\'existe pas'**
String get channelVrHint;
/// No description provided for @addonsSectionTitle.
///
/// In fr, this message translates to:
/// **'Add-ons'**
String get addonsSectionTitle;
/// No description provided for @addonAssistant.
///
/// In fr, this message translates to:
/// **'Assistant IA'**
String get addonAssistant;
/// No description provided for @backgroundTitle.
///
/// In fr, this message translates to:
/// **'Fond immersif'**
String get backgroundTitle;
/// No description provided for @backgroundHintConfiguration.
///
/// In fr, this message translates to:
/// **'Le décor dans lequel cette visite se déroule dans le casque. Sans lui, les contenus flottent dans le noir.'**
String get backgroundHintConfiguration;
/// No description provided for @backgroundHintMenu.
///
/// In fr, this message translates to:
/// **'Le décor du menu d\'accueil du casque, avant que le visiteur ait choisi quoi que ce soit.'**
String get backgroundHintMenu;
/// No description provided for @backgroundResourceLabel.
///
/// In fr, this message translates to:
/// **'Média du fond'**
String get backgroundResourceLabel;
/// No description provided for @backgroundKindPano.
///
/// In fr, this message translates to:
/// **'Panorama 360° : affiché tout autour du visiteur, dans le casque.'**
String get backgroundKindPano;
/// No description provided for @backgroundKindVideo360.
///
/// In fr, this message translates to:
/// **'Vidéo 360° : jouée en boucle et sans son. ⚠️ Sur une borne, le décodeur tourne toute la journée.'**
String get backgroundKindVideo360;
/// No description provided for @backgroundKindScene3D.
///
/// In fr, this message translates to:
/// **'Décor 3D : prévu par le contrat, pas encore affiché en fond par le casque.'**
String get backgroundKindScene3D;
/// No description provided for @backgroundFallbackLabel.
///
/// In fr, this message translates to:
/// **'Image de repli'**
String get backgroundFallbackLabel;
/// No description provided for @backgroundFallbackHint.
///
/// In fr, this message translates to:
/// **'Affichée par le web, le mobile et les tablettes, qui ne savent pas rendre une 360. Sans elle, ces canaux n\'affichent aucun fond.'**
String get backgroundFallbackHint;
/// No description provided for @backgroundRemove.
///
/// In fr, this message translates to:
/// **'Retirer le fond'**
String get backgroundRemove;
/// No description provided for @addonImmersive.
///
/// In fr, this message translates to:
/// **'Contenu immersif'**
String get addonImmersive;
/// No description provided for @addonImmersiveHint.
///
/// In fr, this message translates to:
/// **'Image 360, vidéo 360 et modèles 3D. L\'activation ajoute 100 Go au quota de stockage du plan, et les retire à la désactivation — une vidéo 360 de 5 min pèse des Go.'**
String get addonImmersiveHint;
/// No description provided for @channelApplicationCreated.
///
/// In fr, this message translates to:
/// **'Canal activé et application créée'**
String get channelApplicationCreated;
/// No description provided for @noAppConfigured. /// No description provided for @noAppConfigured.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
@ -4264,6 +4366,72 @@ abstract class AppLocalizations {
/// **'Application VR non configurée'** /// **'Application VR non configurée'**
String get vrAppNotConfigured; String get vrAppNotConfigured;
/// No description provided for @vrTabConfig.
///
/// In fr, this message translates to:
/// **'Configuration'**
String get vrTabConfig;
/// No description provided for @vrTabHeadsets.
///
/// In fr, this message translates to:
/// **'Casques'**
String get vrTabHeadsets;
/// No description provided for @headsets.
///
/// In fr, this message translates to:
/// **'Casques'**
String get headsets;
/// No description provided for @noHeadsetPaired.
///
/// In fr, this message translates to:
/// **'Aucun casque appairé'**
String get noHeadsetPaired;
/// No description provided for @headsetBattery.
///
/// In fr, this message translates to:
/// **'Batterie'**
String get headsetBattery;
/// No description provided for @headsetAppVersion.
///
/// In fr, this message translates to:
/// **'Version'**
String get headsetAppVersion;
/// No description provided for @headsetLastSeen.
///
/// In fr, this message translates to:
/// **'Vu le {date}'**
String headsetLastSeen(String date);
/// No description provided for @headsetNeverSeen.
///
/// In fr, this message translates to:
/// **'Jamais vu'**
String get headsetNeverSeen;
/// No description provided for @headsetSilentHours.
///
/// In fr, this message translates to:
/// **'Muet depuis {count} h'**
String headsetSilentHours(String count);
/// No description provided for @headsetSilentDays.
///
/// In fr, this message translates to:
/// **'Muet depuis {count} j'**
String headsetSilentDays(String count);
/// No description provided for @updateHeadsetBtn.
///
/// In fr, this message translates to:
/// **'Mettre à jour le casque'**
String get updateHeadsetBtn;
/// No description provided for @titleColonLabel. /// No description provided for @titleColonLabel.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:
@ -5818,6 +5986,132 @@ abstract class AppLocalizations {
/// **'JSON (URL)'** /// **'JSON (URL)'**
String get resourceTypeJsonUrl; String get resourceTypeJsonUrl;
/// No description provided for @resourceTypeImage360.
///
/// In fr, this message translates to:
/// **'Image 360°'**
String get resourceTypeImage360;
/// No description provided for @resourceTypeVideo360.
///
/// In fr, this message translates to:
/// **'Vidéo 360°'**
String get resourceTypeVideo360;
/// No description provided for @resourceTypeModel3D.
///
/// In fr, this message translates to:
/// **'Modèle 3D'**
String get resourceTypeModel3D;
/// No description provided for @model3DResourceLabel.
///
/// In fr, this message translates to:
/// **'Modèle 3D'**
String get model3DResourceLabel;
/// No description provided for @model3DPickModelFirst.
///
/// In fr, this message translates to:
/// **'Choisissez d\'abord un modèle 3D dans la médiathèque.'**
String get model3DPickModelFirst;
/// No description provided for @model3DPlacePoints.
///
/// In fr, this message translates to:
/// **'Faites glisser les points d\'intérêt sur le modèle. Leur position est enregistrée avec la section.'**
String get model3DPlacePoints;
/// No description provided for @scene3DModeTitle.
///
/// In fr, this message translates to:
/// **'Ce que le visiteur fait de la scène'**
String get scene3DModeTitle;
/// No description provided for @scene3DModeAsset.
///
/// In fr, this message translates to:
/// **'Objet'**
String get scene3DModeAsset;
/// No description provided for @scene3DModeScene.
///
/// In fr, this message translates to:
/// **'Décor'**
String get scene3DModeScene;
/// No description provided for @scene3DModeAssetHint.
///
/// In fr, this message translates to:
/// **'Un objet posé devant le visiteur, qu\'il regarde sous toutes ses faces — une épée, une pièce de collection. Les points tournent avec l\'objet.'**
String get scene3DModeAssetHint;
/// No description provided for @scene3DModeSceneHint.
///
/// In fr, this message translates to:
/// **'Un lieu dans lequel le visiteur se trouve et regarde autour — une salle reconstituée. Les points restent où ils sont.'**
String get scene3DModeSceneHint;
/// No description provided for @model3DPointsTitle.
///
/// In fr, this message translates to:
/// **'Points d\'intérêt ({count})'**
String model3DPointsTitle(String count);
/// No description provided for @model3DAddPoint.
///
/// In fr, this message translates to:
/// **'Ajouter un point'**
String get model3DAddPoint;
/// No description provided for @model3DNoPoint.
///
/// In fr, this message translates to:
/// **'Aucun point sur ce modèle 3D.'**
String get model3DNoPoint;
/// No description provided for @model3DNewPoint.
///
/// In fr, this message translates to:
/// **'Nouveau point'**
String get model3DNewPoint;
/// No description provided for @model3DPointNotPlaced.
///
/// In fr, this message translates to:
/// **'Pas encore placé'**
String get model3DPointNotPlaced;
/// No description provided for @model3DPointSaveError.
///
/// In fr, this message translates to:
/// **'La position n\'a pas pu être enregistrée'**
String get model3DPointSaveError;
/// No description provided for @sectionTypeScene3D.
///
/// In fr, this message translates to:
/// **'Scène 3D'**
String get sectionTypeScene3D;
/// No description provided for @sectionTypeDescScene3D.
///
/// In fr, this message translates to:
/// **'Un objet ou un décor en 3D que le visiteur explore, avec des points d\'intérêt posés dessus. Affiché dans le casque VR.'**
String get sectionTypeDescScene3D;
/// No description provided for @mediaModel3DNoPreview.
///
/// In fr, this message translates to:
/// **'Pas d\'aperçu ici : le modèle 3D se visualise dans une section Scène 3D, et dans le casque.'**
String get mediaModel3DNoPreview;
/// No description provided for @mediaToggle360Hint.
///
/// In fr, this message translates to:
/// **'Toucher pour basculer entre média plat et 360° — une image 360° est affichée en immersif dans le casque et n\'est pas compressée'**
String get mediaToggle360Hint;
/// No description provided for @minutesAbbr. /// No description provided for @minutesAbbr.
/// ///
/// In fr, this message translates to: /// In fr, this message translates to:

View File

@ -2248,7 +2248,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get mediaDropZone => 'Drop your files here or click to browse'; String get mediaDropZone => 'Drop your files here or click to browse';
@override @override
String get mediaDropFormats => 'JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON'; String get mediaDropFormats =>
'JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON, GLB';
@override @override
String get mediaOrPasteUrl => 'or paste a URL'; String get mediaOrPasteUrl => 'or paste a URL';
@ -2310,6 +2311,65 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get menuVr => 'VR'; String get menuVr => 'VR';
@override
String get channelsSectionTitle => 'Channels';
@override
String get channelVrHint => 'Also creates the VR application if missing';
@override
String get addonsSectionTitle => 'Add-ons';
@override
String get addonAssistant => 'AI assistant';
@override
String get backgroundTitle => 'Immersive background';
@override
String get backgroundHintConfiguration =>
'The setting this visit takes place in, inside the headset. Without it, content floats in the dark.';
@override
String get backgroundHintMenu =>
'The setting of the headset\'s home menu, before the visitor has chosen anything.';
@override
String get backgroundResourceLabel => 'Background media';
@override
String get backgroundKindPano =>
'360° panorama: shown all around the visitor, in the headset.';
@override
String get backgroundKindVideo360 =>
'360° video: looped and muted. ⚠️ On a kiosk, the decoder runs all day.';
@override
String get backgroundKindScene3D =>
'3D setting: in the contract, not yet shown as a background by the headset.';
@override
String get backgroundFallbackLabel => 'Fallback image';
@override
String get backgroundFallbackHint =>
'Shown by web, mobile and tablets, which cannot render a 360. Without it, those channels show no background at all.';
@override
String get backgroundRemove => 'Remove background';
@override
String get addonImmersive => 'Immersive content';
@override
String get addonImmersiveHint =>
'360° images, 360° video and 3D models. Turning it on adds 100 GB to the plan\'s storage quota, and turning it off removes them — a 5-minute 360° video weighs gigabytes.';
@override
String get channelApplicationCreated =>
'Channel enabled and application created';
@override @override
String get noAppConfigured => 'No application configured on this instance'; String get noAppConfigured => 'No application configured on this instance';
@ -2322,6 +2382,45 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get vrAppNotConfigured => 'VR application not configured'; String get vrAppNotConfigured => 'VR application not configured';
@override
String get vrTabConfig => 'Configuration';
@override
String get vrTabHeadsets => 'Headsets';
@override
String get headsets => 'Headsets';
@override
String get noHeadsetPaired => 'No headset paired';
@override
String get headsetBattery => 'Battery';
@override
String get headsetAppVersion => 'Version';
@override
String headsetLastSeen(String date) {
return 'Seen on $date';
}
@override
String get headsetNeverSeen => 'Never seen';
@override
String headsetSilentHours(String count) {
return 'Silent for $count h';
}
@override
String headsetSilentDays(String count) {
return 'Silent for $count d';
}
@override
String get updateHeadsetBtn => 'Update headset';
@override @override
String get titleColonLabel => 'Title:'; String get titleColonLabel => 'Title:';
@ -3148,6 +3247,78 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get resourceTypeJsonUrl => 'JSON (URL)'; String get resourceTypeJsonUrl => 'JSON (URL)';
@override
String get resourceTypeImage360 => '360° image';
@override
String get resourceTypeVideo360 => '360° video';
@override
String get resourceTypeModel3D => '3D model';
@override
String get model3DResourceLabel => '3D model';
@override
String get model3DPickModelFirst =>
'Pick a 3D model from the media library first.';
@override
String get model3DPlacePoints =>
'Drag the points of interest onto the model. Their position is saved with the section.';
@override
String get scene3DModeTitle => 'What the visitor does with the scene';
@override
String get scene3DModeAsset => 'Object';
@override
String get scene3DModeScene => 'Place';
@override
String get scene3DModeAssetHint =>
'An object placed in front of the visitor, examined from every side — a sword, a collection piece. Points turn with the object.';
@override
String get scene3DModeSceneHint =>
'A place the visitor stands in and looks around — a reconstructed room. Points stay where they are.';
@override
String model3DPointsTitle(String count) {
return 'Points of interest ($count)';
}
@override
String get model3DAddPoint => 'Add a point';
@override
String get model3DNoPoint => 'No point on this model yet.';
@override
String get model3DNewPoint => 'New point';
@override
String get model3DPointNotPlaced => 'Not placed yet';
@override
String get model3DPointSaveError => 'The position could not be saved';
@override
String get sectionTypeScene3D => '3D scene';
@override
String get sectionTypeDescScene3D =>
'A 3D object or setting the visitor explores, with points of interest placed on it. Shown in the VR headset.';
@override
String get mediaModel3DNoPreview =>
'No preview here: a 3D model is viewed in a 3D scene section, and in the headset.';
@override
String get mediaToggle360Hint =>
'Tap to switch between flat and 360° media — a 360° image is shown immersively in the headset and is never compressed';
@override @override
String get minutesAbbr => 'min'; String get minutesAbbr => 'min';

View File

@ -2292,7 +2292,8 @@ class AppLocalizationsFr extends AppLocalizations {
'Glissez vos fichiers ici ou cliquez pour parcourir'; 'Glissez vos fichiers ici ou cliquez pour parcourir';
@override @override
String get mediaDropFormats => 'JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON'; String get mediaDropFormats =>
'JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON, GLB';
@override @override
String get mediaOrPasteUrl => 'ou coller une URL'; String get mediaOrPasteUrl => 'ou coller une URL';
@ -2354,6 +2355,65 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get menuVr => 'VR'; String get menuVr => 'VR';
@override
String get channelsSectionTitle => 'Canaux';
@override
String get channelVrHint =>
'Crée aussi l\'application VR si elle n\'existe pas';
@override
String get addonsSectionTitle => 'Add-ons';
@override
String get addonAssistant => 'Assistant IA';
@override
String get backgroundTitle => 'Fond immersif';
@override
String get backgroundHintConfiguration =>
'Le décor dans lequel cette visite se déroule dans le casque. Sans lui, les contenus flottent dans le noir.';
@override
String get backgroundHintMenu =>
'Le décor du menu d\'accueil du casque, avant que le visiteur ait choisi quoi que ce soit.';
@override
String get backgroundResourceLabel => 'Média du fond';
@override
String get backgroundKindPano =>
'Panorama 360° : affiché tout autour du visiteur, dans le casque.';
@override
String get backgroundKindVideo360 =>
'Vidéo 360° : jouée en boucle et sans son. ⚠️ Sur une borne, le décodeur tourne toute la journée.';
@override
String get backgroundKindScene3D =>
'Décor 3D : prévu par le contrat, pas encore affiché en fond par le casque.';
@override
String get backgroundFallbackLabel => 'Image de repli';
@override
String get backgroundFallbackHint =>
'Affichée par le web, le mobile et les tablettes, qui ne savent pas rendre une 360. Sans elle, ces canaux n\'affichent aucun fond.';
@override
String get backgroundRemove => 'Retirer le fond';
@override
String get addonImmersive => 'Contenu immersif';
@override
String get addonImmersiveHint =>
'Image 360, vidéo 360 et modèles 3D. L\'activation ajoute 100 Go au quota de stockage du plan, et les retire à la désactivation — une vidéo 360 de 5 min pèse des Go.';
@override
String get channelApplicationCreated => 'Canal activé et application créée';
@override @override
String get noAppConfigured => String get noAppConfigured =>
'Aucune application configurée sur cette instance'; 'Aucune application configurée sur cette instance';
@ -2367,6 +2427,45 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get vrAppNotConfigured => 'Application VR non configurée'; String get vrAppNotConfigured => 'Application VR non configurée';
@override
String get vrTabConfig => 'Configuration';
@override
String get vrTabHeadsets => 'Casques';
@override
String get headsets => 'Casques';
@override
String get noHeadsetPaired => 'Aucun casque appairé';
@override
String get headsetBattery => 'Batterie';
@override
String get headsetAppVersion => 'Version';
@override
String headsetLastSeen(String date) {
return 'Vu le $date';
}
@override
String get headsetNeverSeen => 'Jamais vu';
@override
String headsetSilentHours(String count) {
return 'Muet depuis $count h';
}
@override
String headsetSilentDays(String count) {
return 'Muet depuis $count j';
}
@override
String get updateHeadsetBtn => 'Mettre à jour le casque';
@override @override
String get titleColonLabel => 'Titre :'; String get titleColonLabel => 'Titre :';
@ -3199,6 +3298,79 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get resourceTypeJsonUrl => 'JSON (URL)'; String get resourceTypeJsonUrl => 'JSON (URL)';
@override
String get resourceTypeImage360 => 'Image 360°';
@override
String get resourceTypeVideo360 => 'Vidéo 360°';
@override
String get resourceTypeModel3D => 'Modèle 3D';
@override
String get model3DResourceLabel => 'Modèle 3D';
@override
String get model3DPickModelFirst =>
'Choisissez d\'abord un modèle 3D dans la médiathèque.';
@override
String get model3DPlacePoints =>
'Faites glisser les points d\'intérêt sur le modèle. Leur position est enregistrée avec la section.';
@override
String get scene3DModeTitle => 'Ce que le visiteur fait de la scène';
@override
String get scene3DModeAsset => 'Objet';
@override
String get scene3DModeScene => 'Décor';
@override
String get scene3DModeAssetHint =>
'Un objet posé devant le visiteur, qu\'il regarde sous toutes ses faces — une épée, une pièce de collection. Les points tournent avec l\'objet.';
@override
String get scene3DModeSceneHint =>
'Un lieu dans lequel le visiteur se trouve et regarde autour — une salle reconstituée. Les points restent où ils sont.';
@override
String model3DPointsTitle(String count) {
return 'Points d\'intérêt ($count)';
}
@override
String get model3DAddPoint => 'Ajouter un point';
@override
String get model3DNoPoint => 'Aucun point sur ce modèle 3D.';
@override
String get model3DNewPoint => 'Nouveau point';
@override
String get model3DPointNotPlaced => 'Pas encore placé';
@override
String get model3DPointSaveError =>
'La position n\'a pas pu être enregistrée';
@override
String get sectionTypeScene3D => 'Scène 3D';
@override
String get sectionTypeDescScene3D =>
'Un objet ou un décor en 3D que le visiteur explore, avec des points d\'intérêt posés dessus. Affiché dans le casque VR.';
@override
String get mediaModel3DNoPreview =>
'Pas d\'aperçu ici : le modèle 3D se visualise dans une section Scène 3D, et dans le casque.';
@override
String get mediaToggle360Hint =>
'Toucher pour basculer entre média plat et 360° — une image 360° est affichée en immersif dans le casque et n\'est pas compressée';
@override @override
String get minutesAbbr => 'min'; String get minutesAbbr => 'min';

View File

@ -2272,7 +2272,8 @@ class AppLocalizationsNl extends AppLocalizations {
'Sleep uw bestanden hierheen of klik om te bladeren'; 'Sleep uw bestanden hierheen of klik om te bladeren';
@override @override
String get mediaDropFormats => 'JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON'; String get mediaDropFormats =>
'JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON, GLB';
@override @override
String get mediaOrPasteUrl => 'of plak een URL'; String get mediaOrPasteUrl => 'of plak een URL';
@ -2335,6 +2336,66 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get menuVr => 'VR'; String get menuVr => 'VR';
@override
String get channelsSectionTitle => 'Kanalen';
@override
String get channelVrHint =>
'Maakt ook de VR-applicatie aan als die ontbreekt';
@override
String get addonsSectionTitle => 'Add-ons';
@override
String get addonAssistant => 'AI-assistent';
@override
String get backgroundTitle => 'Immersieve achtergrond';
@override
String get backgroundHintConfiguration =>
'Het decor waarin dit bezoek zich in de headset afspeelt. Zonder decor zweeft de inhoud in het donker.';
@override
String get backgroundHintMenu =>
'Het decor van het hoofdmenu van de headset, voordat de bezoeker iets gekozen heeft.';
@override
String get backgroundResourceLabel => 'Media van de achtergrond';
@override
String get backgroundKindPano =>
'360°-panorama: rondom de bezoeker getoond, in de headset.';
@override
String get backgroundKindVideo360 =>
'360°-video: in lus en zonder geluid. ⚠️ Op een kiosk draait de decoder de hele dag.';
@override
String get backgroundKindScene3D =>
'3D-decor: voorzien in het contract, nog niet als achtergrond getoond door de headset.';
@override
String get backgroundFallbackLabel => 'Terugvalafbeelding';
@override
String get backgroundFallbackHint =>
'Getoond door web, mobiel en tablets, die geen 360 kunnen renderen. Zonder deze tonen die kanalen geen achtergrond.';
@override
String get backgroundRemove => 'Achtergrond verwijderen';
@override
String get addonImmersive => 'Immersieve content';
@override
String get addonImmersiveHint =>
'360°-afbeeldingen, 360°-video en 3D-modellen. Inschakelen voegt 100 GB toe aan het opslagquotum van het plan, uitschakelen haalt ze weg — een 360°-video van 5 min weegt gigabytes.';
@override
String get channelApplicationCreated =>
'Kanaal ingeschakeld en applicatie aangemaakt';
@override @override
String get noAppConfigured => String get noAppConfigured =>
'Geen applicatie geconfigureerd op deze instantie'; 'Geen applicatie geconfigureerd op deze instantie';
@ -2348,6 +2409,45 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get vrAppNotConfigured => 'VR-applicatie niet geconfigureerd'; String get vrAppNotConfigured => 'VR-applicatie niet geconfigureerd';
@override
String get vrTabConfig => 'Configuratie';
@override
String get vrTabHeadsets => 'Headsets';
@override
String get headsets => 'Headsets';
@override
String get noHeadsetPaired => 'Geen headset gekoppeld';
@override
String get headsetBattery => 'Batterij';
@override
String get headsetAppVersion => 'Versie';
@override
String headsetLastSeen(String date) {
return 'Gezien op $date';
}
@override
String get headsetNeverSeen => 'Nooit gezien';
@override
String headsetSilentHours(String count) {
return '$count u zonder signaal';
}
@override
String headsetSilentDays(String count) {
return '$count d zonder signaal';
}
@override
String get updateHeadsetBtn => 'Headset bijwerken';
@override @override
String get titleColonLabel => 'Titel:'; String get titleColonLabel => 'Titel:';
@ -3175,6 +3275,78 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get resourceTypeJsonUrl => 'JSON (URL)'; String get resourceTypeJsonUrl => 'JSON (URL)';
@override
String get resourceTypeImage360 => '360°-afbeelding';
@override
String get resourceTypeVideo360 => '360°-video';
@override
String get resourceTypeModel3D => '3D-model';
@override
String get model3DResourceLabel => '3D-model';
@override
String get model3DPickModelFirst =>
'Kies eerst een 3D-model uit de mediatheek.';
@override
String get model3DPlacePoints =>
'Sleep de interessepunten op het model. Hun positie wordt met de sectie opgeslagen.';
@override
String get scene3DModeTitle => 'Wat de bezoeker met de scène doet';
@override
String get scene3DModeAsset => 'Object';
@override
String get scene3DModeScene => 'Ruimte';
@override
String get scene3DModeAssetHint =>
'Een object voor de bezoeker, van alle kanten te bekijken — een zwaard, een collectiestuk. Punten draaien mee met het object.';
@override
String get scene3DModeSceneHint =>
'Een plek waar de bezoeker in staat en rondkijkt — een gereconstrueerde zaal. Punten blijven waar ze zijn.';
@override
String model3DPointsTitle(String count) {
return 'Interessepunten ($count)';
}
@override
String get model3DAddPoint => 'Punt toevoegen';
@override
String get model3DNoPoint => 'Nog geen punt op dit model.';
@override
String get model3DNewPoint => 'Nieuw punt';
@override
String get model3DPointNotPlaced => 'Nog niet geplaatst';
@override
String get model3DPointSaveError => 'De positie kon niet worden opgeslagen';
@override
String get sectionTypeScene3D => '3D-scène';
@override
String get sectionTypeDescScene3D =>
'Een 3D-object of decor dat de bezoeker verkent, met interessepunten erop. Wordt in de VR-headset getoond.';
@override
String get mediaModel3DNoPreview =>
'Geen voorbeeld hier: een 3D-model bekijk je in een 3D-scènesectie en in de headset.';
@override
String get mediaToggle360Hint =>
'Tik om te wisselen tussen plat en 360° — een 360°-afbeelding wordt immersief in de headset getoond en wordt niet gecomprimeerd';
@override @override
String get minutesAbbr => 'min'; String get minutesAbbr => 'min';

View File

@ -1005,7 +1005,7 @@
"@_mediatheque_dialogue": {}, "@_mediatheque_dialogue": {},
"mediaAddTitle": "Een bron toevoegen", "mediaAddTitle": "Een bron toevoegen",
"mediaDropZone": "Sleep uw bestanden hierheen of klik om te bladeren", "mediaDropZone": "Sleep uw bestanden hierheen of klik om te bladeren",
"mediaDropFormats": "JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON", "mediaDropFormats": "JPG, PNG, GIF, MP3, MP4, WEBM, PDF, JSON, GLB",
"mediaOrPasteUrl": "of plak een URL", "mediaOrPasteUrl": "of plak een URL",
"mediaUrlHint": "https://… afbeelding, YouTube-video of JSON-bestand", "mediaUrlHint": "https://… afbeelding, YouTube-video of JSON-bestand",
"mediaUrlInvalid": "Deze URL is niet geldig", "mediaUrlInvalid": "Deze URL is niet geldig",
@ -1052,10 +1052,59 @@
"menuKiosk": "Kiosk", "menuKiosk": "Kiosk",
"menuWeb": "Web", "menuWeb": "Web",
"menuVr": "VR", "menuVr": "VR",
"channelsSectionTitle": "Kanalen",
"channelVrHint": "Maakt ook de VR-applicatie aan als die ontbreekt",
"addonsSectionTitle": "Add-ons",
"addonAssistant": "AI-assistent",
"backgroundTitle": "Immersieve achtergrond",
"backgroundHintConfiguration": "Het decor waarin dit bezoek zich in de headset afspeelt. Zonder decor zweeft de inhoud in het donker.",
"backgroundHintMenu": "Het decor van het hoofdmenu van de headset, voordat de bezoeker iets gekozen heeft.",
"backgroundResourceLabel": "Media van de achtergrond",
"backgroundKindPano": "360°-panorama: rondom de bezoeker getoond, in de headset.",
"backgroundKindVideo360": "360°-video: in lus en zonder geluid. ⚠️ Op een kiosk draait de decoder de hele dag.",
"backgroundKindScene3D": "3D-decor: voorzien in het contract, nog niet als achtergrond getoond door de headset.",
"backgroundFallbackLabel": "Terugvalafbeelding",
"backgroundFallbackHint": "Getoond door web, mobiel en tablets, die geen 360 kunnen renderen. Zonder deze tonen die kanalen geen achtergrond.",
"backgroundRemove": "Achtergrond verwijderen",
"addonImmersive": "Immersieve content",
"addonImmersiveHint": "360°-afbeeldingen, 360°-video en 3D-modellen. Inschakelen voegt 100 GB toe aan het opslagquotum van het plan, uitschakelen haalt ze weg — een 360°-video van 5 min weegt gigabytes.",
"channelApplicationCreated": "Kanaal ingeschakeld en applicatie aangemaakt",
"noAppConfigured": "Geen applicatie geconfigureerd op deze instantie", "noAppConfigured": "Geen applicatie geconfigureerd op deze instantie",
"mobileAppNotConfigured": "Mobiele applicatie niet geconfigureerd", "mobileAppNotConfigured": "Mobiele applicatie niet geconfigureerd",
"kioskAppNotConfigured": "Kioskapplicatie niet geconfigureerd", "kioskAppNotConfigured": "Kioskapplicatie niet geconfigureerd",
"vrAppNotConfigured": "VR-applicatie niet geconfigureerd", "vrAppNotConfigured": "VR-applicatie niet geconfigureerd",
"vrTabConfig": "Configuratie",
"vrTabHeadsets": "Headsets",
"headsets": "Headsets",
"noHeadsetPaired": "Geen headset gekoppeld",
"headsetBattery": "Batterij",
"headsetAppVersion": "Versie",
"headsetLastSeen": "Gezien op {date}",
"@headsetLastSeen": {
"placeholders": {
"date": {
"type": "String"
}
}
},
"headsetNeverSeen": "Nooit gezien",
"headsetSilentHours": "{count} u zonder signaal",
"@headsetSilentHours": {
"placeholders": {
"count": {
"type": "String"
}
}
},
"headsetSilentDays": "{count} d zonder signaal",
"@headsetSilentDays": {
"placeholders": {
"count": {
"type": "String"
}
}
},
"updateHeadsetBtn": "Headset bijwerken",
"titleColonLabel": "Titel:", "titleColonLabel": "Titel:",
"titleModalLabel": "Titel", "titleModalLabel": "Titel",
"descriptionColonLabel": "Beschrijving:", "descriptionColonLabel": "Beschrijving:",
@ -1386,6 +1435,34 @@
"resourceTypePdf": "PDF", "resourceTypePdf": "PDF",
"resourceTypeJson": "JSON", "resourceTypeJson": "JSON",
"resourceTypeJsonUrl": "JSON (URL)", "resourceTypeJsonUrl": "JSON (URL)",
"resourceTypeImage360": "360°-afbeelding",
"resourceTypeVideo360": "360°-video",
"resourceTypeModel3D": "3D-model",
"model3DResourceLabel": "3D-model",
"model3DPickModelFirst": "Kies eerst een 3D-model uit de mediatheek.",
"model3DPlacePoints": "Sleep de interessepunten op het model. Hun positie wordt met de sectie opgeslagen.",
"scene3DModeTitle": "Wat de bezoeker met de scène doet",
"scene3DModeAsset": "Object",
"scene3DModeScene": "Ruimte",
"scene3DModeAssetHint": "Een object voor de bezoeker, van alle kanten te bekijken — een zwaard, een collectiestuk. Punten draaien mee met het object.",
"scene3DModeSceneHint": "Een plek waar de bezoeker in staat en rondkijkt — een gereconstrueerde zaal. Punten blijven waar ze zijn.",
"model3DPointsTitle": "Interessepunten ({count})",
"@model3DPointsTitle": {
"placeholders": {
"count": {
"type": "String"
}
}
},
"model3DAddPoint": "Punt toevoegen",
"model3DNoPoint": "Nog geen punt op dit model.",
"model3DNewPoint": "Nieuw punt",
"model3DPointNotPlaced": "Nog niet geplaatst",
"model3DPointSaveError": "De positie kon niet worden opgeslagen",
"sectionTypeScene3D": "3D-scène",
"sectionTypeDescScene3D": "Een 3D-object of decor dat de bezoeker verkent, met interessepunten erop. Wordt in de VR-headset getoond.",
"mediaModel3DNoPreview": "Geen voorbeeld hier: een 3D-model bekijk je in een 3D-scènesectie en in de headset.",
"mediaToggle360Hint": "Tik om te wisselen tussen plat en 360° — een 360°-afbeelding wordt immersief in de headset getoond en wordt niet gecomprimeerd",
"minutesAbbr": "min", "minutesAbbr": "min",
"secondsAbbr": "sec", "secondsAbbr": "sec",
"metersAbbr": "m", "metersAbbr": "m",

View File

@ -42,6 +42,7 @@ part 'api/section_api.dart';
part 'api/section_agenda_api.dart'; part 'api/section_agenda_api.dart';
part 'api/section_event_api.dart'; part 'api/section_event_api.dart';
part 'api/section_map_api.dart'; part 'api/section_map_api.dart';
part 'api/section_scene3_d_api.dart';
part 'api/section_parcours_api.dart'; part 'api/section_parcours_api.dart';
part 'api/section_quiz_api.dart'; part 'api/section_quiz_api.dart';
part 'api/notification_api.dart'; part 'api/notification_api.dart';
@ -170,6 +171,11 @@ part 'model/section_event.dart';
part 'model/section_event_dto.dart'; part 'model/section_event_dto.dart';
part 'model/section_game.dart'; part 'model/section_game.dart';
part 'model/parcours_dto.dart'; part 'model/parcours_dto.dart';
part 'model/immersive_background_dto.dart';
part 'model/immersive_background_kind.dart';
part 'model/scene3_d_dto.dart';
part 'model/scene3_d_mode.dart';
part 'model/position3_d.dart';
part 'model/section_game_all_of_game_puzzle_image.dart'; part 'model/section_game_all_of_game_puzzle_image.dart';
part 'model/section_map.dart'; part 'model/section_map.dart';
part 'model/section_map_all_of_map_map_provider.dart'; part 'model/section_map_all_of_map_map_provider.dart';

View File

@ -129,8 +129,11 @@ class DeviceApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] instanceId: /// * [String] instanceId:
///
/// * [AppType] appType:
Future<Response> deviceGetWithHttpInfo({ Future<Response> deviceGetWithHttpInfo({
String? instanceId, String? instanceId,
AppType? appType,
}) async { }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/api/Device'; final path = r'/api/Device';
@ -145,6 +148,9 @@ class DeviceApi {
if (instanceId != null) { if (instanceId != null) {
queryParams.addAll(_queryParams('', 'instanceId', instanceId)); queryParams.addAll(_queryParams('', 'instanceId', instanceId));
} }
if (appType != null) {
queryParams.addAll(_queryParams('', 'appType', appType));
}
const contentTypes = <String>[]; const contentTypes = <String>[];
@ -162,11 +168,15 @@ class DeviceApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] instanceId: /// * [String] instanceId:
///
/// * [AppType] appType:
Future<List<DeviceDTO>?> deviceGet({ Future<List<DeviceDTO>?> deviceGet({
String? instanceId, String? instanceId,
AppType? appType,
}) async { }) async {
final response = await deviceGetWithHttpInfo( final response = await deviceGetWithHttpInfo(
instanceId: instanceId, instanceId: instanceId,
appType: appType,
); );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));

View File

@ -0,0 +1,167 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Les points d'interet d'une section maquette 3D (item E7 du lot XR-4).
///
/// Meme objet que les points d'une carte — un `GeoPointDTO` — sur une autre
/// collection : ce qui change, c'est `localTransform` au lieu de `geometry`.
class SectionScene3DApi {
SectionScene3DApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'GET /api/SectionScene3D/{sectionId}/points' operation.
Future<Response> sectionScene3DGetPointsWithHttpInfo(String sectionId,) async {
final path = r'/api/SectionScene3D/{sectionId}/points'
.replaceAll('{sectionId}', sectionId);
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<List<GeoPointDTO>?> sectionScene3DGetPoints(String sectionId,) async {
final response = await sectionScene3DGetPointsWithHttpInfo(sectionId,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<GeoPointDTO>') as List)
.cast<GeoPointDTO>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'POST /api/SectionScene3D/{sectionId}/points' operation.
Future<Response> sectionScene3DCreatePointWithHttpInfo(
String sectionId,
GeoPointDTO geoPointDTO,
) async {
final path = r'/api/SectionScene3D/{sectionId}/points'
.replaceAll('{sectionId}', sectionId);
Object? postBody = geoPointDTO;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<GeoPointDTO?> sectionScene3DCreatePoint(
String sectionId,
GeoPointDTO geoPointDTO,
) async {
final response = await sectionScene3DCreatePointWithHttpInfo(sectionId, geoPointDTO,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(
await _decodeBodyBytes(response), 'GeoPointDTO',) as GeoPointDTO;
}
return null;
}
/// Performs an HTTP 'PUT /api/SectionScene3D/points' operation.
Future<Response> sectionScene3DUpdatePointWithHttpInfo(GeoPointDTO geoPointDTO,) async {
final path = r'/api/SectionScene3D/points';
Object? postBody = geoPointDTO;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<GeoPointDTO?> sectionScene3DUpdatePoint(GeoPointDTO geoPointDTO,) async {
final response = await sectionScene3DUpdatePointWithHttpInfo(geoPointDTO,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(
await _decodeBodyBytes(response), 'GeoPointDTO',) as GeoPointDTO;
}
return null;
}
/// Performs an HTTP 'DELETE /api/SectionScene3D/points/{id}' operation.
Future<Response> sectionScene3DDeletePointWithHttpInfo(int id,) async {
final path = r'/api/SectionScene3D/points/{id}'
.replaceAll('{id}', id.toString());
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<void> sectionScene3DDeletePoint(int id,) async {
final response = await sectionScene3DDeletePointWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View File

@ -325,6 +325,16 @@ class ApiClient {
return GeoPointDTO.fromJson(value); return GeoPointDTO.fromJson(value);
case 'Geometry': case 'Geometry':
return Geometry.fromJson(value); return Geometry.fromJson(value);
case 'ImmersiveBackgroundDTO':
return ImmersiveBackgroundDTO.fromJson(value);
case 'ImmersiveBackgroundKind':
return ImmersiveBackgroundKindTypeTransformer().decode(value);
case 'Scene3DMode':
return Scene3DModeTypeTransformer().decode(value);
case 'Scene3DDTO':
return Scene3DDTO.fromJson(value);
case 'Position3D':
return Position3D.fromJson(value);
case 'GeometryCentroid': case 'GeometryCentroid':
return GeometryCentroid.fromJson(value); return GeometryCentroid.fromJson(value);
case 'GeometryDTO': case 'GeometryDTO':

View File

@ -68,6 +68,9 @@ String parameterToString(dynamic value) {
if (value is ApiKeyAppType) { if (value is ApiKeyAppType) {
return ApiKeyAppTypeTypeTransformer().encode(value).toString(); return ApiKeyAppTypeTypeTransformer().encode(value).toString();
} }
if (value is Scene3DMode) {
return Scene3DModeTypeTransformer().encode(value).toString();
}
if (value is AppType) { if (value is AppType) {
return AppTypeTypeTransformer().encode(value).toString(); return AppTypeTypeTransformer().encode(value).toString();
} }

View File

@ -26,12 +26,14 @@ class ApiKeyAppType {
static const number0 = ApiKeyAppType._(0); static const number0 = ApiKeyAppType._(0);
static const number1 = ApiKeyAppType._(1); static const number1 = ApiKeyAppType._(1);
static const number2 = ApiKeyAppType._(2); static const number2 = ApiKeyAppType._(2);
static const number3 = ApiKeyAppType._(3); // VrApp
/// List of all possible values in this [enum][ApiKeyAppType]. /// List of all possible values in this [enum][ApiKeyAppType].
static const values = <ApiKeyAppType>[ static const values = <ApiKeyAppType>[
number0, number0,
number1, number1,
number2, number2,
number3,
]; ];
static ApiKeyAppType? fromJson(dynamic value) => static ApiKeyAppType? fromJson(dynamic value) =>

View File

@ -32,6 +32,7 @@ class ApplicationInstanceDTO {
this.appName = const [], this.appName = const [],
this.appStoreUrl, this.appStoreUrl,
this.playStoreUrl, this.playStoreUrl,
this.immersiveBackground,
}); });
String? id; String? id;
@ -78,6 +79,9 @@ class ApplicationInstanceDTO {
String? playStoreUrl; String? playStoreUrl;
/// Fond du menu general, specifique VR.
ImmersiveBackgroundDTO? immersiveBackground;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
@ -219,6 +223,11 @@ class ApplicationInstanceDTO {
} else { } else {
json[r'appStoreUrl'] = null; json[r'appStoreUrl'] = null;
} }
if (this.immersiveBackground != null) {
json[r'immersiveBackground'] = this.immersiveBackground;
} else {
json[r'immersiveBackground'] = null;
}
if (this.playStoreUrl != null) { if (this.playStoreUrl != null) {
json[r'playStoreUrl'] = this.playStoreUrl; json[r'playStoreUrl'] = this.playStoreUrl;
} else { } else {
@ -272,6 +281,7 @@ class ApplicationInstanceDTO {
appName: TranslationDTO.listFromJson(json[r'appName']), appName: TranslationDTO.listFromJson(json[r'appName']),
appStoreUrl: mapValueOfType<String>(json, r'appStoreUrl'), appStoreUrl: mapValueOfType<String>(json, r'appStoreUrl'),
playStoreUrl: mapValueOfType<String>(json, r'playStoreUrl'), playStoreUrl: mapValueOfType<String>(json, r'playStoreUrl'),
immersiveBackground: ImmersiveBackgroundDTO.fromJson(json[r'immersiveBackground']),
); );
} }
return null; return null;

View File

@ -27,6 +27,7 @@ class ConfigurationDTO {
this.sectionIds = const [], this.sectionIds = const [],
this.loaderImageId, this.loaderImageId,
this.loaderImageUrl, this.loaderImageUrl,
this.immersiveBackground,
}); });
String? id; String? id;
@ -69,6 +70,9 @@ class ConfigurationDTO {
String? loaderImageUrl; String? loaderImageUrl;
/// Fond immersif de la visite, null si elle n'en a pas.
ImmersiveBackgroundDTO? immersiveBackground;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
@ -177,6 +181,11 @@ class ConfigurationDTO {
} else { } else {
json[r'loaderImageId'] = null; json[r'loaderImageId'] = null;
} }
if (this.immersiveBackground != null) {
json[r'immersiveBackground'] = this.immersiveBackground;
} else {
json[r'immersiveBackground'] = null;
}
if (this.loaderImageUrl != null) { if (this.loaderImageUrl != null) {
json[r'loaderImageUrl'] = this.loaderImageUrl; json[r'loaderImageUrl'] = this.loaderImageUrl;
} else { } else {
@ -228,6 +237,7 @@ class ConfigurationDTO {
: const [], : const [],
loaderImageId: mapValueOfType<String>(json, r'loaderImageId'), loaderImageId: mapValueOfType<String>(json, r'loaderImageId'),
loaderImageUrl: mapValueOfType<String>(json, r'loaderImageUrl'), loaderImageUrl: mapValueOfType<String>(json, r'loaderImageUrl'),
immersiveBackground: ImmersiveBackgroundDTO.fromJson(json[r'immersiveBackground']),
); );
} }
return null; return null;

View File

@ -28,6 +28,9 @@ class DeviceDetailDTO {
this.lastConnectionLevel, this.lastConnectionLevel,
this.batteryLevel, this.batteryLevel,
this.lastBatteryLevel, this.lastBatteryLevel,
this.appType,
this.appVersion,
this.lastSeen,
}); });
String? id; String? id;
@ -78,6 +81,13 @@ class DeviceDetailDTO {
/// ///
DateTime? lastBatteryLevel; DateTime? lastBatteryLevel;
/// Canal de l'appareil. Absent de la requête = tablette, côté serveur.
AppType? appType;
String? appVersion;
DateTime? lastSeen;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
@ -96,7 +106,10 @@ class DeviceDetailDTO {
other.connectionLevel == connectionLevel && other.connectionLevel == connectionLevel &&
other.lastConnectionLevel == lastConnectionLevel && other.lastConnectionLevel == lastConnectionLevel &&
other.batteryLevel == batteryLevel && other.batteryLevel == batteryLevel &&
other.lastBatteryLevel == lastBatteryLevel; other.lastBatteryLevel == lastBatteryLevel &&
other.appType == appType &&
other.appVersion == appVersion &&
other.lastSeen == lastSeen;
@override @override
int get hashCode => int get hashCode =>
@ -115,11 +128,14 @@ class DeviceDetailDTO {
(connectionLevel == null ? 0 : connectionLevel!.hashCode) + (connectionLevel == null ? 0 : connectionLevel!.hashCode) +
(lastConnectionLevel == null ? 0 : lastConnectionLevel!.hashCode) + (lastConnectionLevel == null ? 0 : lastConnectionLevel!.hashCode) +
(batteryLevel == null ? 0 : batteryLevel!.hashCode) + (batteryLevel == null ? 0 : batteryLevel!.hashCode) +
(lastBatteryLevel == null ? 0 : lastBatteryLevel!.hashCode); (lastBatteryLevel == null ? 0 : lastBatteryLevel!.hashCode) +
(appType == null ? 0 : appType!.hashCode) +
(appVersion == null ? 0 : appVersion!.hashCode) +
(lastSeen == null ? 0 : lastSeen!.hashCode);
@override @override
String toString() => String toString() =>
'DeviceDetailDTO[id=$id, identifier=$identifier, name=$name, ipAddressWLAN=$ipAddressWLAN, ipAddressETH=$ipAddressETH, configurationId=$configurationId, configuration=$configuration, connected=$connected, dateCreation=$dateCreation, dateUpdate=$dateUpdate, instanceId=$instanceId, connectionLevel=$connectionLevel, lastConnectionLevel=$lastConnectionLevel, batteryLevel=$batteryLevel, lastBatteryLevel=$lastBatteryLevel]'; 'DeviceDetailDTO[id=$id, identifier=$identifier, name=$name, ipAddressWLAN=$ipAddressWLAN, ipAddressETH=$ipAddressETH, configurationId=$configurationId, configuration=$configuration, connected=$connected, dateCreation=$dateCreation, dateUpdate=$dateUpdate, instanceId=$instanceId, connectionLevel=$connectionLevel, lastConnectionLevel=$lastConnectionLevel, batteryLevel=$batteryLevel, lastBatteryLevel=$lastBatteryLevel, appType=$appType, appVersion=$appVersion, lastSeen=$lastSeen]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -200,6 +216,21 @@ class DeviceDetailDTO {
} else { } else {
json[r'lastBatteryLevel'] = null; json[r'lastBatteryLevel'] = null;
} }
if (this.appType != null) {
json[r'appType'] = this.appType;
} else {
json[r'appType'] = null;
}
if (this.appVersion != null) {
json[r'appVersion'] = this.appVersion;
} else {
json[r'appVersion'] = null;
}
if (this.lastSeen != null) {
json[r'lastSeen'] = this.lastSeen!.toUtc().toIso8601String();
} else {
json[r'lastSeen'] = null;
}
return json; return json;
} }
@ -239,6 +270,9 @@ class DeviceDetailDTO {
lastConnectionLevel: mapDateTime(json, r'lastConnectionLevel', r''), lastConnectionLevel: mapDateTime(json, r'lastConnectionLevel', r''),
batteryLevel: mapValueOfType<String>(json, r'batteryLevel'), batteryLevel: mapValueOfType<String>(json, r'batteryLevel'),
lastBatteryLevel: mapDateTime(json, r'lastBatteryLevel', r''), lastBatteryLevel: mapDateTime(json, r'lastBatteryLevel', r''),
appType: AppType.fromJson(json[r'appType']),
appVersion: mapValueOfType<String>(json, r'appVersion'),
lastSeen: mapDateTime(json, r'lastSeen', r''),
); );
} }
return null; return null;

View File

@ -24,6 +24,7 @@ class DeviceDTO {
this.dateCreation, this.dateCreation,
this.dateUpdate, this.dateUpdate,
this.instanceId, this.instanceId,
this.appType,
}); });
String? id; String? id;
@ -54,6 +55,9 @@ class DeviceDTO {
String? instanceId; String? instanceId;
/// Canal de l'appareil. Absent de la requête = tablette, côté serveur.
AppType? appType;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
@ -68,7 +72,8 @@ class DeviceDTO {
other.connected == connected && other.connected == connected &&
other.dateCreation == dateCreation && other.dateCreation == dateCreation &&
other.dateUpdate == dateUpdate && other.dateUpdate == dateUpdate &&
other.instanceId == instanceId; other.instanceId == instanceId &&
other.appType == appType;
@override @override
int get hashCode => int get hashCode =>
@ -83,11 +88,12 @@ class DeviceDTO {
(connected == null ? 0 : connected!.hashCode) + (connected == null ? 0 : connected!.hashCode) +
(dateCreation == null ? 0 : dateCreation!.hashCode) + (dateCreation == null ? 0 : dateCreation!.hashCode) +
(dateUpdate == null ? 0 : dateUpdate!.hashCode) + (dateUpdate == null ? 0 : dateUpdate!.hashCode) +
(instanceId == null ? 0 : instanceId!.hashCode); (instanceId == null ? 0 : instanceId!.hashCode) +
(appType == null ? 0 : appType!.hashCode);
@override @override
String toString() => String toString() =>
'DeviceDTO[id=$id, identifier=$identifier, name=$name, ipAddressWLAN=$ipAddressWLAN, ipAddressETH=$ipAddressETH, configurationId=$configurationId, configuration=$configuration, connected=$connected, dateCreation=$dateCreation, dateUpdate=$dateUpdate, instanceId=$instanceId]'; 'DeviceDTO[id=$id, identifier=$identifier, name=$name, ipAddressWLAN=$ipAddressWLAN, ipAddressETH=$ipAddressETH, configurationId=$configurationId, configuration=$configuration, connected=$connected, dateCreation=$dateCreation, dateUpdate=$dateUpdate, instanceId=$instanceId, appType=$appType]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -146,6 +152,11 @@ class DeviceDTO {
} else { } else {
json[r'instanceId'] = null; json[r'instanceId'] = null;
} }
if (this.appType != null) {
json[r'appType'] = this.appType;
} else {
json[r'appType'] = null;
}
return json; return json;
} }
@ -181,6 +192,7 @@ class DeviceDTO {
dateCreation: mapDateTime(json, r'dateCreation', r''), dateCreation: mapDateTime(json, r'dateCreation', r''),
dateUpdate: mapDateTime(json, r'dateUpdate', r''), dateUpdate: mapDateTime(json, r'dateUpdate', r''),
instanceId: mapValueOfType<String>(json, r'instanceId'), instanceId: mapValueOfType<String>(json, r'instanceId'),
appType: AppType.fromJson(json[r'appType']),
); );
} }
return null; return null;

View File

@ -29,6 +29,8 @@ class GeoPointDTO {
this.polyColor, this.polyColor,
this.sectionMapId, this.sectionMapId,
this.sectionEventId, this.sectionEventId,
this.sectionScene3DId,
this.localTransform,
}); });
int? id; int? id;
@ -63,6 +65,13 @@ class GeoPointDTO {
String? sectionEventId; String? sectionEventId;
/// Section maquette 3D a laquelle ce point appartient, le cas echeant.
String? sectionScene3DId;
/// Position du point sur la maquette. Nulle sur un point de carte : les deux
/// positions coexistent, geometry pour le terrain et celle-ci pour le modele.
Position3D? localTransform;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
@ -82,7 +91,9 @@ class GeoPointDTO {
other.geometry == geometry && other.geometry == geometry &&
other.polyColor == polyColor && other.polyColor == polyColor &&
other.sectionMapId == sectionMapId && other.sectionMapId == sectionMapId &&
other.sectionEventId == sectionEventId; other.sectionEventId == sectionEventId &&
other.sectionScene3DId == sectionScene3DId &&
other.localTransform == localTransform;
@override @override
int get hashCode => int get hashCode =>
@ -102,11 +113,13 @@ class GeoPointDTO {
(geometry == null ? 0 : geometry!.hashCode) + (geometry == null ? 0 : geometry!.hashCode) +
(polyColor == null ? 0 : polyColor!.hashCode) + (polyColor == null ? 0 : polyColor!.hashCode) +
(sectionMapId == null ? 0 : sectionMapId!.hashCode) + (sectionMapId == null ? 0 : sectionMapId!.hashCode) +
(sectionEventId == null ? 0 : sectionEventId!.hashCode); (sectionEventId == null ? 0 : sectionEventId!.hashCode) +
(sectionScene3DId == null ? 0 : sectionScene3DId!.hashCode) +
(localTransform == null ? 0 : localTransform!.hashCode);
@override @override
String toString() => String toString() =>
'GeoPointDTO[id=$id, title=$title, description=$description, contents=$contents, categorieId=$categorieId, imageResourceId=$imageResourceId, imageUrl=$imageUrl, schedules=$schedules, prices=$prices, phone=$phone, email=$email, site=$site, geometry=$geometry, polyColor=$polyColor, sectionMapId=$sectionMapId, sectionEventId=$sectionEventId]'; 'GeoPointDTO[id=$id, title=$title, description=$description, contents=$contents, categorieId=$categorieId, imageResourceId=$imageResourceId, imageUrl=$imageUrl, schedules=$schedules, prices=$prices, phone=$phone, email=$email, site=$site, geometry=$geometry, polyColor=$polyColor, sectionMapId=$sectionMapId, sectionEventId=$sectionEventId, sectionScene3DId=$sectionScene3DId, localTransform=$localTransform]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -230,6 +243,8 @@ class GeoPointDTO {
polyColor: mapValueOfType<String>(json, r'polyColor'), polyColor: mapValueOfType<String>(json, r'polyColor'),
sectionMapId: mapValueOfType<String>(json, r'sectionMapId'), sectionMapId: mapValueOfType<String>(json, r'sectionMapId'),
sectionEventId: mapValueOfType<String>(json, r'sectionEventId'), sectionEventId: mapValueOfType<String>(json, r'sectionEventId'),
sectionScene3DId: mapValueOfType<String>(json, r'sectionScene3DId'),
localTransform: Position3D.fromJson(json[r'localTransform']),
); );
} }
return null; return null;

View File

@ -0,0 +1,130 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Le fond d'un lieu. Un seul type, deux porteurs : une `Configuration` le porte
/// pour le fond d'une visite, l'`ApplicationInstance` VR pour le fond du menu.
///
/// Le repli n'est pas decoratif : trois canaux sur quatre ne rendent pas un
/// panorama. Sans `fallbackResourceId`, activer un fond immersif noircirait
/// l'ecran partout ailleurs que dans le casque.
class ImmersiveBackgroundDTO {
ImmersiveBackgroundDTO({
this.resourceId,
this.kind,
this.fallbackResourceId,
this.resourceUrl,
this.fallbackUrl,
});
String? resourceId;
ImmersiveBackgroundKind? kind;
String? fallbackResourceId;
/// URL de la ressource, posee par le serveur a la lecture.
String? resourceUrl;
String? fallbackUrl;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ImmersiveBackgroundDTO &&
other.resourceId == resourceId &&
other.kind == kind &&
other.fallbackResourceId == fallbackResourceId;
@override
int get hashCode =>
(resourceId == null ? 0 : resourceId!.hashCode) +
(kind == null ? 0 : kind!.hashCode) +
(fallbackResourceId == null ? 0 : fallbackResourceId!.hashCode);
@override
String toString() =>
'ImmersiveBackgroundDTO[resourceId=$resourceId, kind=$kind, fallbackResourceId=$fallbackResourceId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.resourceId != null) {
json[r'resourceId'] = this.resourceId;
} else {
json[r'resourceId'] = null;
}
if (this.kind != null) {
json[r'kind'] = this.kind;
} else {
json[r'kind'] = null;
}
if (this.fallbackResourceId != null) {
json[r'fallbackResourceId'] = this.fallbackResourceId;
} else {
json[r'fallbackResourceId'] = null;
}
if (this.resourceUrl != null) {
json[r'resourceUrl'] = this.resourceUrl;
} else {
json[r'resourceUrl'] = null;
}
if (this.fallbackUrl != null) {
json[r'fallbackUrl'] = this.fallbackUrl;
} else {
json[r'fallbackUrl'] = null;
}
return json;
}
static ImmersiveBackgroundDTO? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return ImmersiveBackgroundDTO(
resourceId: mapValueOfType<String>(json, r'resourceId'),
kind: ImmersiveBackgroundKind.fromJson(json[r'kind']),
fallbackResourceId: mapValueOfType<String>(json, r'fallbackResourceId'),
resourceUrl: mapValueOfType<String>(json, r'resourceUrl'),
fallbackUrl: mapValueOfType<String>(json, r'fallbackUrl'),
);
}
return null;
}
static List<ImmersiveBackgroundDTO> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ImmersiveBackgroundDTO>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ImmersiveBackgroundDTO.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ImmersiveBackgroundDTO> mapFromJson(dynamic json) {
final map = <String, ImmersiveBackgroundDTO>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
final value = ImmersiveBackgroundDTO.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
static const requiredKeys = <String>{};
}

View File

@ -0,0 +1,90 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Ce qu'est le fond d'une visite : un panorama fixe, une video 360 qui tourne en
/// boucle, ou une scene 3D.
///
/// 0 = Pano 1 = Video360 2 = Scene3D
class ImmersiveBackgroundKind {
const ImmersiveBackgroundKind._(this.value);
final int value;
@override
String toString() => value.toString();
int toJson() => value;
/// Une image equirectangulaire : le cas le plus courant et le moins couteux.
static const Pano = ImmersiveBackgroundKind._(0);
/// Une video equirectangulaire, jouee en boucle et sans son.
static const Video360 = ImmersiveBackgroundKind._(1);
/// Un decor 3D. Prevu par le contrat, pas encore rendu en fond par le casque.
static const Scene3D = ImmersiveBackgroundKind._(2);
static const values = <ImmersiveBackgroundKind>[Pano, Video360, Scene3D];
static ImmersiveBackgroundKind? fromJson(dynamic value) =>
ImmersiveBackgroundKindTypeTransformer().decode(value);
static List<ImmersiveBackgroundKind> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ImmersiveBackgroundKind>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ImmersiveBackgroundKind.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
class ImmersiveBackgroundKindTypeTransformer {
factory ImmersiveBackgroundKindTypeTransformer() => _instance ??= const ImmersiveBackgroundKindTypeTransformer._();
const ImmersiveBackgroundKindTypeTransformer._();
int encode(ImmersiveBackgroundKind data) => data.value;
ImmersiveBackgroundKind? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
if (data.runtimeType == String) {
switch (data.toString()) {
case r'Pano': return ImmersiveBackgroundKind.Pano;
case r'Video360': return ImmersiveBackgroundKind.Video360;
case r'Scene3D': return ImmersiveBackgroundKind.Scene3D;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
} else if (data.runtimeType == int) {
switch (data) {
case 0: return ImmersiveBackgroundKind.Pano;
case 1: return ImmersiveBackgroundKind.Video360;
case 2: return ImmersiveBackgroundKind.Scene3D;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
}
return null;
}
static ImmersiveBackgroundKindTypeTransformer? _instance;
}

View File

@ -23,6 +23,7 @@ class InstanceDTO {
this.isWeb, this.isWeb,
this.isVR, this.isVR,
this.isAssistant, this.isAssistant,
this.hasImmersiveContent,
this.guideName, this.guideName,
this.guidePersonaPrompt, this.guidePersonaPrompt,
this.guideVoiceId, this.guideVoiceId,
@ -101,6 +102,9 @@ class InstanceDTO {
bool? isAssistant; bool? isAssistant;
/// Add-on « Contenu immersif » : image 360, video 360 et modeles 3D.
bool? hasImmersiveContent;
/// Nom du guide affiché au visiteur, libre. /// Nom du guide affiché au visiteur, libre.
String? guideName; String? guideName;
@ -159,6 +163,7 @@ class InstanceDTO {
other.isWeb == isWeb && other.isWeb == isWeb &&
other.isVR == isVR && other.isVR == isVR &&
other.isAssistant == isAssistant && other.isAssistant == isAssistant &&
other.hasImmersiveContent == hasImmersiveContent &&
other.guideName == guideName && other.guideName == guideName &&
other.guidePersonaPrompt == guidePersonaPrompt && other.guidePersonaPrompt == guidePersonaPrompt &&
other.guideVoiceId == guideVoiceId && other.guideVoiceId == guideVoiceId &&
@ -188,6 +193,7 @@ class InstanceDTO {
(isWeb == null ? 0 : isWeb!.hashCode) + (isWeb == null ? 0 : isWeb!.hashCode) +
(isVR == null ? 0 : isVR!.hashCode) + (isVR == null ? 0 : isVR!.hashCode) +
(isAssistant == null ? 0 : isAssistant!.hashCode) + (isAssistant == null ? 0 : isAssistant!.hashCode) +
(hasImmersiveContent == null ? 0 : hasImmersiveContent!.hashCode) +
(guideName == null ? 0 : guideName!.hashCode) + (guideName == null ? 0 : guideName!.hashCode) +
(guidePersonaPrompt == null ? 0 : guidePersonaPrompt!.hashCode) + (guidePersonaPrompt == null ? 0 : guidePersonaPrompt!.hashCode) +
(guideVoiceId == null ? 0 : guideVoiceId!.hashCode) + (guideVoiceId == null ? 0 : guideVoiceId!.hashCode) +
@ -249,6 +255,11 @@ class InstanceDTO {
} else { } else {
json[r'isVR'] = null; json[r'isVR'] = null;
} }
if (this.hasImmersiveContent != null) {
json[r'hasImmersiveContent'] = this.hasImmersiveContent;
} else {
json[r'hasImmersiveContent'] = null;
}
if (this.isAssistant != null) { if (this.isAssistant != null) {
json[r'isAssistant'] = this.isAssistant; json[r'isAssistant'] = this.isAssistant;
} else { } else {
@ -379,6 +390,7 @@ class InstanceDTO {
isWeb: mapValueOfType<bool>(json, r'isWeb'), isWeb: mapValueOfType<bool>(json, r'isWeb'),
isVR: mapValueOfType<bool>(json, r'isVR'), isVR: mapValueOfType<bool>(json, r'isVR'),
isAssistant: mapValueOfType<bool>(json, r'isAssistant'), isAssistant: mapValueOfType<bool>(json, r'isAssistant'),
hasImmersiveContent: mapValueOfType<bool>(json, r'hasImmersiveContent'),
guideName: mapValueOfType<String>(json, r'guideName'), guideName: mapValueOfType<String>(json, r'guideName'),
guidePersonaPrompt: mapValueOfType<String>(json, r'guidePersonaPrompt'), guidePersonaPrompt: mapValueOfType<String>(json, r'guidePersonaPrompt'),
guideVoiceId: mapValueOfType<String>(json, r'guideVoiceId'), guideVoiceId: mapValueOfType<String>(json, r'guideVoiceId'),

View File

@ -0,0 +1,106 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Position d'un point d'interet sur une maquette 3D, dans le repere du modele.
///
/// Convention glTF Y vers le haut, Z vers l'arriere, main droite — et non celle
/// d'Unity. La conversion vit a un seul endroit cote casque : un miroir d'axes est
/// invisible sur une maquette symetrique et tres couteux decouvert tard.
class Position3D {
Position3D({
this.x = 0,
this.y = 0,
this.z = 0,
this.rotationY,
});
double x;
double y;
double z;
/// Rotation en degres autour de Y. Sert a orienter un panneau vers l'allee.
double? rotationY;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Position3D &&
other.x == x &&
other.y == y &&
other.z == z &&
other.rotationY == rotationY;
@override
int get hashCode =>
x.hashCode + y.hashCode + z.hashCode + (rotationY == null ? 0 : rotationY!.hashCode);
@override
String toString() => 'Position3D[x=$x, y=$y, z=$z, rotationY=$rotationY]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'x'] = this.x;
json[r'y'] = this.y;
json[r'z'] = this.z;
if (this.rotationY != null) {
json[r'rotationY'] = this.rotationY;
} else {
json[r'rotationY'] = null;
}
return json;
}
static Position3D? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return Position3D(
x: mapValueOfType<num>(json, r'x')?.toDouble() ?? 0,
y: mapValueOfType<num>(json, r'y')?.toDouble() ?? 0,
z: mapValueOfType<num>(json, r'z')?.toDouble() ?? 0,
rotationY: mapValueOfType<num>(json, r'rotationY')?.toDouble(),
);
}
return null;
}
static List<Position3D> listFromJson(dynamic json, {bool growable = false,}) {
final result = <Position3D>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = Position3D.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, Position3D> mapFromJson(dynamic json) {
final map = <String, Position3D>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
final value = Position3D.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
static const requiredKeys = <String>{};
}

View File

@ -11,6 +11,7 @@
part of openapi.api; part of openapi.api;
/// 0 = Image 1 = Video 2 = ImageUrl 3 = VideoUrl 4 = Audio 5 = PDF 6 = JSON 7 = JSONUrl /// 0 = Image 1 = Video 2 = ImageUrl 3 = VideoUrl 4 = Audio 5 = PDF 6 = JSON 7 = JSONUrl
/// 8 = Word 9 = PowerPoint 10 = Text 11 = Image360 12 = Video360 13 = Model3D
class ResourceType { class ResourceType {
/// Instantiate a new enum with the provided [value]. /// Instantiate a new enum with the provided [value].
const ResourceType._(this.value); const ResourceType._(this.value);
@ -31,6 +32,19 @@ class ResourceType {
static const Pdf = ResourceType._(5); static const Pdf = ResourceType._(5);
static const Json = ResourceType._(6); static const Json = ResourceType._(6);
static const JsonUrl = ResourceType._(7); static const JsonUrl = ResourceType._(7);
static const Word = ResourceType._(8);
static const PowerPoint = ResourceType._(9);
static const Text = ResourceType._(10);
/// Photo equirectangulaire, affichee en skybox dans un casque.
static const Image360 = ResourceType._(11);
/// Video equirectangulaire, meme usage.
static const Video360 = ResourceType._(12);
/// Modele 3D glTF binaire. La valeur existe ; son exploitation demande encore un
/// type de section dedie et un editeur de placement de points d'interet.
static const Model3D = ResourceType._(13);
/// List of all possible values in this [enum][ResourceType]. /// List of all possible values in this [enum][ResourceType].
static const values = <ResourceType>[ static const values = <ResourceType>[
@ -41,7 +55,13 @@ class ResourceType {
Audio, Audio,
Pdf, Pdf,
Json, Json,
JsonUrl JsonUrl,
Word,
PowerPoint,
Text,
Image360,
Video360,
Model3D
]; ];
static ResourceType? fromJson(dynamic value) => ResourceTypeTypeTransformer().decode(value); static ResourceType? fromJson(dynamic value) => ResourceTypeTypeTransformer().decode(value);
@ -89,6 +109,12 @@ class ResourceTypeTypeTransformer {
case "PDF": return ResourceType.Pdf; case "PDF": return ResourceType.Pdf;
case "JSON": return ResourceType.Json; case "JSON": return ResourceType.Json;
case "JSONUrl": return ResourceType.JsonUrl; case "JSONUrl": return ResourceType.JsonUrl;
case "Word": return ResourceType.Word;
case "PowerPoint": return ResourceType.PowerPoint;
case "Text": return ResourceType.Text;
case "Image360": return ResourceType.Image360;
case "Video360": return ResourceType.Video360;
case "Model3D": return ResourceType.Model3D;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');
@ -105,6 +131,12 @@ class ResourceTypeTypeTransformer {
case 5: return ResourceType.Pdf; case 5: return ResourceType.Pdf;
case 6: return ResourceType.Json; case 6: return ResourceType.Json;
case 7: return ResourceType.JsonUrl; case 7: return ResourceType.JsonUrl;
case 8: return ResourceType.Word;
case 9: return ResourceType.PowerPoint;
case 10: return ResourceType.Text;
case 11: return ResourceType.Image360;
case 12: return ResourceType.Video360;
case 13: return ResourceType.Model3D;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');

View File

@ -0,0 +1,299 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Une scene 3D un objet ou un decor et ses points d'interet (item E7 du lot XR-4).
///
/// Le modele arrive par un id de ressource `Model3D`, comme une image ou une video :
/// rien de particulier dans la mediatheque, et le telechargement hors ligne l'emporte
/// avec le reste de la visite.
class Scene3DDTO {
Scene3DDTO({
this.id,
this.label,
this.title = const [],
this.description = const [],
this.isActive,
this.imageId,
this.imageSource,
this.configurationId,
this.isSubSection,
this.parentId,
this.type,
this.dateCreation,
this.order,
this.instanceId,
this.latitude,
this.longitude,
this.meterZoneGPS,
this.isBeacon,
this.beaconId,
this.model3DResourceId,
this.model3DSource,
this.mode,
this.points = const [],
});
String? id;
String? label;
List<TranslationDTO>? title;
List<TranslationDTO>? description;
bool? isActive;
String? imageId;
String? imageSource;
String? configurationId;
bool? isSubSection;
String? parentId;
SectionType? type;
DateTime? dateCreation;
int? order;
String? instanceId;
String? latitude;
String? longitude;
int? meterZoneGPS;
bool? isBeacon;
int? beaconId;
/// Ressource `Model3D` (glTF binaire) affichee par cette section.
String? model3DResourceId;
/// URL du modele, remplie a la lecture comme `imageSource`.
String? model3DSource;
/// Objet manipule (0) ou decor habite (1).
Scene3DMode? mode;
/// Points poses sur le modele. Meme objet que ceux d'une carte : seule leur
/// position change de nature.
List<GeoPointDTO>? points;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Scene3DDTO &&
other.id == id &&
other.label == label &&
other.model3DResourceId == model3DResourceId &&
other.model3DSource == model3DSource &&
_deepEquality.equals(other.points, points);
@override
int get hashCode =>
(id == null ? 0 : id!.hashCode) +
(label == null ? 0 : label!.hashCode) +
(model3DResourceId == null ? 0 : model3DResourceId!.hashCode) +
(model3DSource == null ? 0 : model3DSource!.hashCode) +
(points == null ? 0 : points!.hashCode);
@override
String toString() =>
'Scene3DDTO[id=$id, label=$label, model3DResourceId=$model3DResourceId, points=$points]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.id != null) {
json[r'id'] = this.id;
} else {
json[r'id'] = null;
}
if (this.label != null) {
json[r'label'] = this.label;
} else {
json[r'label'] = null;
}
if (this.title != null) {
json[r'title'] = this.title;
} else {
json[r'title'] = null;
}
if (this.description != null) {
json[r'description'] = this.description;
} else {
json[r'description'] = null;
}
if (this.isActive != null) {
json[r'isActive'] = this.isActive;
} else {
json[r'isActive'] = null;
}
if (this.imageId != null) {
json[r'imageId'] = this.imageId;
} else {
json[r'imageId'] = null;
}
if (this.imageSource != null) {
json[r'imageSource'] = this.imageSource;
} else {
json[r'imageSource'] = null;
}
if (this.configurationId != null) {
json[r'configurationId'] = this.configurationId;
} else {
json[r'configurationId'] = null;
}
if (this.isSubSection != null) {
json[r'isSubSection'] = this.isSubSection;
} else {
json[r'isSubSection'] = null;
}
if (this.parentId != null) {
json[r'parentId'] = this.parentId;
} else {
json[r'parentId'] = null;
}
if (this.type != null) {
json[r'type'] = this.type;
} else {
json[r'type'] = null;
}
if (this.dateCreation != null) {
json[r'dateCreation'] = this.dateCreation!.toUtc().toIso8601String();
} else {
json[r'dateCreation'] = null;
}
if (this.order != null) {
json[r'order'] = this.order;
} else {
json[r'order'] = null;
}
if (this.instanceId != null) {
json[r'instanceId'] = this.instanceId;
} else {
json[r'instanceId'] = null;
}
if (this.latitude != null) {
json[r'latitude'] = this.latitude;
} else {
json[r'latitude'] = null;
}
if (this.longitude != null) {
json[r'longitude'] = this.longitude;
} else {
json[r'longitude'] = null;
}
if (this.meterZoneGPS != null) {
json[r'meterZoneGPS'] = this.meterZoneGPS;
} else {
json[r'meterZoneGPS'] = null;
}
if (this.isBeacon != null) {
json[r'isBeacon'] = this.isBeacon;
} else {
json[r'isBeacon'] = null;
}
if (this.beaconId != null) {
json[r'beaconId'] = this.beaconId;
} else {
json[r'beaconId'] = null;
}
if (this.model3DResourceId != null) {
json[r'model3DResourceId'] = this.model3DResourceId;
} else {
json[r'model3DResourceId'] = null;
}
if (this.model3DSource != null) {
json[r'model3DSource'] = this.model3DSource;
} else {
json[r'model3DSource'] = null;
}
if (this.mode != null) {
json[r'mode'] = this.mode;
} else {
json[r'mode'] = null;
}
if (this.points != null) {
json[r'points'] = this.points;
} else {
json[r'points'] = null;
}
return json;
}
static Scene3DDTO? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return Scene3DDTO(
id: mapValueOfType<String>(json, r'id'),
label: mapValueOfType<String>(json, r'label'),
title: TranslationDTO.listFromJson(json[r'title']),
description: TranslationDTO.listFromJson(json[r'description']),
isActive: mapValueOfType<bool>(json, r'isActive'),
imageId: mapValueOfType<String>(json, r'imageId'),
imageSource: mapValueOfType<String>(json, r'imageSource'),
configurationId: mapValueOfType<String>(json, r'configurationId'),
isSubSection: mapValueOfType<bool>(json, r'isSubSection'),
parentId: mapValueOfType<String>(json, r'parentId'),
type: SectionType.fromJson(json[r'type']),
dateCreation: mapDateTime(json, r'dateCreation', r''),
order: mapValueOfType<int>(json, r'order'),
instanceId: mapValueOfType<String>(json, r'instanceId'),
latitude: mapValueOfType<String>(json, r'latitude'),
longitude: mapValueOfType<String>(json, r'longitude'),
meterZoneGPS: mapValueOfType<int>(json, r'meterZoneGPS'),
isBeacon: mapValueOfType<bool>(json, r'isBeacon'),
beaconId: mapValueOfType<int>(json, r'beaconId'),
model3DResourceId: mapValueOfType<String>(json, r'model3DResourceId'),
model3DSource: mapValueOfType<String>(json, r'model3DSource'),
mode: Scene3DMode.fromJson(json[r'mode']),
points: GeoPointDTO.listFromJson(json[r'points']),
);
}
return null;
}
static List<Scene3DDTO> listFromJson(dynamic json, {bool growable = false,}) {
final result = <Scene3DDTO>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = Scene3DDTO.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, Scene3DDTO> mapFromJson(dynamic json) {
final map = <String, Scene3DDTO>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
final value = Scene3DDTO.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
static const requiredKeys = <String>{};
}

View File

@ -0,0 +1,90 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Ce que le visiteur fait d'une scene 3D, et c'est une opposition franche :
/// soit il **manipule un objet** pose devant lui (camera orbitale, les points
/// tournent avec l'objet), soit il **est dedans** et regarde autour (camera fixe,
/// les points restent ou ils sont).
///
/// Meme GLB, meme modele de point, meme editeur. Ce qui change, c'est ou l'on met
/// le visiteur et aucun fichier ne peut le deviner.
///
/// 0 = Asset 1 = Scene
class Scene3DMode {
const Scene3DMode._(this.value);
final int value;
@override
String toString() => value.toString();
int toJson() => value;
/// Un objet pose devant le visiteur : l'epee du roi, une piece de collection.
static const Asset = Scene3DMode._(0);
/// Un decor dans lequel le visiteur se trouve : une salle reconstituee.
static const Scene = Scene3DMode._(1);
static const values = <Scene3DMode>[Asset, Scene];
static Scene3DMode? fromJson(dynamic value) =>
Scene3DModeTypeTransformer().decode(value);
static List<Scene3DMode> listFromJson(dynamic json, {bool growable = false,}) {
final result = <Scene3DMode>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = Scene3DMode.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
class Scene3DModeTypeTransformer {
factory Scene3DModeTypeTransformer() => _instance ??= const Scene3DModeTypeTransformer._();
const Scene3DModeTypeTransformer._();
int encode(Scene3DMode data) => data.value;
Scene3DMode? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
if (data.runtimeType == String) {
switch (data.toString()) {
case r'Asset': return Scene3DMode.Asset;
case r'Scene': return Scene3DMode.Scene;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
} else if (data.runtimeType == int) {
switch (data) {
case 0: return Scene3DMode.Asset;
case 1: return Scene3DMode.Scene;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
}
return null;
}
static Scene3DModeTypeTransformer? _instance;
}

View File

@ -37,6 +37,9 @@ class SectionType {
static const Event = SectionType._(11); static const Event = SectionType._(11);
static const Parcours = SectionType._(12); static const Parcours = SectionType._(12);
/// Maquette 3D avec points d'interet (E7 du lot XR-4).
static const Scene3D = SectionType._(13);
/// List of all possible values in this [enum][SectionType]. /// List of all possible values in this [enum][SectionType].
static const values = <SectionType>[ static const values = <SectionType>[
Map, Map,
@ -52,6 +55,7 @@ class SectionType {
Weather, Weather,
Event, Event,
Parcours, Parcours,
Scene3D,
]; ];
static SectionType? fromJson(dynamic value) => SectionTypeTypeTransformer().decode(value); static SectionType? fromJson(dynamic value) => SectionTypeTypeTransformer().decode(value);
@ -104,6 +108,7 @@ class SectionTypeTypeTransformer {
case r'Weather': return SectionType.Weather; case r'Weather': return SectionType.Weather;
case r'Event': return SectionType.Event; case r'Event': return SectionType.Event;
case r'Parcours': return SectionType.Parcours; case r'Parcours': return SectionType.Parcours;
case r'Scene3D': return SectionType.Scene3D;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');
@ -125,6 +130,7 @@ class SectionTypeTypeTransformer {
case 10: return SectionType.Weather; case 10: return SectionType.Weather;
case 11: return SectionType.Event; case 11: return SectionType.Event;
case 12: return SectionType.Parcours; case 12: return SectionType.Parcours;
case 13: return SectionType.Scene3D;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');