flutter_integrated_whb/lib/customWidget/photo_picker_row.dart

807 lines
28 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.

// <your_file_name>.dart
import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.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';
const String kAcceptVideoSectionKey = 'accept_video';
/// ---------------------- 全局 MediaBus (轻量事件总线) ----------------------
class MediaEvent {
final String key;
final MediaEventType type;
final List<String>? paths;
MediaEvent._(this.key, this.type, [this.paths]);
factory MediaEvent.clear(String key) => MediaEvent._(key, MediaEventType.clear);
factory MediaEvent.set(String key, List<String> paths) =>
MediaEvent._(key, MediaEventType.set, List<String>.from(paths));
}
enum MediaEventType { clear, set }
class MediaBus {
MediaBus._internal();
static final MediaBus _instance = MediaBus._internal();
factory MediaBus() => _instance;
final StreamController<MediaEvent> _ctrl = StreamController<MediaEvent>.broadcast();
Stream<MediaEvent> get stream => _ctrl.stream;
void emit(MediaEvent ev) {
if (!_ctrl.isClosed) _ctrl.add(ev);
}
Future<void> dispose() async {
await _ctrl.close();
}
}
/// ---------------------- /MediaBus ----------------------
/// 媒体选择类型
enum MediaType { image, video }
/// ---------- 辅助函数(文件顶部复用) ----------
bool _isNetworkPath(String? p) {
if (p == null) return false;
final s = p.trim().toLowerCase();
return s.startsWith('http://') || s.startsWith('https://');
}
/// 把路径列表转换为存在的本地 File 列表(过滤掉网络路径与空路径与不存在的本地文件)
/// 注意:这个函数**可能**会同步访问文件系统existsSync它只应在用户触发后调用不应在 build() 中被频繁调用。
List<File> _localFilesFromPaths(List<String>? paths) {
if (paths == null) return <File>[];
return paths
.map((e) => (e ?? '').toString().trim())
.where((s) => s.isNotEmpty && !_isNetworkPath(s))
.where((s) => File(s).existsSync())
.map((s) => File(s))
.toList();
}
/// 规范化路径列表trim + 过滤空字符串
List<String> _normalizePaths(List<String>? src) {
if (src == null) return <String>[];
return src.map((e) => (e ?? '').toString().trim()).where((s) => s.isNotEmpty).toList();
}
/// ---------- MediaPickerRow ----------
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;
/// 默认 false —— 仅在 initState 时读取 initialMediaPaths
final bool followInitialUpdates;
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,
this.followInitialUpdates = false, // 默认 false
}) : super(key: key);
@override
_MediaPickerGridState createState() => _MediaPickerGridState();
}
class _MediaPickerGridState extends State<MediaPickerRow> {
final ImagePicker _picker = ImagePicker();
late List<String> _mediaPaths;
bool _isProcessing = false;
/// 缓存每个本地路径是否存在(避免在 build 中反复同步 IO
final Map<String, bool> _localExistsCache = {};
@override
void initState() {
super.initState();
// 初始化内部路径(保留网络路径与本地路径)
_mediaPaths = _normalizePaths(widget.initialMediaPaths).take(widget.maxCount).toList();
// 预先检查一次本地文件是否存在(只在 init 时做一次同步检查)
for (final pth in _mediaPaths) {
final t = pth.trim();
if (!_isNetworkPath(t)) {
try {
_localExistsCache[t] = File(t).existsSync();
} catch (_) {
_localExistsCache[t] = false;
}
} else {
_localExistsCache[pth] = false;
}
}
// 仅在存在本地真实文件时才把 File 列表回调给外部(避免父组件用这个回调覆盖只有网络路径的数据)
final initialLocalFiles = _localFilesFromPaths(_mediaPaths);
if (initialLocalFiles.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
widget.onChanged(initialLocalFiles);
});
}
}
@override
void didUpdateWidget(covariant MediaPickerRow oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.followInitialUpdates) {
final oldList = _normalizePaths(oldWidget.initialMediaPaths);
final newList = _normalizePaths(widget.initialMediaPaths);
if (!listEquals(oldList, newList)) {
_mediaPaths = newList.take(widget.maxCount).toList();
// 更新本地存在缓存(同步检查,仅在更新时执行)
for (final pth in _mediaPaths) {
final t = pth.trim();
if (!_localExistsCache.containsKey(t)) {
if (!_isNetworkPath(t)) {
try {
_localExistsCache[t] = File(t).existsSync();
} catch (_) {
_localExistsCache[t] = false;
}
} else {
_localExistsCache[t] = false;
}
}
}
if (mounted) setState(() {});
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onChanged(_localFilesFromPaths(_mediaPaths));
});
}
}
}
Future<void> _handlePickedPath(String path) async {
if (!mounted) return;
if (path.isEmpty) return;
try {
String finalPath = path;
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));
// 记录缓存(只在添加时检查一次文件是否真实存在)
if (!_isNetworkPath(finalPath)) {
try {
_localExistsCache[finalPath] = File(finalPath).existsSync();
} catch (_) {
_localExistsCache[finalPath] = false;
}
} else {
_localExistsCache[finalPath] = false;
}
// 回调仅包含本地真实存在的文件(网络路径不会出现在此回调中)
widget.onChanged(_localFilesFromPaths(_mediaPaths));
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));
// 记录存在
try {
_localExistsCache[path] = File(path).existsSync();
} catch (_) {
_localExistsCache[path] = false;
}
widget.onChanged(_localFilesFromPaths(_mediaPaths));
widget.onMediaAdded?.call(path);
}
} else {
picked = await _picker.pickVideo(source: ImageSource.camera);
if (picked != null) {
await _handlePickedPath(picked.path);
}
}
} catch (e) {
debugPrint('拍摄失败: $e');
}
}
Future<void> _pickGallery() async {
if (!widget.isEdit || _mediaPaths.length >= widget.maxCount) return;
try {
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;
}
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(_localFilesFromPaths(_mediaPaths));
}
} else {
final DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
final int sdkInt = androidInfo.version.sdkInt ?? 0;
PermissionStatus permissionStatus = PermissionStatus.denied;
if (sdkInt >= 33) {
if (widget.mediaType == MediaType.image) {
permissionStatus = await Permission.photos.request();
} else if (widget.mediaType == MediaType.video) {
permissionStatus = await Permission.videos.request();
} else {
final statuses = await [Permission.photos, Permission.videos].request();
permissionStatus = statuses[Permission.photos] ?? statuses[Permission.videos] ?? PermissionStatus.denied;
}
} else if (sdkInt >= 30) {
permissionStatus = await Permission.storage.request();
} else {
permissionStatus = await Permission.storage.request();
}
if (permissionStatus.isGranted) {
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(_localFilesFromPaths(_mediaPaths));
}
} 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) {
ToastUtil.showNormal(context, '相册选择失败');
}
}
}
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) {
ToastUtil.showNormal(context, '相机权限被拒绝');
}
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));
try {
_localExistsCache[path] = File(path).existsSync();
} catch (_) {
_localExistsCache[path] = false;
}
widget.onChanged(_localFilesFromPaths(_mediaPaths));
widget.onMediaAdded?.call(path);
}
} else {
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) return;
final removed = _mediaPaths[index];
setState(() => _mediaPaths.removeAt(index));
// 从缓存中移除
_localExistsCache.remove(removed);
// 始终通知 onMediaRemoved用于父端业务逻辑
widget.onMediaRemoved?.call(removed);
// 只有当本地文件集合发生变化时才触发 onChanged避免因为删除网络路径导致父端把列表置空
final localFiles = _localFilesFromPaths(_mediaPaths);
widget.onChanged(localFiles);
}
@override
Widget build(BuildContext context) {
final showAddButton = widget.isEdit && _mediaPaths.length < widget.maxCount;
final itemCount = _mediaPaths.length + (showAddButton ? 1 : 0);
// 预计算缩略图解码宽度(减少内存开销)
final tileLogicalW = (MediaQuery.of(context).size.width / 4).round();
final cacheWidth = (tileLogicalW * MediaQuery.of(context).devicePixelRatio).round();
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 raw = (_mediaPaths[index] ?? '').toString().trim();
final isNetwork = _isNetworkPath(raw);
return GestureDetector(
onTap: () => widget.onMediaTapped?.call(raw),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox.expand(
child: raw.isEmpty
? Container(
color: Colors.grey.shade200,
child: const Center(child: Icon(Icons.broken_image, size: 28, color: Colors.grey)),
)
: (widget.mediaType == MediaType.image
? (isNetwork
? Image.network(
raw,
fit: BoxFit.cover,
// request a scaled decode to reduce memory
width: tileLogicalW.toDouble(),
height: tileLogicalW.toDouble(),
// errorBuilder for network errors
errorBuilder: (_, __, ___) => Container(
color: Colors.grey.shade200,
child: const Center(child: Icon(Icons.broken_image)),
),
)
: Image.file(
File(raw),
fit: BoxFit.cover,
width: tileLogicalW.toDouble(),
height: tileLogicalW.toDouble(),
// Use cacheWidth to ask the engine to decode a smaller bitmap (reduces memory).
cacheWidth: cacheWidth,
errorBuilder: (_, __, ___) => Container(
color: Colors.grey.shade200,
child: const Center(child: Icon(Icons.broken_image)),
),
))
: 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();
}
},
),
if (_isProcessing)
Positioned.fill(
child: Container(
color: Colors.transparent,
child: const Center(child: CircularProgressIndicator()),
),
),
],
);
}
}
/// ---------- RepairedPhotoSection ----------
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;
final String sectionKey;
final bool followInitialUpdates;
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,
this.followInitialUpdates = false, // 默认 false
this.sectionKey = kAcceptVideoSectionKey,
}) : super(key: key);
@override
_RepairedPhotoSectionState createState() => _RepairedPhotoSectionState();
}
class _RepairedPhotoSectionState extends State<RepairedPhotoSection> {
late List<String> _mediaPaths;
StreamSubscription<MediaEvent>? _sub;
@override
void initState() {
super.initState();
_mediaPaths = _normalizePaths(widget.initialMediaPaths).take(widget.maxCount).toList();
// 订阅 MediaBus如果需要
_sub = MediaBus().stream.listen((ev) {
if (ev.key != widget.sectionKey) return;
if (ev.type == MediaEventType.clear) {
if (_mediaPaths.isNotEmpty) {
setState(() {
_mediaPaths = <String>[];
});
widget.onChanged(_localFilesFromPaths(_mediaPaths));
}
} else if (ev.type == MediaEventType.set && ev.paths != null) {
final newList = _normalizePaths(ev.paths).take(widget.maxCount).toList();
if (!listEquals(newList, _mediaPaths)) {
setState(() {
_mediaPaths = newList;
});
widget.onChanged(_localFilesFromPaths(_mediaPaths));
}
}
});
}
@override
void didUpdateWidget(covariant RepairedPhotoSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.followInitialUpdates) {
final oldList = _normalizePaths(oldWidget.initialMediaPaths);
final newList = _normalizePaths(widget.initialMediaPaths);
if (!listEquals(oldList, newList)) {
setState(() {
_mediaPaths = newList.take(widget.maxCount).toList();
});
widget.onChanged(_localFilesFromPaths(_mediaPaths));
}
}
if (oldWidget.sectionKey != widget.sectionKey) {
_sub?.cancel();
_sub = MediaBus().stream.listen((ev) {
if (ev.key != widget.sectionKey) return;
if (ev.type == MediaEventType.clear) {
if (_mediaPaths.isNotEmpty) {
setState(() {
_mediaPaths = <String>[];
});
widget.onChanged(_localFilesFromPaths(_mediaPaths));
}
} else if (ev.type == MediaEventType.set && ev.paths != null) {
final newList = _normalizePaths(ev.paths).take(widget.maxCount).toList();
if (!listEquals(newList, _mediaPaths)) {
setState(() {
_mediaPaths = newList;
});
widget.onChanged(_localFilesFromPaths(_mediaPaths));
}
}
});
}
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@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)
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隐患识别与处理'),
],
),
),
),
),
],
),
);
}
}