807 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			Dart
		
	
	
			
		
		
	
	
			807 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			Dart
		
	
	
| // <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隐患识别与处理'),
 | ||
|                     ],
 | ||
|                   ),
 | ||
|                 ),
 | ||
|               ),
 | ||
|             ),
 | ||
|         ],
 | ||
|       ),
 | ||
|     );
 | ||
|   }
 | ||
| }
 |