Flutter 封装:基于 file_picker 的上传组件 NFileUploadBox

301 阅读10分钟

一、需求来源

最近遇到上传文档附件的需求,基于 file_picker 显示上传百分比,支持失败重连,支持失败删除;每个文档上传成功之后都会进行 url 回调。

支持功能:

  1. 去重,如果已经上传文档,二次选择会直接过滤,防止重复文档多次上传;;
  2. 点击支持音频播放、视频播放、文档预览;

效果如下:

ezgif.com-video-to-gif-converter.gif

二、使用示例

final fileUploadBoxController = NFileUploadBoxController();
final isAllUploadFinished = ValueNotifier(false);

var selectedFiles = [].map((e) => NFileUploadModel(url: e)).toList();

//...

Container(
  margin:
      const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
  padding: const EdgeInsets.all(12),
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.all(Radius.circular(8)),
  ),
  child: NFileUploadBox(
    controller: fileUploadBoxController,
    title: "上传音视频文件",
    description: "支持格式...."
        ",单个文件大小不超过100m,最多支持10个文件",
    maxCount: 9,
    allowedExtensions: [
      ...NFileType.audio.exts,
      ...NFileType.video.exts,
      ...NFileType.doc.exts,
      ...NFileType.excel.exts,
      ...NFileType.ppt.exts,
      ...NFileType.pdf.exts,
    ],
    items: selectedFiles,
    // showFileSize: true,
    onChanged: (List<NFileUploadModel> value) {
      selectedFiles = value;
      YLog.d("$widget selectedFiles: $selectedFiles");
      isAllUploadFinished.value = true;
    },
    onCancel: () {
      isAllUploadFinished.value = true;
    },
    onStart: () {
      isAllUploadFinished.value = false;
    },
  ),
),
[log] DLog 2024-09-07 10:19:14.092997 UploadPageDemo selectedFiles: [{"url":"https://yl-prescription-share.oss-cn-beijing.aliyuncs.com/beta/gcp/测试视频1.MOV","file":{"path":"/Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/测试视频1.MOV","name":"测试视频1.MOV","size":2286856,"identifier":"file:///Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/%E6%B5%8B%E8%AF%95%E8%A7%86%E9%A2%911.MOV"}}, {"url":"https://yl-prescription-share.oss-cn-beijing.aliyuncs.com/beta/gcp/陈一发儿 - 童话镇_副本.mp3","file":{"path":"/Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/陈一发儿 - 童话镇_副本.mp3","name":"陈一发儿 - 童话镇_副本.mp3","size":10405608,"identifier":"file:///Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/%E9%99%88%E4%B8%80%E5%8F%91%E5%84%BF%20-%20%E7%AB%A5%E8%AF%9D%E9%95%87_%E5%89%AF%E6%9C%AC.mp3"}}, {"url":"https://yl-prescription-share.oss-cn-beijing.aliyuncs.com/beta/gcp/Flutter闲鱼最佳实践.pdf","file":{"path":"/Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/Flutter闲鱼最佳实践.pdf","name":"Flutter闲鱼最佳实践.pdf","size":21863874,"identifier":"file:///Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/Flutter%E9%97%B2%E9%B1%BC%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5.pdf"}}, {"url":"https://yl-prescription-share.oss-cn-beijing.aliyuncs.com/beta/gcp/研发tapd培训.pptx","file":{"path":"/Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/研发tapd培训.pptx","name":"研发tapd培训.pptx","size":2982153,"identifier":"file:///Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/%E7%A0%94%E5%8F%91tapd%E5%9F%B9%E8%AE%AD.pptx"}}, {"url":"https://yl-prescription-share.oss-cn-beijing.aliyuncs.com/beta/gcp/假设房价按年5%增幅.xlsx","file":{"path":"/Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/假设房价按年5%增幅.xlsx","name":"假设房价按年5%增幅.xlsx","size":12897,"identifier":"file:///Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/%E5%81%87%E8%AE%BE%E6%88%BF%E4%BB%B7%E6%8C%89%E5%B9%B45%25%E5%A2%9E%E5%B9%85.xlsx"}}, {"url":"https://yl-prescription-share.oss-cn-beijing.aliyuncs.com/beta/gcp/手机APP个税年度汇算操作流程2021042002.docx","file":{"path":"/Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/手机APP个税年度汇算操作流程2021042002.docx","name":"手机APP个税年度汇算操作流程2021042002.docx","size":2653921,"identifier":"file:///Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/%E6%89%8B%E6%9C%BAAPP%E4%B8%AA%E7%A8%8E%E5%B9%B4%E5%BA%A6%E6%B1%87%E7%AE%97%E6%93%8D%E4%BD%9C%E6%B5%81%E7%A8%8B2021042002.docx"}}, {"url":"https://yl-prescription-share.oss-cn-beijing.aliyuncs.com/beta/gcp/图数据库neo4j实战.pdf","file":{"path":"/Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/图数据库neo4j实战.pdf","name":"图数据库neo4j实战.pdf","size":1948037,"identifier":"file:///Users/shang/Library/Developer/CoreSimulator/Devices/0A26CC53-8AE9-4170-8BE3-9A4ECC103E32/data/Containers/Data/Application/93841387-5D9C-477F-AAAB-E43DB919E2EE/tmp/%E5%9B%BE%E6%95%B0%E6%8D%AE%E5%BA%93neo4j%E5%AE%9E%E6%88%98.pdf"}}]

三、源码

1、上传模型源码

/// 文件
class NFileUploadModel {
  NFileUploadModel({
    this.url,
    this.assetFile,
  });

  /// 上传之后的文件 url
  String? url;

  /// 本地选择的文件实体
  NPickFile? assetFile;

  Map<String, dynamic> toJson() {
    final data = Map<String, dynamic>();
    data['url'] = url;
    data['file'] = assetFile?.toJson();
    return data;
  }

  @override
  String toString() {
    return jsonEncode(toJson());
  }
}

/// 文件上传选择实体
class NPickFile {
  NPickFile({
    required this.path,
    required this.name,
    required this.size,
    this.identifier,
  });

  final String path;
  final String name;
  final int size;
  final String? identifier;

  Map<String, dynamic> toJson() {
    final data = Map<String, dynamic>();
    data['path'] = path;
    data['name'] = name;
    data['size'] = size;
    data['identifier'] = identifier;
    return data;
  }

  static NPickFile fromPlatformFile(PlatformFile file) {
    return NPickFile(
      path: file.path ?? "",
      name: file.name,
      size: file.size,
      identifier: file.identifier,
    );
  }
}

2、NFileUploadBox 源码

//
//  NFileUploadBox.dart
//  projects
//
//  Created by shang on 2024/8/27 15:47.
//  Copyright © 2024/8/27 shang. All rights reserved.
//

import 'dart:async';
import 'dart:io';

import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:yl_ylgcp_app/extension/file_ext.dart';
import 'package:yl_ylgcp_app/extension/string_ext.dart';
import 'package:yl_ylgcp_app/routers/app_route.dart';
import 'package:yl_ylgcp_app/routers/navigator_util.dart';
import 'package:yl_ylgcp_app/theme/color.dart';
import 'package:yl_ylgcp_app/util/enum/permission_type_enum.dart';
import 'package:yl_ylgcp_app/util/phone_permission.dart';
import 'package:yl_ylgcp_app/util/tool_util.dart';
import 'package:yl_ylgcp_app/util/ylog.dart';
import 'package:yl_ylgcp_app/vender/easy_toast.dart';
import 'package:yl_ylgcp_app/vender/upload_file/n_file_upload_Item.dart';
import 'package:yl_ylgcp_app/vender/upload_file/n_file_upload_model.dart';
import 'package:yl_ylgcp_app/widget/n_pair.dart';
import 'package:yl_ylgcp_app/widget/n_text.dart';

/// 从文件存储系统选择文件
class NFileUploadBox extends StatefulWidget {
  const NFileUploadBox({
    super.key,
    this.controller,
    required this.items,
    this.title = "上传文件",
    this.description = "支持格式/单个文件大小限制/最大数量",
    required this.onChanged,
    this.onStart,
    this.onCancel,
    this.canEdit = true,
    this.showFileSize = false,
    this.maxMB = 100,
    this.maxCount = 9,
    this.type = FileType.custom,
    this.allowMultiple = true,
    required this.allowedExtensions,
    this.header,
    this.footer,
  });

  final NFileUploadBoxController? controller;

  final List<NFileUploadModel> items;
  final String title;
  final String description;

  /// 全部结束(有成功有失败 url="")或者删除完失败图片时会回调
  final ValueChanged<List<NFileUploadModel>> onChanged;

  /// 开始上传回调
  final VoidCallback? onStart;

  /// 取消
  final VoidCallback? onCancel;

  /// 可以编辑
  final bool canEdit;

  /// 做大个数
  final int maxCount;

  /// 最大尺寸
  final int maxMB;
  final FileType type;
  final bool allowMultiple;
  final List<String> allowedExtensions;

  /// 显示文件大小
  final bool showFileSize;
  final Widget? header;

  final Widget? footer;

  @override
  State<NFileUploadBox> createState() => _NFileUploadBoxState();
}

class _NFileUploadBoxState extends State<NFileUploadBox> {
  late final List<NFileUploadModel> selectedModels = [];

  /// 全部上传结束
  final isAllUploadFinished = ValueNotifier(false);

  @override
  void dispose() {
    widget.controller?._detach(this);
    super.dispose();
  }

  @override
  void initState() {
    widget.controller?._attach(this);
    selectedModels.addAll(widget.items);
    super.initState();
  }

  @override
  void didUpdateWidget(covariant NFileUploadBox oldWidget) {
    final entityIds = widget.items.map((e) => e.assetFile?.path).join(",");
    final oldWidgetEntityIds =
        oldWidget.items.map((e) => e.assetFile?.path).join(",");
    if (entityIds != oldWidgetEntityIds) {
      selectedModels
        ..clear()
        ..addAll(widget.items);
    }
    super.didUpdateWidget(oldWidget);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        widget.header ??
            Padding(
              padding: EdgeInsets.symmetric(vertical: 5),
              child: NText(
                data: widget.title,
                fontSize: 14,
                color: fontColor737373,
              ),
            ),
        ...selectedModels.map((e) {
          final index = selectedModels.indexOf(e);

          // final fileName = (e.assetFile?.path ?? "").split("/").last;
          return GestureDetector(
            onTap: () => onTapItem(e),
            child: NFileUploadItem(
              model: e,
              canEdit: widget.canEdit,
              urlBlock: (url) {
                final isAllFinished =
                    selectedModels.where((e) => e.url == null).isEmpty;
                // debugPrint("isAllFinished: ${isAllFinished}");
                if (isAllFinished) {
                  final urls = selectedModels.map((e) => e.url).toList();
                  debugPrint("isAllFinished urls: $urls");
                  widget.onChanged(selectedModels);
                  isAllUploadFinished.value = true;
                }
              },
              onDelete: widget.canEdit == false
                  ? null
                  : () {
                      debugPrint(
                          "onDelete: $index, length: ${selectedModels[index].assetFile?.path}");
                      selectedModels.remove(e);
                      setState(() {});
                      widget.onChanged(selectedModels);
                    },
              showFileSize: widget.showFileSize,
            ),
          );
        }).toList(),
        if (selectedModels.length < widget.maxCount && widget.canEdit)
          buildUploadButton(
            onPressed: () async {
              await onPickFile(
                maxMB: widget.maxMB,
                maxCount: widget.maxCount,
                type: widget.type,
                allowMultiple: widget.allowMultiple,
                allowedExtensions: widget.allowedExtensions,
                onPermission: () async {
                  //todo: 安卓权限
                  bool isGranted = await PhonePermission.checkDocument(
                    permissionType: PermissionTypeEnum.im,
                  );
                  return isGranted;
                },
              );
            },
          ),
        Offstage(
          offstage: !widget.canEdit,
          child: widget.footer ??
              Padding(
                padding: const EdgeInsets.only(top: 5.0),
                child: NText(
                  data: widget.description,
                  fontSize: 12,
                  color: fontColorB3B3B3,
                ),
              ),
        ),
      ],
    );
  }

  Widget buildUploadButton({required VoidCallback onPressed}) {
    return GestureDetector(
      onTap: onPressed,
      child: Container(
        width: double.infinity,
        height: 36,
        decoration: BoxDecoration(
          // color: bgColor,
          border: Border.all(color: lineColor),
          borderRadius: BorderRadius.all(Radius.circular(4)),
        ),
        child: NPair(
          icon: Image(
            image: "icon_upload_one.png".toAssetImage(),
            width: 16,
            height: 16,
          ),
          child: NText(
            data: "选择文件并上传",
            fontSize: 14,
            color: fontColorB3B3B3,
          ),
        ),
      ),
    );
  }

  /// 选择文件
  Future<void> onPickFile({
    int maxMB = 100,
    int maxCount = 9,
    FileType type = FileType.any,
    bool allowMultiple = true,
    required List<String> allowedExtensions,
    required FutureOr<bool> Function() onPermission,
  }) async {
    if (!await onPermission()) {
      return;
    }

    try {
      FilePickerResult? pickerResult = await FilePicker.platform.pickFiles(
        type: type,
        allowMultiple: allowMultiple,
        allowedExtensions: allowedExtensions,
      );

      final result = pickerResult?.files ?? [];
      final lengthBefore = result.length;
      YLog.d("result: 过滤前 ${result.length}");
      result.removeWhere((el) {
        final same =
            selectedModels.map((e) => e.assetFile?.path).contains(el.path);
        final result = same || el.size > maxMB * 1024 * 1024;
        return result;
      });
      final lengthAfter = result.length;
      YLog.d("result: 过滤后 ${result.length}");
      final filterCount = lengthBefore - lengthAfter;
      if (filterCount > 0) {
        EasyToast.showToast("已过滤 $filterCount 个无效文件");
      }

      if (result.isEmpty == true) {
        debugPrint("没有添加");
        widget.onCancel?.call();
        return;
      }

      widget.onStart?.call();
      isAllUploadFinished.value = false;

      for (final e in result) {
        if (!selectedModels.contains(e)) {
          selectedModels
              .add(NFileUploadModel(assetFile: NPickFile.fromPlatformFile(e)));
        }
      }

      if (selectedModels.length > maxCount) {
        selectedModels.removeRange(0, selectedModels.length - maxCount);
      }
      setState(() {});
    } catch (e) {
      debugPrint("$this $e");
    }
  }

  onTapItem(NFileUploadModel model) {
    if (model.url?.startsWith("http") == true) {
      final fileName = model.url?.split("/").last;
      switch (model.url!.fileType) {
        case NFileType.image:
          {
            final urls = selectedModels
                .where((e) => e.url?.startsWith("http") == true)
                .map((e) => e.url ?? "")
                .toList();
            final index = urls.indexOf(model.url ?? "");
            // debugPrint("urls: ${urls.length}, $index");
            FocusScope.of(context).unfocus();
            ToolUtil.imagePreview(urls, index);
          }
        case NFileType.video:
          {
            Get.toNamed(APPRouter.chewiePlayerPage, arguments: {
              "videoUrl": model.url,
              "videoTitle": fileName,
            });
          }
          break;
        case NFileType.audio:
          {
            Get.toNamed(APPRouter.audioPlayPage, arguments: {
              "url": model.url,
              "desc": fileName,
              "title": fileName,
            });
          }
          break;
        case NFileType.doc:
        case NFileType.excel:
        case NFileType.ppt:
        case NFileType.pdf:
          {
            FocusScope.of(context).unfocus();
            ToolUtil.openWebViewPage(model.url ?? "", title: fileName);
          }
          break;
        default:
          break;
      }
    }
  }
}

/// AssetUploadBox 组件控制器
class NFileUploadBoxController {
  _NFileUploadBoxState? _anchor;

  void _attach(_NFileUploadBoxState anchor) {
    _anchor = anchor;
  }

  void _detach(_NFileUploadBoxState anchor) {
    if (_anchor == anchor) {
      _anchor = null;
    }
  }

  /// 是否全部上传结束
  ValueNotifier<bool>? get isAllUploadFinished => _anchor?.isAllUploadFinished;
}

3、每个文件对应的子组件 NFileUploadItem 源码

//
//  NFileUploadItem.dart
//  yl_health_app
//
//  Created by shang on 2023/04/30 11:19.
//  Copyright © 2023/04/30 shang. All rights reserved.
//

import 'dart:io';
import 'dart:math';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:yl_ylgcp_app/extension/file_ext.dart';
import 'package:yl_ylgcp_app/extension/num_ext.dart';
import 'package:yl_ylgcp_app/http/oss/upload_oss.dart';
import 'package:yl_ylgcp_app/theme/color.dart';
import 'package:yl_ylgcp_app/vender/image_service.dart';
import 'package:yl_ylgcp_app/vender/upload_file/n_file_upload_model.dart';
import 'package:yl_ylgcp_app/vender/video_service.dart';
import 'package:yl_ylgcp_app/widget/n_text.dart';

/// 上传图片单元(基于 wechat_assets_picker)
class NFileUploadItem extends StatefulWidget {
  const NFileUploadItem({
    super.key,
    required this.model,
    this.radius = 4,
    this.urlBlock,
    this.onDelete,
    this.canEdit = true,
    this.showFileSize = false,
    this.borderColor = Colors.transparent,
  });

  final NFileUploadModel model;

  /// 圆角 默认8
  final double radius;

  /// 上传成功获取 url 回调
  final ValueChanged<String>? urlBlock;

  /// 返回删除元素的 id
  final VoidCallback? onDelete;

  /// 显示文件大小
  final bool showFileSize;

  /// 边框颜色
  final Color borderColor;

  /// 是否可编辑 - 删除
  final bool canEdit;

  @override
  NFileUploadItemState createState() => NFileUploadItemState();
}

class NFileUploadItemState extends State<NFileUploadItem>
    with AutomaticKeepAliveClientMixin {
  /// 防止触发多次上传动作
  var _isLoading = false;

  /// 请求成功或失败
  final _successVN = ValueNotifier(true);

  /// 上传进度
  final _percentVN = ValueNotifier(0.0);

  String? get filePath => widget.model.assetFile?.path;

  String? get fileName => widget.model.assetFile?.name;

  // bool? get only => widget.model.url?.startsWith("http") == true;

  @override
  void initState() {
    super.initState();

    onRefresh();
  }

  @override
  void didUpdateWidget(covariant NFileUploadItem oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.model.assetFile?.path == oldWidget.model.assetFile?.path ||
        widget.model.url?.startsWith("http") == true) {
      return;
    }
    onRefresh();
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);

    final fileName = widget.model.assetFile?.name ??
        widget.model.url?.split("/").last ??
        "--";

    final fileNameNew = fileName.split(".").firstOrNull ?? "--";
    final ext = fileName.split(".").lastOrNull ?? "";

    final nameWidget = Row(
      children: [
        Flexible(
          child: NText(
            data: fileNameNew,
            fontSize: 14,
            color: fontColor737373,
            maxLines: 1,
          ),
        ),
        NText(
          data: ".$ext",
          fontSize: 14,
          color: fontColor737373,
          maxLines: 1,
        ),
      ],
    );

    return Container(
      margin: const EdgeInsets.only(
        bottom: 9,
      ),
      padding: const EdgeInsets.only(
        left: 10,
        top: 9,
        bottom: 9,
        right: 12,
      ),
      decoration: const BoxDecoration(
        color: bgColor,
        // border: Border.all(color: Colors.blue),
        borderRadius: BorderRadius.all(Radius.circular(4)),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Flexible(
                child: GestureDetector(
                  child: nameWidget ??
                      NText(
                        data: fileName,
                        fontSize: 14,
                        color: fontColor737373,
                        maxLines: 1,
                      ),
                ),
              ),
              buildDelete(),
            ],
          ),
          if (widget.canEdit) buildUploading(),
          if (widget.showFileSize)
            buildFileSizeInfo(
              length: widget.model.assetFile?.size,
            ),
        ],
      ),
    );
  }

  /// 右上角删除按钮
  Widget buildDelete() {
    if (widget.onDelete == null) {
      return const SizedBox();
    }
    return GestureDetector(
      onTap: widget.onDelete,
      child: const Image(
        image: AssetImage(
          "assets/images/icon_trash.png",
        ),
        width: 12,
        height: 14,
      ),
    );
  }

  Widget buildUploading() {
    return AnimatedBuilder(
      animation: Listenable.merge([
        _successVN,
        _percentVN,
      ]),
      builder: (context, child) {
        // YLog.d("buildUploading ${fileName}: ${_percentVN.value}");
        if (_successVN.value == false) {
          return buildUploadFail();
        }
        final value = _percentVN.value;
        if (value >= 1) {
          return const SizedBox();
        }

        final showPercent = widget.model.assetFile != null &&
            (widget.model.assetFile!.size > 2 * 1024 * 1024) == true;

        final desc = showPercent ? value.toStringAsPercent(2) : "上传中";

        return Row(
          children: [
            Expanded(
              child: LinearProgressIndicator(
                value: _percentVN.value,
              ),
            ),
            Padding(
              padding: const EdgeInsets.only(left: 8.0),
              child: NText(
                data: desc,
                fontSize: 12,
              ),
            ),
          ],
        );
      },
    );
  }

  Widget buildUploadFail() {
    return InkWell(
      onTap: onRefresh,
      child: const Row(
        children: [
          // Icon(Icons.refresh, color: Colors.red),
          NText(
            data: "点击重试",
            fontSize: 14,
            color: Colors.red,
          ),
        ],
      ),
    );
  }

  Future<String?> uploadFile({
    required String path,
  }) async {
    var res = await OssUtil.upload(
      filePath: path,
      onSendProgress: (int count, int total) {
        final percent = (count / total);
        if (percent >= 0.99) {
          _percentVN.value = 0.99;
        } else {
          _percentVN.value = percent;
        }
      },
      onReceiveProgress: (int count, int total) {
        _percentVN.value = 1;
      },
    );
    _percentVN.value = 1;
    if (res != null) {
      // debugPrint("res: $res");
      return res;
    }
    return null;
  }

  onRefresh() {
    if (!widget.canEdit) {
      return;
    }
    // debugPrint("onRefresh ${widget.entity}");
    final path = widget.model.assetFile?.path;
    if (path == null || path.isEmpty) {
      throw "文件路径为空";
    }

    if (_isLoading) {
      debugPrint("_isLoading: $_isLoading ${widget.model.assetFile}");
      return;
    }

    _isLoading = true;
    _successVN.value = true;

    compressFile(
      file: File(path),
    ).then((file) {
      return uploadFile(path: file.path);
    }).then((value) {
      final url = value;
      if (url == null || url.isEmpty) {
        _successVN.value = false;
        throw "上传失败 ${widget.model.assetFile?.path}";
      }
      _successVN.value = true;
      widget.model.url = url;
    }).catchError((err) {
      debugPrint("err: $err");
      widget.model.url = "";
      _successVN.value = false;
    }).whenComplete(() {
      _isLoading = false;

      // LogUtil.d("${fileName}_whenComplete");
      widget.urlBlock?.call(widget.model.url ?? "");
    });
  }

  /// 压缩文件
  Future<File> compressFile({required File file}) async {
    final ext = file.path.split(".").last;
    if (NFileType.video.exts.contains(ext)) {
      return await VideoService.compressVideo(file, showToast: false);
    }
    if (NFileType.image.exts.contains(ext)) {
      return await ImageService().compressAndGetFile(file, needLogInfo: false);
    }
    return file;
  }

  Widget buildFileSizeInfo({required int? length}) {
    if (length == null) {
      return const SizedBox();
    }
    final result = length / (1024 * 1024);
    final desc = "${result.toStringAsFixed(2)}MB";
    return Align(child: Text(desc));
  }

  @override
  bool get wantKeepAlive => true;
}

4、文件类型支持

/// app 文件类型
enum NFileType {
  unknown('未知', []),
  image('图片', ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'heic']),
  video(
      '视频', ['mp4', 'avi', 'wmv', 'rmvb', 'mpg', 'mpeg', 'mov', '3gp', 'flv']),
  audio('音频', ['mp3', 'wav', 'wma', 'amr', 'ogg']),
  doc('word文档', ['doc', 'docx']),
  excel('excel文档', ['xls', 'xlsx']),
  ppt('ppt文档', ['ppt', 'pptx']),
  pdf('ppt文档', ['pdf']);

  const NFileType(this.message, this.exts);

  /// 描述
  final String message;

  /// 类型
  final List<String> exts;
}

extension FileExt on File {

  /// 获取文件类型
  NFileType get fileType => path.fileType;

  /// length 转为 MB 描述
  String get fileSizeDesc {
    final length = lengthSync();
    return length.fileSizeDesc;
  }

  /// 压缩质量
  int get compressQuality {
    final length = lengthSync();
    return length.compressQuality;
  }
}

extension FileStringExt on String {
  /// 获取文件类型
  NFileType get fileType {
    if (!contains(".")) {
      return NFileType.unknown;
    }

    final ext = split('.').last.toLowerCase();
    for (final e in NFileType.values) {
      if (e.exts.contains(ext)) {
        return e;
      }
    }
    return NFileType.unknown;
  }
}

总结

1、本文是 AssetUploadDocumentBox 组件的升级版;理论上支持所有的文件类型选择;
2、NFileUploadBox 中点击子组件跳转可以更换为自己的实现
3、未解决的问题
  • 如何限制最大选择数量?
  • 文档是否有好的压缩库?

github