271 lines
8.8 KiB
Dart
271 lines
8.8 KiB
Dart
|
|
// 封装文件:document_picker.dart
|
|||
|
|
// 说明:只支持从相册选图片(单张或多张)和选择文件(pdf/任意文件),不支持拍照。
|
|||
|
|
// 同时在内部封装了一个从底部弹出的选择框(“从相册获取”、“选择文件”、“取消”),
|
|||
|
|
// 并在调用相册选择前检查权限(若未授权会引导用户去设置)。
|
|||
|
|
|
|||
|
|
import 'dart:io';
|
|||
|
|
import 'dart:typed_data';
|
|||
|
|
|
|||
|
|
import 'package:flutter/foundation.dart';
|
|||
|
|
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:image_picker/image_picker.dart';
|
|||
|
|
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
|||
|
|
import 'package:photo_manager/photo_manager.dart';
|
|||
|
|
import 'package:file_picker/file_picker.dart';
|
|||
|
|
|
|||
|
|
/// 选中的文件统一结构
|
|||
|
|
class SelectedFile {
|
|||
|
|
final String name;
|
|||
|
|
final String? path; // 若无法直接拿到物理文件,则为 null
|
|||
|
|
final Uint8List? bytes; // 当 path 不可用时,可能通过 bytes 提供内容
|
|||
|
|
final int? size; // 字节数
|
|||
|
|
final String? mimeType; // 可选 mime
|
|||
|
|
final SourceType source;
|
|||
|
|
|
|||
|
|
SelectedFile({
|
|||
|
|
required this.name,
|
|||
|
|
this.path,
|
|||
|
|
this.bytes,
|
|||
|
|
this.size,
|
|||
|
|
this.mimeType,
|
|||
|
|
required this.source,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
bool get hasFile => path != null || bytes != null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
enum SourceType { gallery, assetPicker, filePicker }
|
|||
|
|
|
|||
|
|
class DocumentPicker {
|
|||
|
|
DocumentPicker._(); // 不可实例化,使用静态方法
|
|||
|
|
|
|||
|
|
static final ImagePicker _imagePicker = ImagePicker();
|
|||
|
|
|
|||
|
|
/// 检查并请求相册/媒体库权限。
|
|||
|
|
/// 返回 true 表示有权限可访问,false 表示拒绝或受限(limited 也视为可用)。
|
|||
|
|
static Future<bool> ensurePhotoPermission() async {
|
|||
|
|
final PermissionState ps = await PhotoManager.requestPermissionExtend();
|
|||
|
|
return ps.isAuth || ps == PermissionState.limited;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 单张从相册选择(image_picker)
|
|||
|
|
static Future<SelectedFile?> pickSingleImageFromGallery({int? maxSizeInBytes}) async {
|
|||
|
|
try {
|
|||
|
|
final XFile? xfile = await _imagePicker.pickImage(source: ImageSource.gallery, imageQuality: 90);
|
|||
|
|
if (xfile == null) return null;
|
|||
|
|
final File f = File(xfile.path);
|
|||
|
|
final int size = await f.length();
|
|||
|
|
if (maxSizeInBytes != null && size > maxSizeInBytes) return null;
|
|||
|
|
final bytes = await f.readAsBytes();
|
|||
|
|
return SelectedFile(
|
|||
|
|
name: xfile.name,
|
|||
|
|
path: xfile.path,
|
|||
|
|
bytes: bytes,
|
|||
|
|
size: size,
|
|||
|
|
mimeType: null,
|
|||
|
|
source: SourceType.gallery,
|
|||
|
|
);
|
|||
|
|
} catch (e) {
|
|||
|
|
debugPrint('pickSingleImageFromGallery error: $e');
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 多选图片(推荐使用 wechat_assets_picker)
|
|||
|
|
static Future<List<SelectedFile>> pickAssets({
|
|||
|
|
required BuildContext context,
|
|||
|
|
int maxAssets = 9,
|
|||
|
|
int? maxSizeInBytes,
|
|||
|
|
}) async {
|
|||
|
|
try {
|
|||
|
|
final hasAuth = await ensurePhotoPermission();
|
|||
|
|
if (!hasAuth) {
|
|||
|
|
// 未授权,返回空,需要调用者处理引导用户
|
|||
|
|
debugPrint('Photo permission denied');
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final List<AssetEntity>? result = await AssetPicker.pickAssets(
|
|||
|
|
context,
|
|||
|
|
pickerConfig: AssetPickerConfig(
|
|||
|
|
maxAssets: maxAssets,
|
|||
|
|
requestType: RequestType.image,
|
|||
|
|
specialPickerType: SpecialPickerType.noPreview,
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (result == null || result.isEmpty) return [];
|
|||
|
|
|
|||
|
|
final List<SelectedFile> out = [];
|
|||
|
|
for (final asset in result) {
|
|||
|
|
final File? file = await asset.file;
|
|||
|
|
if (file != null) {
|
|||
|
|
final int size = await file.length();
|
|||
|
|
if (maxSizeInBytes != null && size > maxSizeInBytes) continue;
|
|||
|
|
final bytes = await file.readAsBytes();
|
|||
|
|
out.add(SelectedFile(
|
|||
|
|
name: file.path.split('/').last,
|
|||
|
|
path: file.path,
|
|||
|
|
bytes: bytes,
|
|||
|
|
size: size,
|
|||
|
|
mimeType: null,
|
|||
|
|
source: SourceType.assetPicker,
|
|||
|
|
));
|
|||
|
|
} else {
|
|||
|
|
final Uint8List? originData = await asset.originBytes;
|
|||
|
|
if (originData == null) continue;
|
|||
|
|
final int size = originData.lengthInBytes;
|
|||
|
|
if (maxSizeInBytes != null && size > maxSizeInBytes) continue;
|
|||
|
|
out.add(SelectedFile(
|
|||
|
|
name: '${asset.id}.jpg',
|
|||
|
|
path: null,
|
|||
|
|
bytes: originData,
|
|||
|
|
size: size,
|
|||
|
|
mimeType: null,
|
|||
|
|
source: SourceType.assetPicker,
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return out;
|
|||
|
|
} catch (e) {
|
|||
|
|
debugPrint('pickAssets error: $e');
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 选择任意文件(如 pdf/doc 等)
|
|||
|
|
static Future<List<SelectedFile>> pickFiles({
|
|||
|
|
bool allowMultiple = false,
|
|||
|
|
List<String>? allowedExtensions,
|
|||
|
|
int? maxSizeInBytes,
|
|||
|
|
}) async {
|
|||
|
|
try {
|
|||
|
|
final FileType ft = (allowedExtensions == null) ? FileType.any : FileType.custom;
|
|||
|
|
|
|||
|
|
final FilePickerResult? result = await FilePicker.platform.pickFiles(
|
|||
|
|
allowMultiple: allowMultiple,
|
|||
|
|
type: ft,
|
|||
|
|
allowedExtensions: allowedExtensions,
|
|||
|
|
withData: true,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (result == null) return [];
|
|||
|
|
|
|||
|
|
final List<SelectedFile> out = [];
|
|||
|
|
for (final pf in result.files) {
|
|||
|
|
final int size = pf.size ?? (pf.bytes?.lengthInBytes ?? 0);
|
|||
|
|
if (maxSizeInBytes != null && size > maxSizeInBytes) continue;
|
|||
|
|
out.add(SelectedFile(
|
|||
|
|
name: pf.name,
|
|||
|
|
path: pf.path,
|
|||
|
|
bytes: pf.bytes,
|
|||
|
|
size: size,
|
|||
|
|
mimeType: pf.extension,
|
|||
|
|
source: SourceType.filePicker,
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return out;
|
|||
|
|
} catch (e) {
|
|||
|
|
debugPrint('pickFiles error: $e');
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 小工具:把 SelectedFile 转换为上传需要的 File (如果 path 可用),否则保存 bytes 到临时文件并返回 File
|
|||
|
|
static Future<File?> toFile(SelectedFile sf) async {
|
|||
|
|
try {
|
|||
|
|
if (sf.path != null) return File(sf.path!);
|
|||
|
|
if (sf.bytes != null) {
|
|||
|
|
final temp = await File('${Directory.systemTemp.path}/${sf.name}').create();
|
|||
|
|
await temp.writeAsBytes(sf.bytes!);
|
|||
|
|
return temp;
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
} catch (e) {
|
|||
|
|
debugPrint('toFile error: $e');
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 底部弹窗,用户从 "从相册获取" / "选择文件" / "取消" 中选择。
|
|||
|
|
/// 该方法会在用户选择后直接执行对应的选择逻辑并返回选中的文件列表。
|
|||
|
|
/// - maxAssets: 多选图片时的最大数量
|
|||
|
|
/// - maxSizeInBytes: 单个文件最大字节数限制
|
|||
|
|
/// - allowedExtensions: 选择文件时允许的扩展名(为空表示不限制)
|
|||
|
|
static Future<List<SelectedFile>> showPickerModal(
|
|||
|
|
BuildContext context, {
|
|||
|
|
int maxAssets = 9,
|
|||
|
|
int? maxSizeInBytes,
|
|||
|
|
List<String>? allowedExtensions,
|
|||
|
|
bool allowMultipleFiles = false,
|
|||
|
|
}) async {
|
|||
|
|
final choice = await showModalBottomSheet<String>(
|
|||
|
|
context: context,
|
|||
|
|
builder: (c) {
|
|||
|
|
return SafeArea(
|
|||
|
|
child: Column(
|
|||
|
|
mainAxisSize: MainAxisSize.min,
|
|||
|
|
children: [
|
|||
|
|
ListTile(
|
|||
|
|
title: const Center(child: Text('从相册获取')),
|
|||
|
|
onTap: () => Navigator.of(c).pop('gallery'),
|
|||
|
|
),
|
|||
|
|
const Divider(height: 1),
|
|||
|
|
ListTile(
|
|||
|
|
title: const Center(child: Text('选择文件')),
|
|||
|
|
onTap: () => Navigator.of(c).pop('file'),
|
|||
|
|
),
|
|||
|
|
const Divider(height: 1),
|
|||
|
|
ListTile(
|
|||
|
|
title: const Center(child: Text('取消')),
|
|||
|
|
onTap: () => Navigator.of(c).pop(null),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (choice == null) return [];
|
|||
|
|
|
|||
|
|
if (choice == 'gallery') {
|
|||
|
|
final hasAuth = await ensurePhotoPermission();
|
|||
|
|
if (!hasAuth) {
|
|||
|
|
// 未授权,提示并引导用户去设置
|
|||
|
|
await showDialog<void>(
|
|||
|
|
context: context,
|
|||
|
|
builder: (d) {
|
|||
|
|
return AlertDialog(
|
|||
|
|
title: const Text('权限未开启'),
|
|||
|
|
content: const Text('应用暂无相册权限,是否去设置开启?'),
|
|||
|
|
actions: [
|
|||
|
|
TextButton(onPressed: () => Navigator.of(d).pop(), child: const Text('取消')),
|
|||
|
|
TextButton(
|
|||
|
|
onPressed: () {
|
|||
|
|
PhotoManager.openSetting();
|
|||
|
|
Navigator.of(d).pop();
|
|||
|
|
},
|
|||
|
|
child: const Text('去设置'),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 先提供多选(wechat_assets_picker),如果你只想要单选可替换为 pickSingleImageFromGallery
|
|||
|
|
final images = await pickAssets(context: context, maxAssets: maxAssets, maxSizeInBytes: maxSizeInBytes);
|
|||
|
|
return images;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (choice == 'file') {
|
|||
|
|
final files = await pickFiles(allowMultiple: allowMultipleFiles, allowedExtensions: allowedExtensions, maxSizeInBytes: maxSizeInBytes);
|
|||
|
|
return files;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
}
|