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>
473 lines
15 KiB
Dart
473 lines
15 KiB
Dart
import 'dart:async';
|
|
import 'dart:html' as html;
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:manager_app/Components/fetch_resource_icon.dart';
|
|
import 'package:manager_app/Screens/Resources/resource_formatting.dart';
|
|
import 'package:manager_app/constants.dart';
|
|
import 'package:manager_app/l10n/app_localizations.dart';
|
|
import 'package:manager_api_new/api.dart';
|
|
|
|
const List<String> kAcceptedExtensions = [
|
|
'jpg', 'jpeg', 'png', 'gif', 'mp3', 'mp4', 'webm', 'pdf', 'json', 'glb',
|
|
];
|
|
|
|
/// Une entrée en attente d'envoi : soit un fichier local, soit une URL.
|
|
///
|
|
/// Les deux vivent dans la même liste parce que c'est ainsi que l'utilisateur les
|
|
/// voit — l'ancien dialogue les séparait en deux onglets, ce qui imposait un choix
|
|
/// technique (où vivent les octets) avant même d'avoir choisi quoi que ce soit.
|
|
class PickedResource {
|
|
final PlatformFile? file;
|
|
final String? url;
|
|
|
|
/// 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;
|
|
int get sizeBytes => isUrl ? 0 : file!.size;
|
|
}
|
|
|
|
class ResourcePicker extends StatefulWidget {
|
|
final List<PickedResource> picked;
|
|
final VoidCallback onChanged;
|
|
|
|
const ResourcePicker({
|
|
Key? key,
|
|
required this.picked,
|
|
required this.onChanged,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<ResourcePicker> createState() => _ResourcePickerState();
|
|
}
|
|
|
|
class _ResourcePickerState extends State<ResourcePicker> {
|
|
bool _hot = false;
|
|
bool _urlOpen = false;
|
|
String? _error;
|
|
final TextEditingController _urlController = TextEditingController();
|
|
|
|
StreamSubscription<html.MouseEvent>? _overSub;
|
|
StreamSubscription<html.MouseEvent>? _leaveSub;
|
|
StreamSubscription<html.MouseEvent>? _dropSub;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_listenToBrowserDrops();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_urlController.dispose();
|
|
_overSub?.cancel();
|
|
_leaveSub?.cancel();
|
|
_dropSub?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Flutter web ne reçoit pas les fichiers glissés depuis le bureau : c'est le
|
|
/// document HTML qui les reçoit. On écoute donc `document`, et on annule son
|
|
/// comportement par défaut — sans quoi le navigateur quitte l'application pour
|
|
/// afficher le fichier lâché.
|
|
void _listenToBrowserDrops() {
|
|
_overSub = html.document.onDragOver.listen((event) {
|
|
event.preventDefault();
|
|
if (!_hot) setState(() => _hot = true);
|
|
});
|
|
_leaveSub = html.document.onDragLeave.listen((event) {
|
|
event.preventDefault();
|
|
if (_hot) setState(() => _hot = false);
|
|
});
|
|
_dropSub = html.document.onDrop.listen((event) async {
|
|
event.preventDefault();
|
|
setState(() => _hot = false);
|
|
final files = event.dataTransfer.files;
|
|
if (files == null) return;
|
|
for (final file in files) {
|
|
final bytes = await _readBytes(file);
|
|
if (bytes != null) _add(file.name, bytes);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<Uint8List?> _readBytes(html.File file) {
|
|
final completer = Completer<Uint8List?>();
|
|
final reader = html.FileReader();
|
|
reader.onLoadEnd.listen((_) {
|
|
final result = reader.result;
|
|
completer.complete(result is Uint8List ? result : null);
|
|
});
|
|
reader.onError.listen((_) => completer.complete(null));
|
|
reader.readAsArrayBuffer(file);
|
|
return completer.future;
|
|
}
|
|
|
|
String? _extensionOf(String name) {
|
|
final dot = name.lastIndexOf('.');
|
|
return dot == -1 ? null : name.substring(dot + 1).toLowerCase();
|
|
}
|
|
|
|
void _add(String name, Uint8List bytes) {
|
|
final l = AppLocalizations.of(context)!;
|
|
final extension = _extensionOf(name);
|
|
|
|
if (!kAcceptedExtensions.contains(extension)) {
|
|
setState(() => _error = '${l.mediaUnsupportedFormat} : $name');
|
|
return;
|
|
}
|
|
if (widget.picked.any((p) => !p.isUrl && p.name == name)) {
|
|
setState(() => _error = l.mediaFileAlreadyAdded);
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_error = null;
|
|
widget.picked.add(PickedResource.file(
|
|
PlatformFile(name: name, size: bytes.length, bytes: bytes),
|
|
resourceTypeForExtension(extension),
|
|
));
|
|
});
|
|
widget.onChanged();
|
|
}
|
|
|
|
Future<void> _browse() async {
|
|
final result = await FilePicker.platform.pickFiles(
|
|
type: FileType.custom,
|
|
allowMultiple: true,
|
|
withData: true,
|
|
allowedExtensions: kAcceptedExtensions,
|
|
);
|
|
if (result == null) return;
|
|
for (final file in result.files) {
|
|
if (file.bytes != null) _add(file.name, file.bytes!);
|
|
}
|
|
}
|
|
|
|
void _addUrl() {
|
|
final l = AppLocalizations.of(context)!;
|
|
final url = _urlController.text.trim();
|
|
|
|
if (url.isEmpty) return;
|
|
if (!Uri.parse(url).isAbsolute) {
|
|
setState(() => _error = l.mediaUrlInvalid);
|
|
return;
|
|
}
|
|
if (widget.picked.any((p) => p.isUrl && p.url == url)) {
|
|
setState(() => _error = l.mediaUrlAlreadyAdded);
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_error = null;
|
|
_urlController.clear();
|
|
widget.picked.add(PickedResource.url(url, _typeForUrl(url)));
|
|
});
|
|
widget.onChanged();
|
|
}
|
|
|
|
/// Même règle qu'avant, mais lisible : une URL YouTube est une vidéo, un `.json`
|
|
/// ou un `.php` un flux JSON, tout le reste une image.
|
|
ResourceType _typeForUrl(String url) {
|
|
final youtube = RegExp(
|
|
r'^https?://(?:www\.)?(?:youtube\.com/(?:[^/]+/[^/]+/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be/)([^"&?/=%]{11})');
|
|
if (youtube.hasMatch(url)) return ResourceType.VideoUrl;
|
|
if (url.endsWith('json') || url.contains('.php')) return ResourceType.JsonUrl;
|
|
return ResourceType.ImageUrl;
|
|
}
|
|
|
|
void _remove(PickedResource entry) {
|
|
setState(() {
|
|
_error = null;
|
|
widget.picked.remove(entry);
|
|
});
|
|
widget.onChanged();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_dropZone(l),
|
|
const SizedBox(height: kSpace4),
|
|
_urlToggle(l),
|
|
if (_urlOpen) ...[
|
|
const SizedBox(height: kSpace3),
|
|
_urlRow(l),
|
|
],
|
|
if (_error != null) ...[
|
|
const SizedBox(height: kSpace3),
|
|
Text(_error!, style: const TextStyle(fontSize: 12.5, color: kError)),
|
|
],
|
|
if (widget.picked.isNotEmpty) ...[
|
|
const SizedBox(height: kSpace4),
|
|
_list(l),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _dropZone(AppLocalizations l) {
|
|
return InkWell(
|
|
onTap: _browse,
|
|
borderRadius: BorderRadius.circular(kRadiusCard),
|
|
child: DottedBorderBox(
|
|
active: _hot,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: kSpace7, horizontal: kSpace5),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.file_upload_outlined,
|
|
size: 26, color: _hot ? kPrimaryColor : kInk3),
|
|
const SizedBox(height: kSpace3),
|
|
Text(
|
|
l.mediaDropZone,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 13.5, fontWeight: FontWeight.w600, color: kInk),
|
|
),
|
|
const SizedBox(height: kSpace1),
|
|
Text(l.mediaDropFormats, style: kTextHint),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _urlToggle(AppLocalizations l) {
|
|
return Row(
|
|
children: [
|
|
const Expanded(child: Divider(height: 1, color: kLineSoft)),
|
|
TextButton.icon(
|
|
onPressed: () => setState(() => _urlOpen = !_urlOpen),
|
|
icon: const Icon(Icons.link, size: 14, color: kInk2),
|
|
label: Text(l.mediaOrPasteUrl,
|
|
style: const TextStyle(fontSize: 12.5, color: kInk2)),
|
|
style: TextButton.styleFrom(
|
|
visualDensity: VisualDensity.compact,
|
|
padding: const EdgeInsets.symmetric(horizontal: kSpace3),
|
|
),
|
|
),
|
|
const Expanded(child: Divider(height: 1, color: kLineSoft)),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _urlRow(AppLocalizations l) {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _urlController,
|
|
autofocus: true,
|
|
style: const TextStyle(fontSize: 13, color: kInk),
|
|
onSubmitted: (_) => _addUrl(),
|
|
decoration: InputDecoration(
|
|
isDense: true,
|
|
filled: true,
|
|
fillColor: kSurface2,
|
|
hintText: l.mediaUrlHint,
|
|
hintStyle: kTextHint,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: kSpace4, vertical: kSpace3),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(kRadiusInput),
|
|
borderSide: const BorderSide(color: kLine),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(kRadiusInput),
|
|
borderSide: const BorderSide(color: kLine),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: kSpace2),
|
|
FilledButton(
|
|
onPressed: _addUrl,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: kPrimaryColor,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(kRadiusPill)),
|
|
),
|
|
child: Text(l.add, style: const TextStyle(fontSize: 13)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Bascule entre un média plat et sa version 360°.
|
|
///
|
|
/// Aucune extension ne dit qu'une photo est équirectangulaire : un panorama 360
|
|
/// est un `.jpg` comme un autre. C'est donc au gestionnaire de le déclarer, et
|
|
/// ça change deux choses — la ressource n'est plus compressée (une 360 ramenée
|
|
/// à 2560 px devient une bouillie dans un casque), et le casque sait qu'il peut
|
|
/// l'afficher en skybox.
|
|
static const Map<ResourceType, ResourceType> _immersiveOf = {
|
|
ResourceType.Image: ResourceType.Image360,
|
|
ResourceType.Image360: ResourceType.Image,
|
|
ResourceType.Video: ResourceType.Video360,
|
|
ResourceType.Video360: ResourceType.Video,
|
|
};
|
|
|
|
Widget _typeChip(AppLocalizations l, PickedResource entry) {
|
|
final toggle = _immersiveOf[entry.type];
|
|
final isImmersive = entry.type == ResourceType.Image360 ||
|
|
entry.type == ResourceType.Video360;
|
|
|
|
final chip = Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: kSpace2, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: isImmersive ? kBrandSoft : kSurface3,
|
|
borderRadius: BorderRadius.circular(kRadiusInput),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(resourceTypeLabel(l, entry.type),
|
|
style: TextStyle(
|
|
fontSize: 10.5,
|
|
color: isImmersive ? kInk : kInk2,
|
|
fontWeight: isImmersive ? FontWeight.w600 : FontWeight.w400)),
|
|
// Sans ce chevron, rien ne distingue une pastille cliquable d'un simple
|
|
// libellé : le basculement 360° n'était découvrable qu'en survolant.
|
|
if (toggle != null) ...[
|
|
const SizedBox(width: 3),
|
|
Icon(Icons.swap_horiz,
|
|
size: 12, color: isImmersive ? kInk : kInk3),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
|
|
if (toggle == null) return chip;
|
|
|
|
return Tooltip(
|
|
message: l.mediaToggle360Hint,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(kRadiusInput),
|
|
onTap: () {
|
|
setState(() => entry.type = toggle);
|
|
widget.onChanged();
|
|
},
|
|
child: chip,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _list(AppLocalizations l) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
for (final entry in widget.picked)
|
|
Container(
|
|
decoration: const BoxDecoration(
|
|
border: Border(bottom: BorderSide(color: kLineSoft)),
|
|
),
|
|
padding: const EdgeInsets.symmetric(vertical: kSpace3),
|
|
child: Row(
|
|
children: [
|
|
Icon(getResourceIcon(entry.type), size: 17, color: kInk3),
|
|
const SizedBox(width: kSpace4),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(entry.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 13, color: kInk)),
|
|
if (!entry.isUrl)
|
|
Text(formatBytes(context, entry.sizeBytes),
|
|
style: kTextHint),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: kSpace3),
|
|
_typeChip(l, entry),
|
|
const SizedBox(width: kSpace2),
|
|
IconButton(
|
|
icon: const Icon(Icons.close, size: 15),
|
|
color: kInk3,
|
|
splashRadius: 15,
|
|
visualDensity: VisualDensity.compact,
|
|
tooltip: l.mediaRemoveFile(entry.name),
|
|
onPressed: () => _remove(entry),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Bordure en pointillés dessinée à la main : Flutter n'en fournit pas, et la seule
|
|
/// alternative était une image de bordure ou un paquet de plus.
|
|
class DottedBorderBox extends StatelessWidget {
|
|
final Widget child;
|
|
final bool active;
|
|
const DottedBorderBox({Key? key, required this.child, this.active = false})
|
|
: super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return CustomPaint(
|
|
painter: _DashedBorderPainter(
|
|
color: active ? kPrimaryColor : kLine, radius: kRadiusCard),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: active ? kSurface3 : kSurface2,
|
|
borderRadius: BorderRadius.circular(kRadiusCard),
|
|
),
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DashedBorderPainter extends CustomPainter {
|
|
final Color color;
|
|
final double radius;
|
|
const _DashedBorderPainter({required this.color, required this.radius});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = color
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1.5;
|
|
|
|
final path = Path()
|
|
..addRRect(RRect.fromRectAndRadius(
|
|
Offset.zero & size, Radius.circular(radius)));
|
|
|
|
for (final metric in path.computeMetrics()) {
|
|
var distance = 0.0;
|
|
while (distance < metric.length) {
|
|
final end = (distance + 5).clamp(0.0, metric.length);
|
|
canvas.drawPath(metric.extractPath(distance, end), paint);
|
|
distance = end + 4;
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_DashedBorderPainter oldDelegate) =>
|
|
oldDelegate.color != color;
|
|
}
|