flutter_integrated_whb/lib/customWidget/photo_picker_row.dart

628 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:qhd_prevention/customWidget/custom_alert_dialog.dart';
import 'package:qhd_prevention/customWidget/full_screen_video_page.dart';
import 'package:qhd_prevention/customWidget/single_image_viewer.dart';
import 'package:qhd_prevention/customWidget/toast_util.dart';
import 'package:qhd_prevention/tools/tools.dart';
import 'package:video_compress/video_compress.dart';
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:path/path.dart' as p;
import 'ItemWidgetFactory.dart';
/// 媒体选择类型
enum MediaType { image, video }
/// 纵向滚动四列的媒体添加组件,支持拍摄、全屏相册多选,以及初始地址列表展示
/// 新增 isEdit 属性控制编辑状态
class MediaPickerRow extends StatefulWidget {
final int maxCount;
final MediaType mediaType;
final List<String>? initialMediaPaths;
final ValueChanged<List<File>> onChanged;
final ValueChanged<String>? onMediaAdded;
final ValueChanged<String>? onMediaRemoved;
final ValueChanged<String>? onMediaTapped; // 新增:媒体点击回调
final bool isEdit; // 新增:控制编辑状态
final bool isCamera; // 新增:只能拍照
const MediaPickerRow({
Key? key,
this.maxCount = 4,
this.mediaType = MediaType.image,
this.initialMediaPaths,
required this.onChanged,
this.onMediaAdded,
this.onMediaRemoved,
this.onMediaTapped, // 新增
this.isEdit = true, // 默认可编辑
this.isCamera = false,
}) : super(key: key);
@override
_MediaPickerGridState createState() => _MediaPickerGridState();
}
class _MediaPickerGridState extends State<MediaPickerRow> {
final ImagePicker _picker = ImagePicker();
late List<String> _mediaPaths;
bool _isProcessing = false; // 转码或处理时显示 loading
@override
void initState() {
super.initState();
_mediaPaths =
widget.initialMediaPaths != null
? widget.initialMediaPaths!.take(widget.maxCount).toList()
: [];
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onChanged(
_mediaPaths
.map((p) => p.startsWith('http') ? File('') : File(p))
.toList(),
);
});
}
// 公共:当得到本地媒体路径时(可能是 mov/avi 等),需要在这里统一处理(转码、入队、回调)
Future<void> _handlePickedPath(String path) async {
if (!mounted) return;
if (path.isEmpty) return;
try {
String finalPath = path;
// 如果是视频并且不是 mp4则调用 video_compress 转码
if (widget.mediaType == MediaType.video) {
final ext = p.extension(path).toLowerCase();
if (ext != '.mp4') {
setState(() => _isProcessing = true);
try {
final info = await VideoCompress.compressVideo(
path,
quality: VideoQuality.MediumQuality,
deleteOrigin: false,
);
if (info != null && info.file != null) {
finalPath = info.file!.path;
debugPrint('✅ 转换完成: $path -> $finalPath');
} else {
throw Exception("转码失败: 返回空文件");
}
} catch (e) {
debugPrint('❌ 视频转码失败: $e');
if (mounted) {
ToastUtil.showNormal(context, '视频转码失败');
}
return;
} finally {
if (mounted) setState(() => _isProcessing = false);
}
}
}
// 添加到列表
if (_mediaPaths.length < widget.maxCount) {
setState(() => _mediaPaths.add(finalPath));
widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
widget.onMediaAdded?.call(finalPath);
}
} catch (e) {
debugPrint('处理选中媒体失败: $e');
}
}
Future<void> _showPickerOptions() async {
if (!widget.isEdit) return; // 不可编辑时直接返回
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
builder:
(_) => SafeArea(
child: Wrap(
children: [
ListTile(
titleAlignment: ListTileTitleAlignment.center,
leading: Icon(
widget.mediaType == MediaType.image
? Icons.camera_alt
: Icons.videocam,
),
title: Text(
widget.mediaType == MediaType.image ? '拍照' : '拍摄视频',
),
onTap: () {
Navigator.of(context).pop();
_pickCamera();
},
),
ListTile(
titleAlignment: ListTileTitleAlignment.center,
leading: Icon(
widget.mediaType == MediaType.image
? Icons.photo_library
: Icons.video_library,
),
title: Text(
widget.mediaType == MediaType.image ? '从相册选择' : '从相册选择视频',
),
onTap: () {
Navigator.of(context).pop();
_pickGallery();
},
),
ListTile(
titleAlignment: ListTileTitleAlignment.center,
leading: const Icon(Icons.close),
title: const Text('取消'),
onTap: () => Navigator.of(context).pop(),
),
],
),
),
);
}
Future<void> _pickCamera() async {
if (!widget.isEdit || _mediaPaths.length >= widget.maxCount) return;
try {
XFile? picked;
if (widget.mediaType == MediaType.image) {
picked = await _picker.pickImage(source: ImageSource.camera);
if (picked != null) {
final path = picked.path;
setState(() => _mediaPaths.add(path));
widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
widget.onMediaAdded?.call(path);
}
} else {
picked = await _picker.pickVideo(source: ImageSource.camera);
if (picked != null) {
await _handlePickedPath(picked.path);
}
}
} catch (e) {
debugPrint('拍摄失败: $e');
}
}
// 修改 _pickGallery 方法
Future<void> _pickGallery() async {
if (!widget.isEdit || _mediaPaths.length >= widget.maxCount) return;
try {
// iOS: 使用 PhotoManager.requestPermissionExtend() 唤起系统权限弹窗(支持 limited
if (Platform.isIOS) {
final permission = await PhotoManager.requestPermissionExtend();
debugPrint('iOS photo permission state: $permission');
if (permission != PermissionState.authorized &&
permission != PermissionState.limited) {
if (mounted) {
ToastUtil.showNormal(context, '请到设置中开启相册访问权限');
}
return;
}
// 授权或 limited继续打开 AssetPicker
final remaining = widget.maxCount - _mediaPaths.length;
final List<AssetEntity>? assets = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
requestType:
widget.mediaType == MediaType.image ? RequestType.image : RequestType.video,
maxAssets: remaining,
gridCount: 4,
),
);
if (assets != null) {
for (final asset in assets) {
if (_mediaPaths.length >= widget.maxCount) break;
try {
final file = await asset.file;
if (file != null) {
final path = file.path;
await _handlePickedPath(path);
} else {
debugPrint('资产获取 file 为空asset id: ${asset.id}');
}
} catch (e) {
debugPrint('读取 asset 文件失败: $e');
}
}
if (mounted) setState(() {});
widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
}
} else {
// Android: 更可靠地请求权限(适配不同 Android 版本)
final DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
final int sdkInt = androidInfo.version.sdkInt ?? 0;
PermissionStatus permissionStatus = PermissionStatus.denied;
if (sdkInt >= 33) {
// Android 13+: 使用更细粒度的媒体权限
// 如果应用只选图片request photos只选视频则 request videos若两者都可能同时需要可请求两个。
if (widget.mediaType == MediaType.image) {
permissionStatus = await Permission.photos.request(); // maps to READ_MEDIA_IMAGES on Android
} else if (widget.mediaType == MediaType.video) {
permissionStatus = await Permission.videos.request(); // maps to READ_MEDIA_VIDEO
} else {
// 两者皆可(同时请求会合并成单个系统对话)
final statuses = await [Permission.photos, Permission.videos].request();
permissionStatus = statuses[Permission.photos] ?? statuses[Permission.videos] ?? PermissionStatus.denied;
}
} else if (sdkInt >= 30) {
// Android 11/12: 通常仍然使用 READ_EXTERNAL_STORAGE若需要“全部文件访问”要 request manageExternalStorage慎用
permissionStatus = await Permission.storage.request();
} else {
// Android 10 及以下
permissionStatus = await Permission.storage.request();
}
// 处理结果
if (permissionStatus.isGranted) {
// 有权限:继续打开 AssetPicker
final remaining = widget.maxCount - _mediaPaths.length;
final List<AssetEntity>? assets = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
requestType: widget.mediaType == MediaType.image ? RequestType.image : RequestType.video,
maxAssets: remaining,
gridCount: 4,
),
);
if (assets != null) {
for (final asset in assets) {
if (_mediaPaths.length >= widget.maxCount) break;
try {
final file = await asset.file;
if (file != null) {
final path = file.path;
await _handlePickedPath(path);
} else {
debugPrint('资产获取 file 为空asset id: ${asset.id}');
}
} catch (e) {
debugPrint('读取 asset 文件失败: $e');
}
}
if (mounted) setState(() {});
widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
}
} else if (permissionStatus.isPermanentlyDenied) {
// 用户点了“不再询问”或系统拒绝弹窗,跳转设置页并提示
if (mounted) {
ToastUtil.showNormal(context, '请到设置中开启相册访问权限');
}
await openAppSettings();
return;
} else {
if (mounted) {
ToastUtil.showNormal(context, '相册访问权限被拒绝');
}
return;
}
}
} catch (e, st) {
debugPrint('相册选择失败: $e\n$st');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('相册选择失败')),
);
}
}
}
// 修改拍照方法,使用 permission_handler 请求相机权限
Future<void> _cameraAction() async {
if (!widget.isEdit || _mediaPaths.length >= widget.maxCount) return;
// 请求相机权限
final PermissionStatus status = await Permission.camera.request();
if (status != PermissionStatus.granted) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('相机权限被拒绝')));
}
return;
}
try {
if (widget.mediaType == MediaType.image) {
XFile? picked = await _picker.pickImage(source: ImageSource.camera);
if (picked != null) {
final path = picked.path;
setState(() => _mediaPaths.add(path));
widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
widget.onMediaAdded?.call(path);
}
} else {
// video from camera
XFile? picked = await _picker.pickVideo(source: ImageSource.camera);
if (picked != null) {
await _handlePickedPath(picked.path);
}
}
} catch (e) {
debugPrint('拍摄失败: $e');
// 友好提示
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('拍摄失败,请检查相机权限或设备状态')));
}
}
}
void _removeMedia(int index) async {
final ok = await CustomAlertDialog.showConfirm(
context,
title: '温馨提示',
content:
widget.mediaType == MediaType.image ? '确定要删除这张图片吗?' : '确定要删除这个视频吗?',
cancelText: '取消',
);
if (ok) {
final removed = _mediaPaths[index];
setState(() => _mediaPaths.removeAt(index));
widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
widget.onMediaRemoved?.call(removed);
}
}
@override
Widget build(BuildContext context) {
final showAddButton = widget.isEdit && _mediaPaths.length < widget.maxCount;
final itemCount = _mediaPaths.length + (showAddButton ? 1 : 0);
return Stack(
children: [
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1,
),
itemCount: itemCount,
itemBuilder: (context, index) {
// 显示媒体项
if (index < _mediaPaths.length) {
final path = _mediaPaths[index];
final isNetwork = path.startsWith('http');
return GestureDetector(
onTap: () => widget.onMediaTapped?.call(path),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox.expand(
// 让内容强制填满格子
child:
widget.mediaType == MediaType.image
? (isNetwork
? Image.network(
path,
fit: BoxFit.cover, // 铺满格子并裁剪
)
: Image.file(
File(path),
fit: BoxFit.cover, // 铺满格子并裁剪
))
: Container(
color: Colors.black12,
child: const Center(
child: Icon(
Icons.videocam,
color: Colors.white70,
),
),
),
),
),
// 只在可编辑状态下显示删除按钮
if (widget.isEdit)
Positioned(
top: -15,
right: -15,
child: IconButton(
icon: const Icon(
Icons.cancel,
size: 20,
color: Colors.red,
),
onPressed: () => _removeMedia(index),
),
),
],
),
);
}
// 显示添加按钮
else if (showAddButton) {
return GestureDetector(
onTap: widget.isCamera ? _cameraAction : _showPickerOptions,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.black12),
borderRadius: BorderRadius.circular(5),
),
child: const Center(
child: Icon(Icons.camera_alt, color: Colors.black26),
),
),
);
} else {
return const SizedBox.shrink();
}
},
),
// 转码/处理 loading 遮罩
if (_isProcessing)
Positioned.fill(
child: Container(
color: Colors.transparent,
child: const Center(child: CircularProgressIndicator()),
),
),
],
);
}
}
/// 照片上传区域组件使用纵向四列Grid展示
/// 新增 isEdit 属性控制编辑状态
class RepairedPhotoSection extends StatefulWidget {
final int maxCount;
final MediaType mediaType;
final String title;
final List<String>? initialMediaPaths;
final ValueChanged<List<File>> onChanged;
final ValueChanged<String>? onMediaAdded;
final ValueChanged<String>? onMediaRemoved;
final ValueChanged<String>? onMediaTapped; // 新增:媒体点击回调
final VoidCallback onAiIdentify;
final bool isShowAI;
final double horizontalPadding;
final bool isRequired;
final bool isShowNum;
final bool isEdit; // 新增:控制编辑状态
final bool isCamera; // 新增:只能拍照
const RepairedPhotoSection({
Key? key,
this.maxCount = 4,
this.mediaType = MediaType.image,
required this.title,
this.initialMediaPaths,
this.isShowAI = false,
required this.onChanged,
required this.onAiIdentify,
this.horizontalPadding = 5,
this.onMediaAdded,
this.onMediaRemoved,
this.onMediaTapped, // 新增
this.isRequired = false,
this.isShowNum = true,
this.isEdit = true, // 默认可编辑
this.isCamera = false,
}) : super(key: key);
@override
_RepairedPhotoSectionState createState() => _RepairedPhotoSectionState();
}
class _RepairedPhotoSectionState extends State<RepairedPhotoSection> {
late List<String> _mediaPaths;
@override
void initState() {
super.initState();
_mediaPaths =
widget.initialMediaPaths?.take(widget.maxCount).toList() ?? [];
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
});
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
padding: const EdgeInsets.only(left: 0, right: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: widget.horizontalPadding),
child: ListItemFactory.createRowSpaceBetweenItem(
leftText: widget.title,
rightText:
widget.isShowNum
? '${_mediaPaths.length}/${widget.maxCount}'
: '',
isRequired: widget.isRequired,
),
),
const SizedBox(height: 8),
Padding(
padding: EdgeInsets.symmetric(horizontal: widget.horizontalPadding),
child: MediaPickerRow(
maxCount: widget.maxCount,
mediaType: widget.mediaType,
initialMediaPaths: _mediaPaths,
isCamera: widget.isCamera,
onChanged: (files) {
final newPaths = files.map((f) => f.path).toList();
setState(() {
_mediaPaths = newPaths;
});
widget.onChanged(files);
},
onMediaAdded: widget.onMediaAdded,
onMediaRemoved: widget.onMediaRemoved,
onMediaTapped: (filePath) {
if (widget.mediaType == MediaType.image) {
presentOpaque(SingleImageViewer(imageUrl: filePath), context);
}else{
showDialog(
context: context,
barrierColor: Colors.black54,
builder: (_) => VideoPlayerPopup(videoUrl:filePath),
);
}
},
// 传递点击回调
isEdit: widget.isEdit, // 传递编辑状态
),
),
const SizedBox(height: 8),
if (widget.isShowAI && widget.isEdit) // 只在可编辑状态下显示AI按钮
Padding(
padding: EdgeInsets.symmetric(
horizontal: widget.horizontalPadding,
),
child: GestureDetector(
onTap: widget.onAiIdentify,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 15),
height: 36,
decoration: BoxDecoration(
color: const Color(0xFFDFEAFF),
borderRadius: BorderRadius.circular(18),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('assets/images/ai_img.png', width: 20),
const SizedBox(width: 5),
const Text('AI隐患识别与处理'),
],
),
),
),
),
],
),
);
}
}