Last active
October 9, 2025 08:17
-
-
Save PlugFox/e94689b8e708c5ab191764506f5da089 to your computer and use it in GitHub Desktop.
Flutter Web Drop Zone
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:typed_data' show Uint8List; | |
| import 'package:meta/meta.dart' show immutable; | |
| import 'drop_zone_stream_vm.dart' | |
| // ignore: uri_does_not_exist | |
| if (dart.library.js_interop) 'drop_zone_stream_js.dart'; | |
| /// {@template drop_zone_event} | |
| /// Events emitted by the [DropZone] stream. | |
| /// {@endtemplate} | |
| @immutable | |
| sealed class DropZoneEvent { | |
| /// {@macro drop_zone_event} | |
| const DropZoneEvent({required this.dx, required this.dy}); | |
| /// The position of the event in the viewport. | |
| /// Values are in logical pixels. | |
| final int dx; | |
| /// The position of the event in the viewport. | |
| /// Values are in logical pixels. | |
| final int dy; | |
| T map<T>({ | |
| required T Function(DropZoneEvent$Enter event) enter, | |
| required T Function(DropZoneEvent$Over event) over, | |
| required T Function(DropZoneEvent$Leave event) leave, | |
| required T Function(DropZoneEvent$Drop event) drop, | |
| }); | |
| } | |
| /// {@macro drop_zone_event} | |
| final class DropZoneEvent$Enter extends DropZoneEvent { | |
| /// {@macro drop_zone_event} | |
| const DropZoneEvent$Enter({required super.dx, required super.dy}); | |
| @override | |
| T map<T>({ | |
| required T Function(DropZoneEvent$Enter event) enter, | |
| required T Function(DropZoneEvent$Over event) over, | |
| required T Function(DropZoneEvent$Leave event) leave, | |
| required T Function(DropZoneEvent$Drop event) drop, | |
| }) => enter(this); | |
| @override | |
| String toString() => 'Enter'; | |
| } | |
| /// {@macro drop_zone_event} | |
| final class DropZoneEvent$Over extends DropZoneEvent { | |
| /// {@macro drop_zone_event} | |
| const DropZoneEvent$Over({required super.dx, required super.dy}); | |
| @override | |
| T map<T>({ | |
| required T Function(DropZoneEvent$Enter event) enter, | |
| required T Function(DropZoneEvent$Over event) over, | |
| required T Function(DropZoneEvent$Leave event) leave, | |
| required T Function(DropZoneEvent$Drop event) drop, | |
| }) => over(this); | |
| @override | |
| String toString() => 'Over'; | |
| } | |
| /// {@macro drop_zone_event} | |
| final class DropZoneEvent$Leave extends DropZoneEvent { | |
| /// {@macro drop_zone_event} | |
| const DropZoneEvent$Leave({required super.dx, required super.dy}); | |
| @override | |
| T map<T>({ | |
| required T Function(DropZoneEvent$Enter event) enter, | |
| required T Function(DropZoneEvent$Over event) over, | |
| required T Function(DropZoneEvent$Leave event) leave, | |
| required T Function(DropZoneEvent$Drop event) drop, | |
| }) => leave(this); | |
| @override | |
| String toString() => 'Leave'; | |
| } | |
| /// {@macro drop_zone_event} | |
| final class DropZoneEvent$Drop extends DropZoneEvent { | |
| /// {@macro drop_zone_event} | |
| const DropZoneEvent$Drop({required super.dx, required super.dy, required this.items}); | |
| /// Map of file names to their byte content. | |
| final Map<String, Uint8List> items; | |
| @override | |
| T map<T>({ | |
| required T Function(DropZoneEvent$Enter event) enter, | |
| required T Function(DropZoneEvent$Over event) over, | |
| required T Function(DropZoneEvent$Leave event) leave, | |
| required T Function(DropZoneEvent$Drop event) drop, | |
| }) => drop(this); | |
| @override | |
| String toString() => 'Drop'; | |
| } | |
| /// DropZone stream | |
| /// Use it as a [Stream] of [DropZoneEvent]s. | |
| /// e.g. `DropZone().listen((event) { ... })` | |
| /// | |
| /// Good idea to show some UI indication when drag-and-drop is active. | |
| /// Display an overlay when [DropZoneEvent$Enter] is received, | |
| /// keep it while [DropZoneEvent$Over] events are received, | |
| /// and hide it when [DropZoneEvent$Leave] or [DropZoneEvent$Drop] is received. | |
| extension type DropZone._(Stream<DropZoneEvent> _source) implements Stream<DropZoneEvent> { | |
| /// Creates a [DropZone] stream. | |
| factory DropZone({ | |
| // Max number of files to accept (15 default) | |
| int limit = 15, | |
| // Max file size in bytes (20 MB default) | |
| int size = 20 * 1024 * 1024, | |
| // Accepted file extensions | |
| Set<String> extensions = const <String>{'png', 'jpg', 'jpeg', 'gif', 'bmp', 'txt', 'pdf'}, | |
| }) => DropZone._($dropZoneStream(limit: limit, size: size, extensions: extensions)); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'package:flutter/foundation.dart' show ValueListenable; | |
| import 'package:flutter/widgets.dart'; | |
| /// A wrapper around [OverlayEntry] to provide additional functionality. | |
| /// Such as removing the entry if it is mounted. | |
| extension type DropZoneOverlayEntry._(OverlayEntry entry) implements OverlayEntry { | |
| /// Remove the entry if it is mounted. | |
| void maybePop() { | |
| if (entry.mounted) { | |
| entry.remove(); | |
| } | |
| } | |
| } | |
| /// {@template drop_zone_overlay} | |
| /// DropZoneOverlay widget with animated visual effects. | |
| /// {@endtemplate} | |
| class DropZoneOverlay extends StatefulWidget { | |
| /// {@macro drop_zone_overlay} | |
| const DropZoneOverlay({ | |
| super.key, // ignore: unused_element | |
| }); | |
| /// Create an OverlayEntry with DropZoneOverlay as its child. | |
| static DropZoneOverlayEntry entry() => | |
| DropZoneOverlayEntry._(OverlayEntry(builder: (context) => const Positioned.fill(child: DropZoneOverlay()))); | |
| /// Show the DropZoneOverlay in the given context. | |
| /// Returns the OverlayEntry that was inserted. | |
| static DropZoneOverlayEntry show(BuildContext context) { | |
| final entry = DropZoneOverlay.entry(); | |
| if (!context.mounted) return entry; | |
| Overlay.maybeOf(context)?.insert(entry); | |
| return entry; | |
| } | |
| @override | |
| State<DropZoneOverlay> createState() => _DropZoneOverlayState(); | |
| } | |
| class _DropZoneOverlayState extends State<DropZoneOverlay> with SingleTickerProviderStateMixin { | |
| late final AnimationController _controller; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _controller = AnimationController(duration: const Duration(milliseconds: 2000), vsync: this)..repeat(reverse: true); | |
| } | |
| @override | |
| void dispose() { | |
| _controller.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) => | |
| IgnorePointer(ignoring: true, child: CustomPaint(painter: DropZoneOverlayPainter(animation: _controller))); | |
| } | |
| /// {@template drop_zone_overlay_painter} | |
| /// Custom painter for the drop zone overlay with animated effects. | |
| /// {@endtemplate} | |
| class DropZoneOverlayPainter extends CustomPainter { | |
| /// {@macro drop_zone_overlay_painter} | |
| const DropZoneOverlayPainter({required ValueListenable<double> animation}) | |
| : _animation = animation, | |
| super(repaint: animation); | |
| final ValueListenable<double> _animation; | |
| @override | |
| void paint(Canvas canvas, Size size) { | |
| final center = size.center(Offset.zero); | |
| final radius = size.shortestSide * 0.35; | |
| // Calculate animation values from the listenable (AnimationController) | |
| final animationValue = _animation.value; | |
| // Apply easing curve for smooth animation | |
| final t = Curves.easeInOut.transform(animationValue); | |
| // Calculate animated properties | |
| final scale = 0.98 + (1.0 - 0.98) * t; | |
| final pulse = t; | |
| // Use fixed radius for text positioning to prevent shifting | |
| final animatedRadius = radius * scale; | |
| // Background overlay with subtle gradient effect | |
| final backgroundPaint = | |
| Paint() | |
| ..shader = const RadialGradient( | |
| center: Alignment.center, | |
| radius: 1.5, | |
| colors: [Color(0x50000000), Color(0x70000000)], | |
| ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) | |
| ..style = PaintingStyle.fill | |
| ..blendMode = BlendMode.srcOver; | |
| canvas.drawRect(Offset.zero & size, backgroundPaint); | |
| // Main drop zone rounded rectangle | |
| final rrect = RRect.fromRectAndRadius( | |
| Rect.fromCenter(center: center, width: animatedRadius * 2, height: animatedRadius * 2), | |
| Radius.circular(animatedRadius * 0.12), | |
| ); | |
| // Inner fill with gradient | |
| final fillPaint = | |
| Paint() | |
| ..shader = const LinearGradient( | |
| begin: Alignment.topLeft, | |
| end: Alignment.bottomRight, | |
| colors: [Color(0x30FFFFFF), Color(0x10FFFFFF)], | |
| ).createShader(rrect.outerRect) | |
| ..style = PaintingStyle.fill | |
| ..isAntiAlias = true; | |
| canvas.drawRRect(rrect, fillPaint); | |
| // Animated pulsing glow effect | |
| final glowPaint = | |
| Paint() | |
| ..color = Color.lerp(const Color(0x20FFFFFF), const Color(0x40FFFFFF), pulse)! | |
| ..style = PaintingStyle.stroke | |
| ..strokeWidth = 8.0 | |
| ..maskFilter = MaskFilter.blur(BlurStyle.normal, 12 + pulse * 8) | |
| ..isAntiAlias = true; | |
| canvas.drawRRect(rrect, glowPaint); | |
| // Main border with dashed effect | |
| final borderPaint = | |
| Paint() | |
| ..color = const Color(0xFFFFFFFF) | |
| ..style = PaintingStyle.stroke | |
| ..strokeWidth = 3.0 | |
| ..strokeCap = StrokeCap.round | |
| ..strokeJoin = StrokeJoin.round | |
| ..isAntiAlias = true; | |
| // Draw dashed border | |
| _drawDashedRRect(canvas, rrect, borderPaint, dashLength: 20, gapLength: 10); | |
| // Upload icon above text - use fixed radius for positioning | |
| _drawUploadIcon(canvas, center, radius, pulse); | |
| // Main text | |
| final mainText = TextPainter( | |
| maxLines: 1, | |
| text: TextSpan( | |
| text: 'Drop files here', | |
| style: TextStyle( | |
| color: const Color(0xFFFFFFFF), | |
| fontSize: switch (size.width) { | |
| >= 800 => 36.0, | |
| >= 600 => 28.0, | |
| _ => 20.0, | |
| }, | |
| shadows: const [ | |
| Shadow(color: Color(0x80000000), offset: Offset(0, 2), blurRadius: 8), | |
| Shadow(color: Color(0x40000000), offset: Offset(0, 4), blurRadius: 16), | |
| ], | |
| fontWeight: FontWeight.bold, | |
| letterSpacing: 0.5, | |
| ), | |
| ), | |
| textDirection: TextDirection.ltr, | |
| )..layout(); | |
| // Subtitle text | |
| final subtitleText = TextPainter( | |
| maxLines: 1, | |
| text: TextSpan( | |
| text: 'to attach to your message', | |
| style: TextStyle( | |
| color: const Color(0xE0FFFFFF), | |
| fontSize: switch (size.width) { | |
| >= 800 => 18.0, | |
| >= 600 => 14.0, | |
| _ => 12.0, | |
| }, | |
| shadows: const [Shadow(color: Color(0x80000000), offset: Offset(0, 1), blurRadius: 4)], | |
| fontWeight: FontWeight.w400, | |
| letterSpacing: 0.3, | |
| ), | |
| ), | |
| textDirection: TextDirection.ltr, | |
| )..layout(); | |
| // Draw texts centered with proper spacing - use fixed radius to prevent shifting | |
| final textY = center.dy + radius * 0.1; | |
| mainText.paint(canvas, Offset((size.width - mainText.width) / 2, textY)); | |
| subtitleText.paint(canvas, Offset((size.width - subtitleText.width) / 2, textY + mainText.height + 8)); | |
| } | |
| /// Draw dashed rounded rectangle border. | |
| void _drawDashedRRect( | |
| Canvas canvas, | |
| RRect rrect, | |
| Paint paint, { | |
| required double dashLength, | |
| required double gapLength, | |
| }) { | |
| final path = Path()..addRRect(rrect); | |
| final metrics = path.computeMetrics(); | |
| for (final metric in metrics) { | |
| double distance = 0.0; | |
| while (distance < metric.length) { | |
| final nextDistance = distance + dashLength; | |
| final dashPath = metric.extractPath(distance, nextDistance.clamp(0.0, metric.length)); | |
| canvas.drawPath(dashPath, paint); | |
| distance = nextDistance + gapLength; | |
| } | |
| } | |
| } | |
| /// Draw upload icon above the text. | |
| void _drawUploadIcon(Canvas canvas, Offset center, double radius, double pulse) { | |
| final iconSize = radius * 0.25; | |
| final iconY = center.dy - radius * 0.3; | |
| final paint = | |
| Paint() | |
| ..color = Color.lerp(const Color(0xFFFFFFFF), const Color(0xFFE0E0E0), pulse * 0.3)! | |
| ..style = PaintingStyle.stroke | |
| ..strokeWidth = 3.0 | |
| ..strokeCap = StrokeCap.round | |
| ..strokeJoin = StrokeJoin.round | |
| ..isAntiAlias = true; | |
| final fillPaint = | |
| Paint() | |
| ..color = Color.lerp(const Color(0x20FFFFFF), const Color(0x40FFFFFF), pulse)! | |
| ..style = PaintingStyle.fill | |
| ..isAntiAlias = true; | |
| // Cloud shape | |
| final cloudPath = Path(); | |
| final cloudCenter = Offset(center.dx, iconY); | |
| // Draw simplified cloud with arc | |
| cloudPath.addOval( | |
| Rect.fromCenter(center: cloudCenter.translate(-iconSize * 0.3, 0), width: iconSize * 0.5, height: iconSize * 0.4), | |
| ); | |
| cloudPath.addOval( | |
| Rect.fromCenter( | |
| center: cloudCenter.translate(0, -iconSize * 0.15), | |
| width: iconSize * 0.6, | |
| height: iconSize * 0.5, | |
| ), | |
| ); | |
| cloudPath.addOval( | |
| Rect.fromCenter(center: cloudCenter.translate(iconSize * 0.3, 0), width: iconSize * 0.5, height: iconSize * 0.4), | |
| ); | |
| canvas.drawPath(cloudPath, fillPaint); | |
| canvas.drawPath(cloudPath, paint); | |
| // Upload arrow | |
| final arrowPath = | |
| Path() | |
| ..moveTo(center.dx, iconY + iconSize * 0.35) | |
| ..lineTo(center.dx, iconY + iconSize * 0.05) | |
| ..moveTo(center.dx - iconSize * 0.15, iconY + iconSize * 0.2) | |
| ..lineTo(center.dx, iconY + iconSize * 0.05) | |
| ..lineTo(center.dx + iconSize * 0.15, iconY + iconSize * 0.2); | |
| canvas.drawPath(arrowPath, paint); | |
| } | |
| @override | |
| bool shouldRepaint(DropZoneOverlayPainter oldDelegate) => false; | |
| @override | |
| bool shouldRebuildSemantics(DropZoneOverlayPainter oldDelegate) => false; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:async'; | |
| import 'dart:js_interop'; | |
| import 'dart:typed_data' show Uint8List; | |
| import 'package:path/path.dart' as p; | |
| import 'package:web/web.dart' as web; | |
| import 'dart:typed_data' show Uint8List; | |
| /// Returns a stream of drag-and-drop events occurring on the web page. | |
| Stream<DropZoneEvent> $dropZoneStream({ | |
| // Max number of files to accept | |
| required int limit, | |
| // Max file size in bytes | |
| required int size, | |
| // Accepted file extensions | |
| required Set<String> extensions, | |
| }) { | |
| var closed = false; // To ensure we only close once | |
| var tearDown = () {}; // Cleanup function | |
| // Print message to console if in debug mode | |
| void print(String message) { | |
| assert(() { | |
| web.console.log(message.toJS); | |
| return true; | |
| }(), 'No-op in release mode'); | |
| } | |
| // Register a cleanup function to be called on stream cancellation | |
| void onCleanUp(void Function() fn) { | |
| final $tearDown = tearDown; | |
| tearDown = () { | |
| try { | |
| fn(); | |
| } on Object { | |
| print('Error during tearDown'); | |
| } finally { | |
| $tearDown(); | |
| } | |
| }; | |
| } | |
| // Create the stream controller with onCancel to clean up | |
| final controller = | |
| StreamController<DropZoneEvent>() | |
| ..onCancel = () { | |
| tearDown(); | |
| }; | |
| onCleanUp(() { | |
| if (!controller.isClosed) controller.close().ignore(); | |
| tearDown = () {}; | |
| }); | |
| int dragDepth = 0; // To track nested drag events | |
| // Add event to the stream | |
| void add(DropZoneEvent event) { | |
| if (closed || controller.isClosed) return; | |
| controller.add(event); | |
| } | |
| // Add error to the stream | |
| void addError(Object error, [StackTrace? stackTrace]) { | |
| if (closed || controller.isClosed) return; | |
| controller.addError(error, stackTrace); | |
| } | |
| // Check if the file is accepted based on size and extension | |
| bool isAcceptedFile(web.File file) { | |
| print('Checking file: ${file.name}, size: ${file.size}'); | |
| // Check file size | |
| if (file.size case int value when value <= 0 || value > size) return false; | |
| // Check file extension | |
| final ext = p.extension(file.name).trim().toLowerCase(); | |
| if (ext.isEmpty) return false; | |
| if (!extensions.contains(ext.substring(1))) return false; | |
| print('Accepted file: ${file.name}, size: ${file.size}'); | |
| return true; | |
| } | |
| // Convert a web.File to bytes (Uint8List) | |
| Future<Uint8List> fileBytes(web.File file) async { | |
| final buffer = await file.arrayBuffer().toDart; | |
| return Uint8List.view(buffer.toDart); | |
| } | |
| // Called when a drag event enters the page | |
| final onDragEnter = | |
| (web.Event e) { | |
| if (!e.isA<web.DragEvent>()) return; | |
| final de = e as web.DragEvent; | |
| if (de.dataTransfer == null) return; | |
| de.preventDefault(); | |
| de.stopPropagation(); | |
| dragDepth++; | |
| add(DropZoneEvent$Enter(dx: de.clientX, dy: de.clientY)); | |
| print('Drag enter, depth: $dragDepth'); | |
| }.toJS; | |
| // Called when a drag event is over the page | |
| // Can be called multiple times per drag | |
| final onDragOver = | |
| (web.Event e) { | |
| if (!e.isA<web.DragEvent>()) return; | |
| final de = e as web.DragEvent; | |
| if (de.dataTransfer == null) return; | |
| de.preventDefault(); | |
| de.stopPropagation(); | |
| de.dataTransfer?.dropEffect = 'copy'; | |
| add(DropZoneEvent$Over(dx: de.clientX, dy: de.clientY)); | |
| print('Drag over, depth: $dragDepth'); | |
| }.toJS; | |
| // Called when a drag event leaves the page | |
| final onDragLeave = | |
| (web.Event e) { | |
| if (!e.isA<web.DragEvent>()) return; | |
| final de = e as web.DragEvent; | |
| de.preventDefault(); | |
| de.stopPropagation(); | |
| dragDepth = (dragDepth - 1).clamp(0, 1 << 20); | |
| if (dragDepth == 0) add(DropZoneEvent$Leave(dx: de.clientX, dy: de.clientY)); | |
| print('Drag leave, depth: $dragDepth'); | |
| }.toJS; | |
| // Called when a drop event occurs on the page | |
| // Files can be accessed via dataTransfer.files or dataTransfer.items | |
| // Can have multiple items, some of which may not be files (e.g. text) | |
| // We only accept files that match the criteria (size, extension) | |
| final onDrop = | |
| (web.Event e) { | |
| if (!e.isA<web.DragEvent>()) return; | |
| final de = e as web.DragEvent; | |
| de.preventDefault(); | |
| de.stopPropagation(); | |
| dragDepth = 0; | |
| final web.DragEvent(clientX: dx, clientY: dy, dataTransfer: dt) = de; | |
| if (dt == null) { | |
| add(DropZoneEvent$Over(dx: de.clientX, dy: de.clientY)); | |
| return; | |
| } | |
| final web.DataTransfer(:items, :files) = dt; | |
| final result = <web.File>[]; | |
| if (items.length > 0) { | |
| print('Drop items: ${items.length}'); | |
| for (var i = 0; i < items.length; i++) { | |
| if (result.length >= limit) break; | |
| try { | |
| final item = items[i]; | |
| if (item.kind == 'file') { | |
| final f = item.getAsFile(); | |
| if (f == null || !isAcceptedFile(f)) { | |
| print('Ignoring invalid file item: ${item.kind}'); | |
| continue; | |
| } | |
| result.add(f); | |
| } else { | |
| // Handle non-file items if needed | |
| print('Ignoring non-file item: ${item.kind}'); | |
| } | |
| } on Object { | |
| print('Error processing dropped item #$i'); | |
| } | |
| } | |
| if (result.isEmpty) print('No valid items dropped'); | |
| } else if (files.length > 0) { | |
| //print('Drop files: ${files.length}'.toJS); | |
| for (var i = 0; i < files.length; i++) { | |
| if (result.length >= limit) break; | |
| try { | |
| final f = files.item(i); | |
| if (f == null || !isAcceptedFile(f)) { | |
| print('Ignoring invalid file: ${f?.name}'); | |
| continue; | |
| } | |
| result.add(f); | |
| } on Object { | |
| print('Error processing dropped file #$i'); | |
| } | |
| } | |
| if (result.isEmpty) print('No valid files dropped'); | |
| } else { | |
| //print('No valid items or files dropped'.toJS); | |
| } | |
| // If no valid files, send empty drop event | |
| if (result.isEmpty) { | |
| add(DropZoneEvent$Drop(dx: de.clientX, dy: de.clientY, items: const {})); | |
| return; | |
| } | |
| Future.wait<MapEntry<String, Uint8List>>([ | |
| for (final file in result) fileBytes(file).then((b) => MapEntry(file.name, b)), | |
| ]).then(Map.fromEntries).then((items) => DropZoneEvent$Drop(dx: dx, dy: dy, items: items)).then(add).ignore(); | |
| }.toJS; | |
| // Listen for events | |
| controller.onListen = () async { | |
| final root = web.document.documentElement ?? web.document.body ?? web.document; | |
| // Add root listeners | |
| try { | |
| final listeners = <String, JSExportedDartFunction>{ | |
| 'dragenter': onDragEnter, | |
| 'dragover': onDragOver, | |
| 'dragleave': onDragLeave, | |
| 'drop': onDrop, | |
| }; | |
| for (final MapEntry(:key, :value) in listeners.entries) { | |
| try { | |
| // Add listener | |
| root.addEventListener(key, value); | |
| // Clean up | |
| onCleanUp(() { | |
| root.removeEventListener(key, value); | |
| }); | |
| } on Object { | |
| print('Error adding event listener $key'); | |
| } | |
| } | |
| } on Object catch (e, s) { | |
| addError(e, s); | |
| tearDown(); | |
| return; | |
| } | |
| // Add window listeners | |
| try { | |
| final listeners = <String, JSExportedDartFunction>{'dragover': onDragOver, 'drop': onDrop}; | |
| for (final MapEntry(:key, :value) in listeners.entries) { | |
| try { | |
| // Add listener | |
| web.window.addEventListener(key, value); | |
| // Clean up | |
| onCleanUp(() { | |
| web.window.removeEventListener(key, value); | |
| }); | |
| } on Object { | |
| print('Error adding window event listener $key'); | |
| } | |
| } | |
| } on Object catch (e, s) { | |
| addError(e, s); | |
| tearDown(); | |
| return; | |
| } | |
| // Ensure we only close once | |
| onCleanUp(() { | |
| if (closed) return; | |
| closed = true; | |
| }); | |
| // Initial log | |
| print('Drag and drop initialized'); | |
| }; | |
| return controller.stream; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'drop_zone.dart' show DropZoneEvent; | |
| /// Returns an empty stream as drag-and-drop is not supported on this platform. | |
| Stream<DropZoneEvent> $dropZoneStream({ | |
| // Max number of files to accept | |
| required int limit, | |
| // Max file size in bytes | |
| required int size, | |
| // Accepted file extensions | |
| required Set<String> extensions, | |
| }) => const Stream<DropZoneEvent>.empty(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment