manager-app/lib/Components/resource_picker.dart

419 lines
13 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',
];
/// 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<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)),
),
],
);
}
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;
}