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隐患识别与处理'),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|