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

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

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

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

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

126 lines
4.5 KiB
Dart

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