// .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? paths; MediaEvent._(this.key, this.type, [this.paths]); factory MediaEvent.clear(String key) => MediaEvent._(key, MediaEventType.clear); factory MediaEvent.set(String key, List paths) => MediaEvent._(key, MediaEventType.set, List.from(paths)); } enum MediaEventType { clear, set } class MediaBus { MediaBus._internal(); static final MediaBus _instance = MediaBus._internal(); factory MediaBus() => _instance; final StreamController _ctrl = StreamController.broadcast(); Stream get stream => _ctrl.stream; void emit(MediaEvent ev) { if (!_ctrl.isClosed) _ctrl.add(ev); } Future 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 _localFilesFromPaths(List? paths) { if (paths == null) return []; 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 _normalizePaths(List? src) { if (src == null) return []; 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? initialMediaPaths; final ValueChanged> onChanged; final ValueChanged? onMediaAdded; final ValueChanged? onMediaRemoved; final ValueChanged? 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 { final ImagePicker _picker = ImagePicker(); late List _mediaPaths; bool _isProcessing = false; /// 缓存每个本地路径是否存在(避免在 build 中反复同步 IO) final Map _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 _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 _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 _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 _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? 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? 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 _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? initialMediaPaths; final ValueChanged> onChanged; final ValueChanged? onMediaAdded; final ValueChanged? onMediaRemoved; final ValueChanged? 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 { late List _mediaPaths; StreamSubscription? _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 = []; }); 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 = []; }); 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隐患识别与处理'), ], ), ), ), ), ], ), ); } }