likes
comments
collection
share

Flutter 封装:文档上传组件 AssetUploadDocumentBox

作者站长头像
站长
· 阅读数 49

一、需求来源

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

支持功能:

  1. 去重,如果已经上传文档,二次选择会直接过滤,防止重复文档多次上传;
  2. 默认 2M 以下不显示进度,因为进度是跳闪;
  3. 支持文档点击方法,点击进行文档预览;

效果如下:

选择

Flutter 封装:文档上传组件 AssetUploadDocumentBox

二、使用示例

var readOnly = false;

final isUploadingDoc = ValueNotifier(false);

final selectedModelsDocVN = ValueNotifier<List<AssetUploadDocumentModel>>([]);


AeUploadDocumentItem(
  enable: !readOnly,
  maxCount: 30,
  selectedModels: selectedModelsDocVN.value,
  // imgUrlsVN: null,
  onUpload: (list) {
    selectedModelsDocVN.value = list;
    setState(() {});
  },
  isUploading: isUploadingDoc,
  header: const NSectionHeader(
    title: '文档上传(最多9个)',
    isRequired: false,
  ),
  // footer: const SizedBox(height: 15),
),

三、源码

/// 文档选择器模型
class AssetUploadDocumentModel {
  AssetUploadDocumentModel({
    this.url,
    this.file,
  });

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

  /// 文件
  File? file;

  Map<String, dynamic> toJson() {
    final data = Map<String, dynamic>();
    data['url'] = url;
    data['file'] = file;
    return data;
  }
}
/// 文档选择 FilePickerMixin
mixin FilePickerMixin<T extends StatefulWidget> on State<T> {
  /// 选择(文档)文件
  Future<List<File>> onPickerFiles({
    int maxMB = 28,
    bool allowMultiple = true,
  }) async {
    bool isGranted = await PermissionUtil.checkDocument();
    if (!isGranted) {
      return <File>[];
    }

    FilePickerResult? result = await FilePicker.platform.pickFiles(
      type: FileType.custom,
      allowMultiple: allowMultiple,
      allowedExtensions: [
        'doc',
        'docx',
        'xls',
        'xlsx',
        'ppt',
        'pptx',
        'pdf',
      ],
    );
    if (result == null) {
      return <File>[];
    }
    List<File> files = result.paths
        .where((e) => e != null)
        .whereType<String>()
        .map((path) => File(path))
        .toList();
    return files;
  }
}

1、AssetUploadBox 源码,整个图片区域

//
//  AssetUploadDocumentBox.dart
//  flutter_templet_project
//
//  Created by shang on 2024/6/30 08:37.
//  Copyright © 2024/6/30 shang. All rights reserved.
//


import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_templet_project/basicWidget/upload_document/asset_upload_document_button.dart';
import 'package:flutter_templet_project/basicWidget/upload_document/asset_upload_document_model.dart';
import 'package:flutter_templet_project/extension/string_ext.dart';
import 'package:flutter_templet_project/extension/widget_ext.dart';
import 'package:flutter_templet_project/mixin/file_picker_mixin.dart';
import 'package:flutter_templet_project/util/color_util.dart';
import 'package:flutter_templet_project/util/tool_util.dart';
import 'package:flutter_templet_project/vendor/toast_util.dart';

/// 上传文档组件(基于 file_picker)
class AssetUploadDocumentBox extends StatefulWidget {
  const AssetUploadDocumentBox({
    super.key,
    this.controller,
    required this.items,
    this.onChanged,
    this.maxCount = 9,
    this.rowCount = 4,
    this.spacing = 3,
    this.runSpacing = 3,
    this.radius = 4,
    this.canEdit = true,
    this.hasPlaceholder = true,
    this.showFileSize = false,
    this.onStart,
    this.onCancel,
    this.hasUrls = false,
  });

  /// 控制器
  final AssetUploadDocumentBoxController? controller;

  /// 默认显示
  final List<AssetUploadDocumentModel> items;

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

  /// 开始上传回调
  final ValueChanged<bool>? onStart;

  /// 取消
  final VoidCallback? onCancel;

  /// 做大个数
  final int maxCount;

  /// 每行个数
  final int rowCount;

  /// 水平间距
  final double spacing;

  /// 垂直间距
  final double runSpacing;

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

  /// 可以编辑
  final bool canEdit;

  /// 是否有占位图(不可编辑时占位图不可点击)
  final bool hasPlaceholder;

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

  // 但是是本地选择上传oss的,可以在相册里面回显。档案编辑不能回显
  final bool hasUrls;

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

class AssetUploadDocumentBoxState extends State<AssetUploadDocumentBox>
    with FilePickerMixin {
  late final List<AssetUploadDocumentModel> selectedModels = [];

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

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

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

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

  @override
  Widget build(BuildContext context) {
    return photoSection(
      items: selectedModels,
      maxCount: widget.maxCount,
      rowCount: widget.rowCount,
      spacing: widget.spacing,
      runSpacing: widget.runSpacing,
      radius: widget.radius,
      canEdit: widget.canEdit,
    );
  }

  photoSection({
    List<AssetUploadDocumentModel> items = const [],
    int maxCount = 9,
    int rowCount = 4,
    double spacing = 10,
    double runSpacing = 10,
    double radius = 4,
    bool canEdit = true,
  }) {
    return LayoutBuilder(builder: (context, constraints) {
      final maxWidth = constraints.maxWidth;
      final itemWidth = (maxWidth - spacing * (rowCount - 1)) / rowCount;
      return Wrap(
        spacing: spacing,
        runSpacing: runSpacing,
        children: [
          ...items.map((e) {
            return ClipRRect(
              borderRadius: BorderRadius.circular(radius),
              child: SizedBox(
                width: itemWidth,
                height: itemWidth,
                child: InkWell(
                  onTap: () {
                    if (e.url?.startsWith("http") != true) {
                      ToastUtil.show("文件链接失效");
                      return;
                    }
                    final fileName = e.url?.split("/").last ?? "";
                    final filUrl = e.url ?? "";
                    ToolUtil.filePreview(fileName, filUrl);
                  },
                  child: AssetUploadDocumentButton(
                    model: e,
                    width: itemWidth,
                    height: itemWidth,
                    radius: radius,
                    canEdit: canEdit,
                    urlBlock: (url) {
                      final isAllFinished =
                          items.where((e) => e.url == null).isEmpty;
                      if (isAllFinished) {
                        widget.onChanged?.call(items);
                        isAllUploadFinished.value = true;
                      }
                    },
                    onDelete: () {
                      items.remove(e);
                      setState(() {});
                      widget.onChanged?.call(items);
                    },
                    showFileSize: widget.showFileSize,
                  ),
                ),
              ),
            );
          }).toList(),
          if (items.length < maxCount && widget.hasPlaceholder)
            InkWell(
              onTap: () async {
                ToolUtil.removeInputFocus();
                if (!canEdit) {
                  debugPrint("无图片编辑权限");
                  return;
                }
                onPicker(maxCount: maxCount);
              },
              child: Container(
                width: itemWidth,
                height: itemWidth,
                alignment: Alignment.center,
                decoration: BoxDecoration(
                  color: bgColorF9F9F9,
                  borderRadius: BorderRadius.circular(radius),
                ),
                child: Image(
                  image: 'assets/images/icon_upload.png'.toAssetImage(),
                  width: 24,
                  height: 24,
                ),
              ),
            )
        ],
      );
    });
  }

  /// 选择
  Future<void> onPicker({
    int maxCount = 9,
    int maxMB = 28,
  }) async {
    try {
      List<File> files = await onPickerFiles(maxMB: maxMB);
      // for (final file in files) {
      //   if (file.lengthSync() >= maxMB * 1024 * 1024) {
      //     ToastUtil.show("单个文件大小不得超出${maxMB}M");
      //     continue;
      //   }
      // }
      if (files.isEmpty) {
        debugPrint("没有添加新图片");
        widget.onCancel?.call();
        return;
      }

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

      /// 添加文件前的数量
      final selectedModelsLength = selectedModels.length;

      for (final e in files) {
        if (!selectedModels.map((e) => e.file?.path).contains(e.path)) {
          selectedModels.add(AssetUploadDocumentModel(file: e));
        }
      }

      if (selectedModelsLength == selectedModels.length) {
        ToastUtil.show("文件已添加,请勿重复添加");
        return;
      }

      if (selectedModels.length > maxCount) {
        selectedModels.removeRange(0, selectedModels.length - maxCount);
      }

      // debugPrint(
      //     "selectedEntities:${selectedEntities.length} ${selectedModels.length}");
      setState(() {});
    } catch (err) {
      debugPrint("err:$err");
      // EasyToast.showToast('$err');
      showToast(message: '$err');
    }
  }

  showToast({required String message}) {
    Text(message).toShowCupertinoDialog(context: context);
  }
}

/// AssetUploadDocumentBox 组件控制器
class AssetUploadDocumentBoxController {
  AssetUploadDocumentBoxState? _anchor;

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

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

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

2、AssetUploadButton 源码,子组件

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

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_templet_project/basicWidget/n_text.dart';
import 'package:flutter_templet_project/basicWidget/upload_document/asset_upload_document_model.dart';
import 'package:flutter_templet_project/enum/FileType.dart';
import 'package:flutter_templet_project/extension/num_ext.dart';
import 'package:flutter_templet_project/extension/string_ext.dart';
import 'package:flutter_templet_project/network/oss/oss_util.dart';
import 'package:get/get.dart';

/// 上传文档子项
class AssetUploadDocumentButton extends StatefulWidget {
  const AssetUploadDocumentButton({
    super.key,
    required this.model,
    this.width,
    this.height,
    this.radius = 4,
    this.urlBlock,
    this.onDelete,
    this.canEdit = true,
    this.showFileSize = false,
  });

  final AssetUploadDocumentModel model;

  /// 宽度
  final double? width;

  /// 高度
  final double? height;

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

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

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

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

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

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

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

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

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

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

  String? get fileName => filePath?.split("/").last;

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

    onRefresh();
  }

  @override
  void didUpdateWidget(covariant AssetUploadDocumentButton oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.model.file?.path == oldWidget.model.file?.path ||
        widget.model.url == oldWidget.model.url) {
      // EasyToast.showInfoToast("path相同");
      return;
    }
    onRefresh();
  }

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

    final fileName =
        (widget.model.file?.path ?? widget.model.url)?.split("/").last ?? "";
    final fileType = IMFileType.values
            .firstWhereOrNull((e) => fileName.endsWith(e.name) == true) ??
        IMFileType.unknow;
    Widget img = FractionallySizedBox(
      heightFactor: 0.75,
      child: Image(
        image: fileType.iconName.toAssetImage(),
        width: widget.width,
        height: widget.height,
      ),
    );

    var imgChild = ClipRRect(
      borderRadius: BorderRadius.circular(widget.radius),
      child: img,
    );

    return Stack(
      fit: StackFit.expand,
      children: [
        imgChild,
        if (widget.model.url?.startsWith("http") == false)
          Positioned(
            top: 0,
            right: 0,
            bottom: 0,
            left: 0,
            child: buildUploading(),
          ),
        if (widget.showFileSize)
          Positioned(
            top: 0,
            right: 0,
            bottom: 0,
            left: 0,
            child: buildFileSizeInfo(
              length: widget.model.file?.lengthSync(),
            ),
          ),
        if (widget.canEdit)
          Positioned(
            top: 0,
            right: 0,
            child: buildDelete(),
          ),
      ],
    );
  }

  /// 右上角删除按钮
  Widget buildDelete() {
    if (widget.onDelete == null) {
      return const SizedBox();
    }
    return Container(
      width: widget.width! * .26,
      height: widget.width! * .26,
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: Colors.black.withOpacity(.3),
        borderRadius: BorderRadius.only(
          topRight: Radius.circular(widget.radius),
          bottomLeft: Radius.circular(widget.radius),
        ),
      ),
      child: IconButton(
        padding: EdgeInsets.zero,
        constraints: const BoxConstraints(),
        onPressed: widget.onDelete,
        icon: Icon(Icons.close, size: widget.width! * .15, color: Colors.white),
      ),
    );
  }

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

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

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

        return Container(
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: Colors.black45,
            borderRadius: BorderRadius.all(Radius.circular(widget.radius)),
          ),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              // if (value <= 0)NText(
              //   data: "处理中",
              //   fontSize: 14,
              //   color: Colors.white,
              // ),
              NText(
                desc,
                fontSize: 12,
                color: Colors.white,
              ),
            ],
          ),
        );
      },
    );
  }

  Widget buildUploadFail() {
    return Stack(
      children: [
        InkWell(
          onTap: () {
            debugPrint("onTap");
            onRefresh();
          },
          child: Container(
            alignment: Alignment.center,
            decoration: BoxDecoration(
              color: Colors.black45,
              borderRadius: BorderRadius.all(Radius.circular(widget.radius)),
            ),
            child: const Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(Icons.refresh, color: Colors.red),
                NText(
                  "点击重试",
                  fontSize: 14,
                  color: Colors.white,
                ),
              ],
            ),
          ),
        ),
        Positioned(
          top: 0,
          right: 0,
          child: buildDelete(),
        ),
      ],
    );
  }

  Future<String?> uploadFile({
    required String path,
  }) async {
    var res = await OssUtil.upload(
      filePath: path,
      onSendProgress: (int count, int total) {
        final percent = (count / total);
        _percentVN.value = percent.clamp(0, 0.99); // dio 上传进度和返回 url 有时间差
      },
      onReceiveProgress: (int count, int total) {
        _percentVN.value = 1;
      },
      needCompress: false,
    );
    if (res?.startsWith("http") == true) {
      // debugPrint("res: $res");
      _percentVN.value = 1;
      return res;
    }
    return null;
  }

  onRefresh() {
    // debugPrint("onRefresh ${widget.entity}");
    final entityFile = widget.model.file;
    if (entityFile == null || widget.model.url?.startsWith("http") == true) {
      return;
    }

    if (_isLoading) {
      debugPrint("_isLoading: $_isLoading ${widget.model.file?.path}");
      return;
    }

    _isLoading = true;
    _successVN.value = true;

    setState(() {});

    final path = entityFile.path;
    // return "";//调试代码,勿删!!!
    uploadFile(
      path: path,
    ).then((value) {
      final url = value;
      if (url == null || url.isEmpty) {
        _successVN.value = false;
        throw "上传失败 ${widget.model.file?.path}";
      }
      _successVN.value = true;
      widget.model.url = url;
    }).catchError((err) {
      debugPrint("err: $err");
      widget.model.url = "";
      _successVN.value = false;

      setState(() {});
    }).whenComplete(() {
      _isLoading = false;
      // LogUtil.d("${fileName}_whenComplete");
      widget.urlBlock?.call(widget.model.url ?? "");
    });
  }

  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: Container(color: Colors.red, child: Text(desc)));
  }

  @override
  bool get wantKeepAlive => true;
}

4、CacheAssetService 源码,媒体缓存工具类

dart
复制代码
import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';

///缓存媒体文件
class CacheAssetService {
  CacheAssetService._();

  static final CacheAssetService _instance = CacheAssetService._();

  factory CacheAssetService() => _instance;


  Directory? _dir;

  Future<Directory> getDir() async {
    if (_dir != null) {
      return _dir!;
    }
    Directory tempDir = await getTemporaryDirectory();
    Directory targetDir = Directory('${tempDir.path}/asset');
    if (!targetDir.existsSync()) {
      targetDir.createSync();
      debugPrint('targetDir 路径为 ${targetDir.path}');
    }
    _dir = targetDir;
    return targetDir;
  }

  /// 清除缓存文件
  Future<void> clearDirCache() async {
    final dir = await getDir();
    await deleteDirectory(dir);
  }

  /// 递归方式删除目录
  Future<void> deleteDirectory(FileSystemEntity? file) async {
    if (file == null) {
      return;
    }

    if (file is Directory) {
      final List<FileSystemEntity> children = file.listSync();
      for (final FileSystemEntity child in children) {
        await deleteDirectory(child);
      }
    }
    await file.delete();
  }

}

总结

1、文档选择使用的是 file_picker,无法限制最大文档数量,可以通过给提示信息的方式提醒用户;
2、文档预览,ToolUtil.filePreview 是自己封装的文档查看组件,这个可以更换为自己的
final fileName = e.url?.split("/").last ?? "";
final filUrl = e.url ?? "";
ToolUtil.filePreview(fileName, filUrl);

3、未解决的问题

  • 如何限制最大选择数量?
  • 文档是否有好的压缩库?

github

转载自:https://juejin.cn/post/7385483271701020735
评论
请登录