flutter_integrated_whb/lib/customWidget/photo_picker_row.dart

738 lines
25 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: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 列表(过滤掉网络路径与空路径与不存在的本地文件)
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 ----------
/// ---------- 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;
@override
void initState() {
super.initState();
// 初始化内部路径(保留网络路径与本地路径)
_mediaPaths = _normalizePaths(widget.initialMediaPaths).take(widget.maxCount).toList();
// 仅在存在本地真实文件时才把 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();
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));
// 回调仅包含本地真实存在的文件(网络路径不会出现在此回调中)
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));
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));
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];
final wasNetwork = _isNetworkPath(removed);
setState(() => _mediaPaths.removeAt(index));
// 始终通知 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);
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,
errorBuilder: (_, __, ___) => Container(
color: Colors.grey.shade200,
child: const Center(child: Icon(Icons.broken_image)),
),
)
: (File(raw).existsSync()
? Image.file(
File(raw),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: Colors.grey.shade200,
child: const Center(child: Icon(Icons.broken_image)),
),
)
: 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隐患识别与处理'),
],
),
),
),
),
],
),
);
}
}