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 kAcceptedExtensions = [ 'jpg', 'jpeg', 'png', 'gif', 'mp3', 'mp4', 'webm', 'pdf', 'json', ]; /// 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; final ResourceType? type; const PickedResource.file(this.file, this.type) : url = null; const 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 picked; final VoidCallback onChanged; const ResourcePicker({ Key? key, required this.picked, required this.onChanged, }) : super(key: key); @override State createState() => _ResourcePickerState(); } class _ResourcePickerState extends State { bool _hot = false; bool _urlOpen = false; String? _error; final TextEditingController _urlController = TextEditingController(); StreamSubscription? _overSub; StreamSubscription? _leaveSub; StreamSubscription? _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 _readBytes(html.File file) { final completer = Completer(); 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 _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)), ), ], ); } 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), 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)), ), 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; }