419 lines
13 KiB
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;
|
|
}
|