From c8947880ba7d36ec519214c644950c74ba2fb11d Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Sun, 13 Sep 2026 16:54:13 +0200 Subject: [PATCH] Ecran VR, section Scene3D et medias immersifs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/Components/fetch_resource_icon.dart | 8 + lib/Components/fetch_section_icon.dart | 4 + .../immersive_background_picker.dart | 125 +++++ lib/Components/resource_picker.dart | 82 +++- lib/Helpers/ImageCompressor.dart | 14 +- .../SubSection/Scene3D/scene3d_config.dart | 430 ++++++++++++++++++ .../Section/section_detail_screen.dart | 11 + .../configuration_detail_screen.dart | 20 + .../Configurations/new_section_popup.dart | 1 + lib/Screens/Main/main_screen.dart | 187 ++++++-- .../Resources/get_element_for_resource.dart | 12 +- .../Resources/resource_formatting.dart | 12 + lib/Screens/Resources/resources_screen.dart | 6 +- lib/Screens/Vr_devices/vr_devices_tab.dart | 316 +++++++++++++ lib/Screens/Vr_devices/vr_screen.dart | 128 ++++++ lib/client.dart | 4 + lib/constants.dart | 6 + lib/l10n/app_en.arb | 79 +++- lib/l10n/app_fr.arb | 79 +++- lib/l10n/app_localizations.dart | 296 +++++++++++- lib/l10n/app_localizations_en.dart | 173 ++++++- lib/l10n/app_localizations_fr.dart | 174 ++++++- lib/l10n/app_localizations_nl.dart | 174 ++++++- lib/l10n/app_nl.arb | 79 +++- manager_api_new/lib/api.dart | 6 + manager_api_new/lib/api/device_api.dart | 10 + .../lib/api/section_scene3_d_api.dart | 167 +++++++ manager_api_new/lib/api_client.dart | 10 + manager_api_new/lib/api_helper.dart | 3 + .../lib/model/api_key_app_type.dart | 2 + .../lib/model/application_instance_dto.dart | 10 + .../lib/model/configuration_dto.dart | 10 + .../lib/model/device_detail_dto.dart | 40 +- manager_api_new/lib/model/device_dto.dart | 18 +- manager_api_new/lib/model/geo_point_dto.dart | 21 +- .../lib/model/immersive_background_dto.dart | 130 ++++++ .../lib/model/immersive_background_kind.dart | 90 ++++ manager_api_new/lib/model/instance_dto.dart | 12 + manager_api_new/lib/model/position3_d.dart | 106 +++++ manager_api_new/lib/model/resource_type.dart | 34 +- manager_api_new/lib/model/scene3_d_dto.dart | 299 ++++++++++++ manager_api_new/lib/model/scene3_d_mode.dart | 90 ++++ manager_api_new/lib/model/section_type.dart | 6 + 43 files changed, 3420 insertions(+), 64 deletions(-) create mode 100644 lib/Components/immersive_background_picker.dart create mode 100644 lib/Screens/Configurations/Section/SubSection/Scene3D/scene3d_config.dart create mode 100644 lib/Screens/Vr_devices/vr_devices_tab.dart create mode 100644 lib/Screens/Vr_devices/vr_screen.dart create mode 100644 manager_api_new/lib/api/section_scene3_d_api.dart create mode 100644 manager_api_new/lib/model/immersive_background_dto.dart create mode 100644 manager_api_new/lib/model/immersive_background_kind.dart create mode 100644 manager_api_new/lib/model/position3_d.dart create mode 100644 manager_api_new/lib/model/scene3_d_dto.dart create mode 100644 manager_api_new/lib/model/scene3_d_mode.dart diff --git a/lib/Components/fetch_resource_icon.dart b/lib/Components/fetch_resource_icon.dart index e2035fc..b18edfd 100644 --- a/lib/Components/fetch_resource_icon.dart +++ b/lib/Components/fetch_resource_icon.dart @@ -19,6 +19,14 @@ IconData getResourceIcon(elementType) { return Icons.file_present_outlined; case ResourceType.JsonUrl: return Icons.file_present_rounded; + // Sans ces trois-là, 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; } \ No newline at end of file diff --git a/lib/Components/fetch_section_icon.dart b/lib/Components/fetch_section_icon.dart index fd27030..9d9e956 100644 --- a/lib/Components/fetch_section_icon.dart +++ b/lib/Components/fetch_section_icon.dart @@ -17,6 +17,7 @@ String getSectionTypeName(AppLocalizations l, SectionType? type) { case SectionType.Weather: return l.sectionTypeWeather; case SectionType.Event: return l.sectionTypeEvent; case SectionType.Parcours: return l.sectionTypeParcours; + case SectionType.Scene3D: return l.sectionTypeScene3D; default: return l.sectionTypeDefault; } } @@ -38,6 +39,7 @@ String getSectionTypeDescription(AppLocalizations l, SectionType? type) { case SectionType.Weather: return l.sectionTypeDescWeather; case SectionType.Event: return l.sectionTypeDescEvent; case SectionType.Parcours: return l.sectionTypeDescParcours; + case SectionType.Scene3D: return l.sectionTypeDescScene3D; default: return ""; } } @@ -70,6 +72,8 @@ IconData getSectionIcon(elementType) { return Icons.event; case SectionType.Parcours: return Icons.route; + case SectionType.Scene3D: + return Icons.view_in_ar; } return Icons.question_mark; } \ No newline at end of file diff --git a/lib/Components/immersive_background_picker.dart b/lib/Components/immersive_background_picker.dart new file mode 100644 index 0000000..8966f73 --- /dev/null +++ b/lib/Components/immersive_background_picker.dart @@ -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 onChanged; + final String title; + final String hint; + + @override + State createState() => _ImmersiveBackgroundPickerState(); +} + +class _ImmersiveBackgroundPickerState extends State { + 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), + ), + ), + ], + ], + ); + } +} diff --git a/lib/Components/resource_picker.dart b/lib/Components/resource_picker.dart index 6edf9a1..3ac3227 100644 --- a/lib/Components/resource_picker.dart +++ b/lib/Components/resource_picker.dart @@ -11,7 +11,7 @@ import 'package:manager_app/l10n/app_localizations.dart'; import 'package:manager_api_new/api.dart'; const List 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. @@ -22,10 +22,14 @@ const List kAcceptedExtensions = [ class PickedResource { final PlatformFile? file; final String? url; - final ResourceType? type; - const PickedResource.file(this.file, this.type) : url = null; - const PickedResource.url(this.url, this.type) : file = null; + /// Modifiable : l'extension donne le type de départ, mais rien dans un `.jpg` + /// 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; String get name => isUrl ? url! : file!.name; @@ -306,6 +310,65 @@ class _ResourcePickerState extends State { ); } + /// 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 _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) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -335,16 +398,7 @@ class _ResourcePickerState extends State { ), ), const SizedBox(width: kSpace3), - Container( - 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)), - ), + _typeChip(l, entry), const SizedBox(width: kSpace2), IconButton( icon: const Icon(Icons.close, size: 15), diff --git a/lib/Helpers/ImageCompressor.dart b/lib/Helpers/ImageCompressor.dart index 902fbd1..db3ddb5 100644 --- a/lib/Helpers/ImageCompressor.dart +++ b/lib/Helpers/ImageCompressor.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:image/image.dart' as img; +import 'package:manager_api_new/api.dart'; /// 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 /// 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 ré-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)) { return CompressedImage(bytes, fallbackMimeType, false); } diff --git a/lib/Screens/Configurations/Section/SubSection/Scene3D/scene3d_config.dart b/lib/Screens/Configurations/Section/SubSection/Scene3D/scene3d_config.dart new file mode 100644 index 0000000..b321ae1 --- /dev/null +++ b/lib/Screens/Configurations/Section/SubSection/Scene3D/scene3d_config.dart @@ -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. +/// +/// Pourquoi une iframe et pas un rendu Flutter. 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. +/// +/// Le protocole n'est pas inventé ici : 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 onChanged; + + const Scene3DConfig({ + Key? key, + required this.initialValue, + required this.onChanged, + }) : super(key: key); + + @override + State createState() => _Scene3DConfigState(); +} + +class _Scene3DConfigState extends State { + /// 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? _asMap(dynamic data) { + if (data is Map) return data.cast(); + if (data is String) { + try { + final decoded = jsonDecode(data); + if (decoded is Map) return decoded.cast(); + } 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 _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(context, listen: false).getContext() as ManagerAppContext) + .clientAPI! + .sectionScene3DApi!; + + Future _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 là 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 _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 _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> _localized(List? 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( + 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 là-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? 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; + } +} diff --git a/lib/Screens/Configurations/Section/section_detail_screen.dart b/lib/Screens/Configurations/Section/section_detail_screen.dart index ed68bcf..002d09a 100644 --- a/lib/Screens/Configurations/Section/section_detail_screen.dart +++ b/lib/Screens/Configurations/Section/section_detail_screen.dart @@ -42,6 +42,7 @@ import 'dart:html' as html; import 'SubSection/Weather/weather_config.dart'; import 'SubSection/Event/event_config.dart'; import 'SubSection/SectionParcours/section_parcours_config.dart'; +import 'SubSection/Scene3D/scene3d_config.dart'; class SectionDetailScreen extends StatefulWidget { final String id; @@ -516,6 +517,13 @@ class _SectionDetailScreenState extends State { sectionDetailDTO = updatedParcours; }, ); + case SectionType.Scene3D: + return Scene3DConfig( + initialValue: sectionDetailDTO as Scene3DDTO, + onChanged: (Scene3DDTO updatedModel) { + sectionDetailDTO = updatedModel; + }, + ); } } @@ -563,6 +571,9 @@ class _SectionDetailScreenState extends State { case SectionType.Parcours: sectionDetailDTO = ParcoursDTO.fromJson(rawSectionData)!; break; + case SectionType.Scene3D: + sectionDetailDTO = Scene3DDTO.fromJson(rawSectionData)!; + break; } } diff --git a/lib/Screens/Configurations/configuration_detail_screen.dart b/lib/Screens/Configurations/configuration_detail_screen.dart index 7874269..10c68fd 100644 --- a/lib/Screens/Configurations/configuration_detail_screen.dart +++ b/lib/Screens/Configurations/configuration_detail_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:manager_app/Components/check_input_container.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/common_loader.dart'; import 'package:manager_app/Components/message_notification.dart'; @@ -125,6 +126,10 @@ class _ConfigurationDetailScreenState extends State { ], rail: [ _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 { ); } + // ── 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 ── Widget _cardSections(ConfigurationDTO config, AppContext appContext) { diff --git a/lib/Screens/Configurations/new_section_popup.dart b/lib/Screens/Configurations/new_section_popup.dart index 61eaa52..2cb0158 100644 --- a/lib/Screens/Configurations/new_section_popup.dart +++ b/lib/Screens/Configurations/new_section_popup.dart @@ -23,6 +23,7 @@ const Map> _sectionFamilies = { SectionType.Map, SectionType.Parcours, SectionType.Event, + SectionType.Scene3D, ], "interactive": [ SectionType.Quiz, diff --git a/lib/Screens/Main/main_screen.dart b/lib/Screens/Main/main_screen.dart index d257be1..687104e 100644 --- a/lib/Screens/Main/main_screen.dart +++ b/lib/Screens/Main/main_screen.dart @@ -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/Configurations/configurations_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/Statistics/statistics_screen.dart'; import 'package:manager_app/Screens/GuideIa/guide_ia_screen.dart'; @@ -138,6 +139,16 @@ class _MainScreenState extends State { final planList = plans ?? []; 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( context: context, builder: (dialogContext) => StatefulBuilder( @@ -145,29 +156,80 @@ class _MainScreenState extends State { title: Text(AppLocalizations.of(context)!.planDialogTitle(inst.name ?? '')), content: SizedBox( width: 360, - child: planList.isEmpty - ? Text(AppLocalizations.of(context)!.noPlansAvailable) - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - RadioListTile( - title: Text(AppLocalizations.of(context)!.noPlan), - value: null, - groupValue: selectedPlanId, - onChanged: (v) => setDialogState(() => selectedPlanId = v), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (planList.isEmpty) + Text(AppLocalizations.of(context)!.noPlansAvailable) + else ...[ + RadioListTile( + title: Text(AppLocalizations.of(context)!.noPlan), + value: null, + groupValue: selectedPlanId, + onChanged: (v) => setDialogState(() => selectedPlanId = v), + ), + ...planList.map((plan) => RadioListTile( + 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( - 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), - ), - value: plan.id, - groupValue: selectedPlanId, - onChanged: (v) => setDialogState(() => selectedPlanId = v), - )), - ], + value: plan.id, + groupValue: selectedPlanId, + onChanged: (v) => setDialogState(() => selectedPlanId = v), + )), + ], + const Divider(height: 24), + Text(AppLocalizations.of(dialogContext)!.channelsSectionTitle, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)), + 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: [ TextButton( @@ -186,19 +248,38 @@ class _MainScreenState extends State { isPushNotification: instanceDetail?.isPushNotification, hasStats: instanceDetail?.hasStats, hasAdvancedStats: instanceDetail?.hasAdvancedStats, - isMobile: instanceDetail?.isMobile, - isTablet: instanceDetail?.isTablet, - isWeb: instanceDetail?.isWeb, - isVR: instanceDetail?.isVR, - isAssistant: instanceDetail?.isAssistant, + isMobile: isMobile, + isTablet: isTablet, + isWeb: isWeb, + isVR: isVR, + isAssistant: isAssistant, + hasImmersiveContent: hasImmersiveContent, aiTokensThisMonth: instanceDetail?.aiTokensThisMonth, aiUsageMonthKey: instanceDetail?.aiUsageMonthKey, 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(); 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) { if (context.mounted) { @@ -214,6 +295,56 @@ class _MainScreenState extends State { ); } + /// Crée les `ApplicationInstance` des canaux qu'on vient d'activer, et seulement + /// celles-là. Renvoie combien ont été 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 _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.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}) { if (bytes == null || bytes == 0) return unlimitedLabel; if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} MB'; @@ -741,7 +872,7 @@ class _MainScreenState extends State { if (applicationInstanceVR == null) return Center(child: Text(AppLocalizations.of(context)!.vrAppNotConfigured)); return Padding( padding: const EdgeInsets.all(8.0), - child: Text(AppLocalizations.of(context)!.vrAppNotConfigured) + child: VrScreen(applicationInstanceDTO: applicationInstanceVR) ); case 'configurations' : return Padding( diff --git a/lib/Screens/Resources/get_element_for_resource.dart b/lib/Screens/Resources/get_element_for_resource.dart index 003a321..aab1730 100644 --- a/lib/Screens/Resources/get_element_for_resource.dart +++ b/lib/Screens/Resources/get_element_for_resource.dart @@ -11,6 +11,10 @@ import 'package:flutter/material.dart'; getElementForResource(BuildContext context, dynamic resourceDTO, AppContext appContext) { 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: return Image.network( resourceDTO.url, @@ -31,7 +35,6 @@ getElementForResource(BuildContext context, dynamic resourceDTO, AppContext appC ); }, ); - break; case ResourceType.ImageUrl: return Image.network( resourceDTO.url, @@ -52,7 +55,6 @@ getElementForResource(BuildContext context, dynamic resourceDTO, AppContext appC ); }, ); - break; case ResourceType.Audio: return AudioPlayerFloatingContainer(audioBytes: null, resourceURl: resourceDTO.url, isAuto: true); /*return FutureBuilder( @@ -82,6 +84,7 @@ getElementForResource(BuildContext context, dynamic resourceDTO, AppContext appC } );*/ //return Text("Fichier audio - aucune visualisation possible"); + case ResourceType.Video360: case ResourceType.Video: if(resourceDTO.url == null) { return Center(child: Text(AppLocalizations.of(context)!.errorLoadingVideo)); @@ -107,6 +110,11 @@ getElementForResource(BuildContext context, dynamic resourceDTO, AppContext appC case ResourceType.JsonUrl: return SelectableText(resourceDTO.url!); + + case ResourceType.Model3D: + return Center( + child: Text(AppLocalizations.of(context)!.mediaModel3DNoPreview, + textAlign: TextAlign.center)); } } diff --git a/lib/Screens/Resources/resource_formatting.dart b/lib/Screens/Resources/resource_formatting.dart index 7a3c05f..34753e2 100644 --- a/lib/Screens/Resources/resource_formatting.dart +++ b/lib/Screens/Resources/resource_formatting.dart @@ -15,6 +15,9 @@ const Map _extensionByType = { ResourceType.Pdf: '.pdf', ResourceType.Json: '.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 @@ -37,6 +40,10 @@ ResourceType? resourceTypeForExtension(String? extension) { return ResourceType.Pdf; case '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: return null; } @@ -61,6 +68,8 @@ String mimeTypeForExtension(String? extension) { return 'application/pdf'; case 'json': return 'application/json'; + case 'glb': + return 'model/gltf-binary'; default: return 'application/octet-stream'; } @@ -77,6 +86,9 @@ String resourceTypeLabel(AppLocalizations l, ResourceType? type) { case ResourceType.Pdf: return l.resourceTypePdf; case ResourceType.Json: return l.resourceTypeJson; 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 '—'; } } diff --git a/lib/Screens/Resources/resources_screen.dart b/lib/Screens/Resources/resources_screen.dart index 153ac83..775b5e9 100644 --- a/lib/Screens/Resources/resources_screen.dart +++ b/lib/Screens/Resources/resources_screen.dart @@ -283,7 +283,8 @@ class _ResourcesScreenState extends State { setState(() => isUploading = true); try { 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 ref = storage @@ -408,7 +409,8 @@ Future create( // 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. final compressed = ImageCompressor.compress(platformFile.bytes!, - platformFile.extension, mimeTypeForExtension(platformFile.extension)); + platformFile.extension, mimeTypeForExtension(platformFile.extension), + type: entry.type); final resourceDTO = ResourceDTO( label: platformFile.name, diff --git a/lib/Screens/Vr_devices/vr_devices_tab.dart b/lib/Screens/Vr_devices/vr_devices_tab.dart new file mode 100644 index 0000000..8a6d847 --- /dev/null +++ b/lib/Screens/Vr_devices/vr_devices_tab.dart @@ -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 createState() => _VrDevicesTabState(); +} + +class _VrDevicesTabState extends State { + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final appContext = Provider.of(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 snapshot) { + List? 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? _detail; + + @override + void initState() { + super.initState(); + final device = widget.link.device; + if (device?.id != null) { + final ctx = Provider.of(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 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(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, + ); + } +} diff --git a/lib/Screens/Vr_devices/vr_screen.dart b/lib/Screens/Vr_devices/vr_screen.dart new file mode 100644 index 0000000..9b397dd --- /dev/null +++ b/lib/Screens/Vr_devices/vr_screen.dart @@ -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 createState() => _VrScreenState(); +} + +class _VrScreenState extends State { + 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(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, + ), + ), + ), + ); + } +} diff --git a/lib/client.dart b/lib/client.dart index c40d36c..2a941b7 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -32,6 +32,9 @@ class Client { SectionMapApi? _sectionMapApi; SectionMapApi? get sectionMapApi => _sectionMapApi; + SectionScene3DApi? _sectionScene3DApi; + SectionScene3DApi? get sectionScene3DApi => _sectionScene3DApi; + SectionQuizApi? _sectionQuizApi; SectionQuizApi? get sectionQuizApi => _sectionQuizApi; @@ -76,6 +79,7 @@ class Client { _resourceApi = ResourceApi(_apiClient); _deviceApi = DeviceApi(_apiClient); _sectionMapApi = SectionMapApi(_apiClient); + _sectionScene3DApi = SectionScene3DApi(_apiClient); _sectionQuizApi = SectionQuizApi(_apiClient); _sectionAgendaApi = SectionAgendaApi(_apiClient); _sectionEventApi = SectionEventApi(_apiClient); diff --git a/lib/constants.dart b/lib/constants.dart index 0afd2e4..8c3c18e 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -203,6 +203,12 @@ const List resource_types = [ ResourceType.Pdf, ResourceType.Json, 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, ]; /* diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8bb3d1e..1af5d59 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1005,7 +1005,7 @@ "@_mediatheque_dialogue": {}, "mediaAddTitle": "Add a resource", "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", "mediaUrlHint": "https://… image, YouTube video or JSON file", "mediaUrlInvalid": "This URL is not valid", @@ -1052,10 +1052,59 @@ "menuKiosk": "Kiosk", "menuWeb": "Web", "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", "mobileAppNotConfigured": "Mobile application not configured", "kioskAppNotConfigured": "Kiosk 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:", "titleModalLabel": "Title", "descriptionColonLabel": "Description:", @@ -1386,6 +1435,34 @@ "resourceTypePdf": "PDF", "resourceTypeJson": "JSON", "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", "secondsAbbr": "sec", "metersAbbr": "m", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 401d4e7..3226d5a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1005,7 +1005,7 @@ "@_mediatheque_dialogue": {}, "mediaAddTitle": "Ajouter une ressource", "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", "mediaUrlHint": "https://… image, vidéo YouTube ou fichier JSON", "mediaUrlInvalid": "Cette URL n'est pas valide", @@ -1052,10 +1052,59 @@ "menuKiosk": "Kiosk", "menuWeb": "Web", "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", "mobileAppNotConfigured": "Application mobile non configurée", "kioskAppNotConfigured": "Application kiosk 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 :", "titleModalLabel": "Titre", "descriptionColonLabel": "Description :", @@ -1386,6 +1435,34 @@ "resourceTypePdf": "PDF", "resourceTypeJson": "JSON", "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", "secondsAbbr": "sec", "metersAbbr": "m", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3484e8f..7b86150 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4147,7 +4147,7 @@ abstract class AppLocalizations { /// No description provided for @mediaDropFormats. /// /// 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; /// No description provided for @mediaOrPasteUrl. @@ -4240,6 +4240,108 @@ abstract class AppLocalizations { /// **'VR'** 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. /// /// In fr, this message translates to: @@ -4264,6 +4366,72 @@ abstract class AppLocalizations { /// **'Application VR non configurée'** 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. /// /// In fr, this message translates to: @@ -5818,6 +5986,132 @@ abstract class AppLocalizations { /// **'JSON (URL)'** 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. /// /// In fr, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5adaa3d..0438597 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2248,7 +2248,8 @@ class AppLocalizationsEn extends AppLocalizations { String get mediaDropZone => 'Drop your files here or click to browse'; @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 String get mediaOrPasteUrl => 'or paste a URL'; @@ -2310,6 +2311,65 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 String get noAppConfigured => 'No application configured on this instance'; @@ -2322,6 +2382,45 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 String get titleColonLabel => 'Title:'; @@ -3148,6 +3247,78 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 String get minutesAbbr => 'min'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 1e82af6..6455708 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2292,7 +2292,8 @@ class AppLocalizationsFr extends AppLocalizations { 'Glissez vos fichiers ici ou cliquez pour parcourir'; @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 String get mediaOrPasteUrl => 'ou coller une URL'; @@ -2354,6 +2355,65 @@ class AppLocalizationsFr extends AppLocalizations { @override 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 String get noAppConfigured => 'Aucune application configurée sur cette instance'; @@ -2367,6 +2427,45 @@ class AppLocalizationsFr extends AppLocalizations { @override 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 String get titleColonLabel => 'Titre :'; @@ -3199,6 +3298,79 @@ class AppLocalizationsFr extends AppLocalizations { @override 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 String get minutesAbbr => 'min'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 7d4bc6e..653b9e9 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2272,7 +2272,8 @@ class AppLocalizationsNl extends AppLocalizations { 'Sleep uw bestanden hierheen of klik om te bladeren'; @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 String get mediaOrPasteUrl => 'of plak een URL'; @@ -2335,6 +2336,66 @@ class AppLocalizationsNl extends AppLocalizations { @override 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 String get noAppConfigured => 'Geen applicatie geconfigureerd op deze instantie'; @@ -2348,6 +2409,45 @@ class AppLocalizationsNl extends AppLocalizations { @override 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 String get titleColonLabel => 'Titel:'; @@ -3175,6 +3275,78 @@ class AppLocalizationsNl extends AppLocalizations { @override 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 String get minutesAbbr => 'min'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 6be7e65..d864fa0 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1005,7 +1005,7 @@ "@_mediatheque_dialogue": {}, "mediaAddTitle": "Een bron toevoegen", "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", "mediaUrlHint": "https://… afbeelding, YouTube-video of JSON-bestand", "mediaUrlInvalid": "Deze URL is niet geldig", @@ -1052,10 +1052,59 @@ "menuKiosk": "Kiosk", "menuWeb": "Web", "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", "mobileAppNotConfigured": "Mobiele applicatie niet geconfigureerd", "kioskAppNotConfigured": "Kioskapplicatie 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:", "titleModalLabel": "Titel", "descriptionColonLabel": "Beschrijving:", @@ -1386,6 +1435,34 @@ "resourceTypePdf": "PDF", "resourceTypeJson": "JSON", "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", "secondsAbbr": "sec", "metersAbbr": "m", diff --git a/manager_api_new/lib/api.dart b/manager_api_new/lib/api.dart index 9c9b664..b008f5b 100644 --- a/manager_api_new/lib/api.dart +++ b/manager_api_new/lib/api.dart @@ -42,6 +42,7 @@ part 'api/section_api.dart'; part 'api/section_agenda_api.dart'; part 'api/section_event_api.dart'; part 'api/section_map_api.dart'; +part 'api/section_scene3_d_api.dart'; part 'api/section_parcours_api.dart'; part 'api/section_quiz_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_game.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_map.dart'; part 'model/section_map_all_of_map_map_provider.dart'; diff --git a/manager_api_new/lib/api/device_api.dart b/manager_api_new/lib/api/device_api.dart index 8d6b451..1d2f2fa 100644 --- a/manager_api_new/lib/api/device_api.dart +++ b/manager_api_new/lib/api/device_api.dart @@ -129,8 +129,11 @@ class DeviceApi { /// Parameters: /// /// * [String] instanceId: + /// + /// * [AppType] appType: Future deviceGetWithHttpInfo({ String? instanceId, + AppType? appType, }) async { // ignore: prefer_const_declarations final path = r'/api/Device'; @@ -145,6 +148,9 @@ class DeviceApi { if (instanceId != null) { queryParams.addAll(_queryParams('', 'instanceId', instanceId)); } + if (appType != null) { + queryParams.addAll(_queryParams('', 'appType', appType)); + } const contentTypes = []; @@ -162,11 +168,15 @@ class DeviceApi { /// Parameters: /// /// * [String] instanceId: + /// + /// * [AppType] appType: Future?> deviceGet({ String? instanceId, + AppType? appType, }) async { final response = await deviceGetWithHttpInfo( instanceId: instanceId, + appType: appType, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); diff --git a/manager_api_new/lib/api/section_scene3_d_api.dart b/manager_api_new/lib/api/section_scene3_d_api.dart new file mode 100644 index 0000000..dc985b8 --- /dev/null +++ b/manager_api_new/lib/api/section_scene3_d_api.dart @@ -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 sectionScene3DGetPointsWithHttpInfo(String sectionId,) async { + final path = r'/api/SectionScene3D/{sectionId}/points' + .replaceAll('{sectionId}', sectionId); + + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> 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') as List) + .cast() + .toList(growable: false); + } + return null; + } + + /// Performs an HTTP 'POST /api/SectionScene3D/{sectionId}/points' operation. + Future sectionScene3DCreatePointWithHttpInfo( + String sectionId, + GeoPointDTO geoPointDTO, + ) async { + final path = r'/api/SectionScene3D/{sectionId}/points' + .replaceAll('{sectionId}', sectionId); + + Object? postBody = geoPointDTO; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future 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 sectionScene3DUpdatePointWithHttpInfo(GeoPointDTO geoPointDTO,) async { + final path = r'/api/SectionScene3D/points'; + + Object? postBody = geoPointDTO; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future 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 sectionScene3DDeletePointWithHttpInfo(int id,) async { + final path = r'/api/SectionScene3D/points/{id}' + .replaceAll('{id}', id.toString()); + + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future sectionScene3DDeletePoint(int id,) async { + final response = await sectionScene3DDeletePointWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } +} diff --git a/manager_api_new/lib/api_client.dart b/manager_api_new/lib/api_client.dart index 6e8f99c..e7a7ccd 100644 --- a/manager_api_new/lib/api_client.dart +++ b/manager_api_new/lib/api_client.dart @@ -325,6 +325,16 @@ class ApiClient { return GeoPointDTO.fromJson(value); case 'Geometry': 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': return GeometryCentroid.fromJson(value); case 'GeometryDTO': diff --git a/manager_api_new/lib/api_helper.dart b/manager_api_new/lib/api_helper.dart index 68ec05d..0caea28 100644 --- a/manager_api_new/lib/api_helper.dart +++ b/manager_api_new/lib/api_helper.dart @@ -68,6 +68,9 @@ String parameterToString(dynamic value) { if (value is ApiKeyAppType) { return ApiKeyAppTypeTypeTransformer().encode(value).toString(); } + if (value is Scene3DMode) { + return Scene3DModeTypeTransformer().encode(value).toString(); + } if (value is AppType) { return AppTypeTypeTransformer().encode(value).toString(); } diff --git a/manager_api_new/lib/model/api_key_app_type.dart b/manager_api_new/lib/model/api_key_app_type.dart index 79bc528..bb9c8a5 100644 --- a/manager_api_new/lib/model/api_key_app_type.dart +++ b/manager_api_new/lib/model/api_key_app_type.dart @@ -26,12 +26,14 @@ class ApiKeyAppType { static const number0 = ApiKeyAppType._(0); static const number1 = ApiKeyAppType._(1); static const number2 = ApiKeyAppType._(2); + static const number3 = ApiKeyAppType._(3); // VrApp /// List of all possible values in this [enum][ApiKeyAppType]. static const values = [ number0, number1, number2, + number3, ]; static ApiKeyAppType? fromJson(dynamic value) => diff --git a/manager_api_new/lib/model/application_instance_dto.dart b/manager_api_new/lib/model/application_instance_dto.dart index b89b6ea..10e9847 100644 --- a/manager_api_new/lib/model/application_instance_dto.dart +++ b/manager_api_new/lib/model/application_instance_dto.dart @@ -32,6 +32,7 @@ class ApplicationInstanceDTO { this.appName = const [], this.appStoreUrl, this.playStoreUrl, + this.immersiveBackground, }); String? id; @@ -78,6 +79,9 @@ class ApplicationInstanceDTO { String? playStoreUrl; + /// Fond du menu general, specifique VR. + ImmersiveBackgroundDTO? immersiveBackground; + @override bool operator ==(Object other) => identical(this, other) || @@ -219,6 +223,11 @@ class ApplicationInstanceDTO { } else { json[r'appStoreUrl'] = null; } + if (this.immersiveBackground != null) { + json[r'immersiveBackground'] = this.immersiveBackground; + } else { + json[r'immersiveBackground'] = null; + } if (this.playStoreUrl != null) { json[r'playStoreUrl'] = this.playStoreUrl; } else { @@ -272,6 +281,7 @@ class ApplicationInstanceDTO { appName: TranslationDTO.listFromJson(json[r'appName']), appStoreUrl: mapValueOfType(json, r'appStoreUrl'), playStoreUrl: mapValueOfType(json, r'playStoreUrl'), + immersiveBackground: ImmersiveBackgroundDTO.fromJson(json[r'immersiveBackground']), ); } return null; diff --git a/manager_api_new/lib/model/configuration_dto.dart b/manager_api_new/lib/model/configuration_dto.dart index f574a38..62c80f5 100644 --- a/manager_api_new/lib/model/configuration_dto.dart +++ b/manager_api_new/lib/model/configuration_dto.dart @@ -27,6 +27,7 @@ class ConfigurationDTO { this.sectionIds = const [], this.loaderImageId, this.loaderImageUrl, + this.immersiveBackground, }); String? id; @@ -69,6 +70,9 @@ class ConfigurationDTO { String? loaderImageUrl; + /// Fond immersif de la visite, null si elle n'en a pas. + ImmersiveBackgroundDTO? immersiveBackground; + @override bool operator ==(Object other) => identical(this, other) || @@ -177,6 +181,11 @@ class ConfigurationDTO { } else { json[r'loaderImageId'] = null; } + if (this.immersiveBackground != null) { + json[r'immersiveBackground'] = this.immersiveBackground; + } else { + json[r'immersiveBackground'] = null; + } if (this.loaderImageUrl != null) { json[r'loaderImageUrl'] = this.loaderImageUrl; } else { @@ -228,6 +237,7 @@ class ConfigurationDTO { : const [], loaderImageId: mapValueOfType(json, r'loaderImageId'), loaderImageUrl: mapValueOfType(json, r'loaderImageUrl'), + immersiveBackground: ImmersiveBackgroundDTO.fromJson(json[r'immersiveBackground']), ); } return null; diff --git a/manager_api_new/lib/model/device_detail_dto.dart b/manager_api_new/lib/model/device_detail_dto.dart index 7b434d7..3b65a97 100644 --- a/manager_api_new/lib/model/device_detail_dto.dart +++ b/manager_api_new/lib/model/device_detail_dto.dart @@ -28,6 +28,9 @@ class DeviceDetailDTO { this.lastConnectionLevel, this.batteryLevel, this.lastBatteryLevel, + this.appType, + this.appVersion, + this.lastSeen, }); String? id; @@ -78,6 +81,13 @@ class DeviceDetailDTO { /// DateTime? lastBatteryLevel; + /// Canal de l'appareil. Absent de la requête = tablette, côté serveur. + AppType? appType; + + String? appVersion; + + DateTime? lastSeen; + @override bool operator ==(Object other) => identical(this, other) || @@ -96,7 +106,10 @@ class DeviceDetailDTO { other.connectionLevel == connectionLevel && other.lastConnectionLevel == lastConnectionLevel && other.batteryLevel == batteryLevel && - other.lastBatteryLevel == lastBatteryLevel; + other.lastBatteryLevel == lastBatteryLevel && + other.appType == appType && + other.appVersion == appVersion && + other.lastSeen == lastSeen; @override int get hashCode => @@ -115,11 +128,14 @@ class DeviceDetailDTO { (connectionLevel == null ? 0 : connectionLevel!.hashCode) + (lastConnectionLevel == null ? 0 : lastConnectionLevel!.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 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 toJson() { final json = {}; @@ -200,6 +216,21 @@ class DeviceDetailDTO { } else { 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; } @@ -239,6 +270,9 @@ class DeviceDetailDTO { lastConnectionLevel: mapDateTime(json, r'lastConnectionLevel', r''), batteryLevel: mapValueOfType(json, r'batteryLevel'), lastBatteryLevel: mapDateTime(json, r'lastBatteryLevel', r''), + appType: AppType.fromJson(json[r'appType']), + appVersion: mapValueOfType(json, r'appVersion'), + lastSeen: mapDateTime(json, r'lastSeen', r''), ); } return null; diff --git a/manager_api_new/lib/model/device_dto.dart b/manager_api_new/lib/model/device_dto.dart index 879f3a8..d64782c 100644 --- a/manager_api_new/lib/model/device_dto.dart +++ b/manager_api_new/lib/model/device_dto.dart @@ -24,6 +24,7 @@ class DeviceDTO { this.dateCreation, this.dateUpdate, this.instanceId, + this.appType, }); String? id; @@ -54,6 +55,9 @@ class DeviceDTO { String? instanceId; + /// Canal de l'appareil. Absent de la requête = tablette, côté serveur. + AppType? appType; + @override bool operator ==(Object other) => identical(this, other) || @@ -68,7 +72,8 @@ class DeviceDTO { other.connected == connected && other.dateCreation == dateCreation && other.dateUpdate == dateUpdate && - other.instanceId == instanceId; + other.instanceId == instanceId && + other.appType == appType; @override int get hashCode => @@ -83,11 +88,12 @@ class DeviceDTO { (connected == null ? 0 : connected!.hashCode) + (dateCreation == null ? 0 : dateCreation!.hashCode) + (dateUpdate == null ? 0 : dateUpdate!.hashCode) + - (instanceId == null ? 0 : instanceId!.hashCode); + (instanceId == null ? 0 : instanceId!.hashCode) + + (appType == null ? 0 : appType!.hashCode); @override 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 toJson() { final json = {}; @@ -146,6 +152,11 @@ class DeviceDTO { } else { json[r'instanceId'] = null; } + if (this.appType != null) { + json[r'appType'] = this.appType; + } else { + json[r'appType'] = null; + } return json; } @@ -181,6 +192,7 @@ class DeviceDTO { dateCreation: mapDateTime(json, r'dateCreation', r''), dateUpdate: mapDateTime(json, r'dateUpdate', r''), instanceId: mapValueOfType(json, r'instanceId'), + appType: AppType.fromJson(json[r'appType']), ); } return null; diff --git a/manager_api_new/lib/model/geo_point_dto.dart b/manager_api_new/lib/model/geo_point_dto.dart index e97efa6..8ef3fe7 100644 --- a/manager_api_new/lib/model/geo_point_dto.dart +++ b/manager_api_new/lib/model/geo_point_dto.dart @@ -29,6 +29,8 @@ class GeoPointDTO { this.polyColor, this.sectionMapId, this.sectionEventId, + this.sectionScene3DId, + this.localTransform, }); int? id; @@ -63,6 +65,13 @@ class GeoPointDTO { 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 bool operator ==(Object other) => identical(this, other) || @@ -82,7 +91,9 @@ class GeoPointDTO { other.geometry == geometry && other.polyColor == polyColor && other.sectionMapId == sectionMapId && - other.sectionEventId == sectionEventId; + other.sectionEventId == sectionEventId && + other.sectionScene3DId == sectionScene3DId && + other.localTransform == localTransform; @override int get hashCode => @@ -102,11 +113,13 @@ class GeoPointDTO { (geometry == null ? 0 : geometry!.hashCode) + (polyColor == null ? 0 : polyColor!.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 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 toJson() { final json = {}; @@ -230,6 +243,8 @@ class GeoPointDTO { polyColor: mapValueOfType(json, r'polyColor'), sectionMapId: mapValueOfType(json, r'sectionMapId'), sectionEventId: mapValueOfType(json, r'sectionEventId'), + sectionScene3DId: mapValueOfType(json, r'sectionScene3DId'), + localTransform: Position3D.fromJson(json[r'localTransform']), ); } return null; diff --git a/manager_api_new/lib/model/immersive_background_dto.dart b/manager_api_new/lib/model/immersive_background_dto.dart new file mode 100644 index 0000000..f5be54f --- /dev/null +++ b/manager_api_new/lib/model/immersive_background_dto.dart @@ -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 toJson() { + final json = {}; + 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(); + + return ImmersiveBackgroundDTO( + resourceId: mapValueOfType(json, r'resourceId'), + kind: ImmersiveBackgroundKind.fromJson(json[r'kind']), + fallbackResourceId: mapValueOfType(json, r'fallbackResourceId'), + resourceUrl: mapValueOfType(json, r'resourceUrl'), + fallbackUrl: mapValueOfType(json, r'fallbackUrl'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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 mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); + for (final entry in json.entries) { + final value = ImmersiveBackgroundDTO.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + static const requiredKeys = {}; +} diff --git a/manager_api_new/lib/model/immersive_background_kind.dart b/manager_api_new/lib/model/immersive_background_kind.dart new file mode 100644 index 0000000..9dcc7ff --- /dev/null +++ b/manager_api_new/lib/model/immersive_background_kind.dart @@ -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 = [Pano, Video360, Scene3D]; + + static ImmersiveBackgroundKind? fromJson(dynamic value) => + ImmersiveBackgroundKindTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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; +} diff --git a/manager_api_new/lib/model/instance_dto.dart b/manager_api_new/lib/model/instance_dto.dart index f89cb9c..6b1d9bb 100644 --- a/manager_api_new/lib/model/instance_dto.dart +++ b/manager_api_new/lib/model/instance_dto.dart @@ -23,6 +23,7 @@ class InstanceDTO { this.isWeb, this.isVR, this.isAssistant, + this.hasImmersiveContent, this.guideName, this.guidePersonaPrompt, this.guideVoiceId, @@ -101,6 +102,9 @@ class InstanceDTO { bool? isAssistant; + /// Add-on « Contenu immersif » : image 360, video 360 et modeles 3D. + bool? hasImmersiveContent; + /// Nom du guide affiché au visiteur, libre. String? guideName; @@ -159,6 +163,7 @@ class InstanceDTO { other.isWeb == isWeb && other.isVR == isVR && other.isAssistant == isAssistant && + other.hasImmersiveContent == hasImmersiveContent && other.guideName == guideName && other.guidePersonaPrompt == guidePersonaPrompt && other.guideVoiceId == guideVoiceId && @@ -188,6 +193,7 @@ class InstanceDTO { (isWeb == null ? 0 : isWeb!.hashCode) + (isVR == null ? 0 : isVR!.hashCode) + (isAssistant == null ? 0 : isAssistant!.hashCode) + + (hasImmersiveContent == null ? 0 : hasImmersiveContent!.hashCode) + (guideName == null ? 0 : guideName!.hashCode) + (guidePersonaPrompt == null ? 0 : guidePersonaPrompt!.hashCode) + (guideVoiceId == null ? 0 : guideVoiceId!.hashCode) + @@ -249,6 +255,11 @@ class InstanceDTO { } else { json[r'isVR'] = null; } + if (this.hasImmersiveContent != null) { + json[r'hasImmersiveContent'] = this.hasImmersiveContent; + } else { + json[r'hasImmersiveContent'] = null; + } if (this.isAssistant != null) { json[r'isAssistant'] = this.isAssistant; } else { @@ -379,6 +390,7 @@ class InstanceDTO { isWeb: mapValueOfType(json, r'isWeb'), isVR: mapValueOfType(json, r'isVR'), isAssistant: mapValueOfType(json, r'isAssistant'), + hasImmersiveContent: mapValueOfType(json, r'hasImmersiveContent'), guideName: mapValueOfType(json, r'guideName'), guidePersonaPrompt: mapValueOfType(json, r'guidePersonaPrompt'), guideVoiceId: mapValueOfType(json, r'guideVoiceId'), diff --git a/manager_api_new/lib/model/position3_d.dart b/manager_api_new/lib/model/position3_d.dart new file mode 100644 index 0000000..4c86906 --- /dev/null +++ b/manager_api_new/lib/model/position3_d.dart @@ -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 toJson() { + final json = {}; + 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(); + + return Position3D( + x: mapValueOfType(json, r'x')?.toDouble() ?? 0, + y: mapValueOfType(json, r'y')?.toDouble() ?? 0, + z: mapValueOfType(json, r'z')?.toDouble() ?? 0, + rotationY: mapValueOfType(json, r'rotationY')?.toDouble(), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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 mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); + for (final entry in json.entries) { + final value = Position3D.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + static const requiredKeys = {}; +} diff --git a/manager_api_new/lib/model/resource_type.dart b/manager_api_new/lib/model/resource_type.dart index b42ec0c..9e32b06 100644 --- a/manager_api_new/lib/model/resource_type.dart +++ b/manager_api_new/lib/model/resource_type.dart @@ -11,6 +11,7 @@ part of openapi.api; /// 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 { /// Instantiate a new enum with the provided [value]. const ResourceType._(this.value); @@ -31,6 +32,19 @@ class ResourceType { static const Pdf = ResourceType._(5); static const Json = ResourceType._(6); 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]. static const values = [ @@ -41,7 +55,13 @@ class ResourceType { Audio, Pdf, Json, - JsonUrl + JsonUrl, + Word, + PowerPoint, + Text, + Image360, + Video360, + Model3D ]; static ResourceType? fromJson(dynamic value) => ResourceTypeTypeTransformer().decode(value); @@ -89,6 +109,12 @@ class ResourceTypeTypeTransformer { case "PDF": return ResourceType.Pdf; case "JSON": return ResourceType.Json; 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: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); @@ -105,6 +131,12 @@ class ResourceTypeTypeTransformer { case 5: return ResourceType.Pdf; case 6: return ResourceType.Json; 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: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/manager_api_new/lib/model/scene3_d_dto.dart b/manager_api_new/lib/model/scene3_d_dto.dart new file mode 100644 index 0000000..d89d5a4 --- /dev/null +++ b/manager_api_new/lib/model/scene3_d_dto.dart @@ -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? title; + + List? 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? 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 toJson() { + final json = {}; + 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(); + + return Scene3DDTO( + id: mapValueOfType(json, r'id'), + label: mapValueOfType(json, r'label'), + title: TranslationDTO.listFromJson(json[r'title']), + description: TranslationDTO.listFromJson(json[r'description']), + isActive: mapValueOfType(json, r'isActive'), + imageId: mapValueOfType(json, r'imageId'), + imageSource: mapValueOfType(json, r'imageSource'), + configurationId: mapValueOfType(json, r'configurationId'), + isSubSection: mapValueOfType(json, r'isSubSection'), + parentId: mapValueOfType(json, r'parentId'), + type: SectionType.fromJson(json[r'type']), + dateCreation: mapDateTime(json, r'dateCreation', r''), + order: mapValueOfType(json, r'order'), + instanceId: mapValueOfType(json, r'instanceId'), + latitude: mapValueOfType(json, r'latitude'), + longitude: mapValueOfType(json, r'longitude'), + meterZoneGPS: mapValueOfType(json, r'meterZoneGPS'), + isBeacon: mapValueOfType(json, r'isBeacon'), + beaconId: mapValueOfType(json, r'beaconId'), + model3DResourceId: mapValueOfType(json, r'model3DResourceId'), + model3DSource: mapValueOfType(json, r'model3DSource'), + mode: Scene3DMode.fromJson(json[r'mode']), + points: GeoPointDTO.listFromJson(json[r'points']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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 mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); + for (final entry in json.entries) { + final value = Scene3DDTO.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + static const requiredKeys = {}; +} diff --git a/manager_api_new/lib/model/scene3_d_mode.dart b/manager_api_new/lib/model/scene3_d_mode.dart new file mode 100644 index 0000000..a8d58b4 --- /dev/null +++ b/manager_api_new/lib/model/scene3_d_mode.dart @@ -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 = [Asset, Scene]; + + static Scene3DMode? fromJson(dynamic value) => + Scene3DModeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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; +} diff --git a/manager_api_new/lib/model/section_type.dart b/manager_api_new/lib/model/section_type.dart index 123b5cd..77eb810 100644 --- a/manager_api_new/lib/model/section_type.dart +++ b/manager_api_new/lib/model/section_type.dart @@ -37,6 +37,9 @@ class SectionType { static const Event = SectionType._(11); 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]. static const values = [ Map, @@ -52,6 +55,7 @@ class SectionType { Weather, Event, Parcours, + Scene3D, ]; static SectionType? fromJson(dynamic value) => SectionTypeTypeTransformer().decode(value); @@ -104,6 +108,7 @@ class SectionTypeTypeTransformer { case r'Weather': return SectionType.Weather; case r'Event': return SectionType.Event; case r'Parcours': return SectionType.Parcours; + case r'Scene3D': return SectionType.Scene3D; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); @@ -125,6 +130,7 @@ class SectionTypeTypeTransformer { case 10: return SectionType.Weather; case 11: return SectionType.Event; case 12: return SectionType.Parcours; + case 13: return SectionType.Scene3D; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data');