flutter_integrated_whb/lib/customWidget/photo_picker_row.dart

807 lines
28 KiB
Dart
Raw Normal View History

2025-09-17 14:38:05 +08:00
// <your_file_name>.dart
2025-09-15 15:54:03 +08:00
import 'dart:async';
2025-07-11 11:03:21 +08:00
import 'dart:io';
2025-09-05 09:16:54 +08:00
import 'package:device_info_plus/device_info_plus.dart';
2025-09-15 15:54:03 +08:00
import 'package:flutter/foundation.dart';
2025-07-11 11:03:21 +08:00
import 'package:flutter/material.dart';
2025-08-29 09:52:48 +08:00
import 'package:flutter/services.dart';
2025-07-11 11:03:21 +08:00
import 'package:image_picker/image_picker.dart';
2025-09-01 17:25:55 +08:00
import 'package:permission_handler/permission_handler.dart';
import 'package:qhd_prevention/customWidget/custom_alert_dialog.dart';
2025-09-02 16:22:17 +08:00
import 'package:qhd_prevention/customWidget/full_screen_video_page.dart';
import 'package:qhd_prevention/customWidget/single_image_viewer.dart';
2025-09-05 09:16:54 +08:00
import 'package:qhd_prevention/customWidget/toast_util.dart';
2025-09-02 16:22:17 +08:00
import 'package:qhd_prevention/tools/tools.dart';
2025-08-29 09:52:48 +08:00
import 'package:video_compress/video_compress.dart';
2025-07-11 11:03:21 +08:00
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import 'package:photo_manager/photo_manager.dart';
2025-08-29 09:52:48 +08:00
import 'package:path/path.dart' as p;
2025-07-11 11:03:21 +08:00
import 'ItemWidgetFactory.dart';
2025-09-15 15:54:03 +08:00
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 ----------------------
2025-07-11 11:03:21 +08:00
/// 媒体选择类型
enum MediaType { image, video }
2025-09-15 15:54:03 +08:00
/// ---------- 辅助函数(文件顶部复用) ----------
bool _isNetworkPath(String? p) {
if (p == null) return false;
final s = p.trim().toLowerCase();
return s.startsWith('http://') || s.startsWith('https://');
}
/// 把路径列表转换为存在的本地 File 列表(过滤掉网络路径与空路径与不存在的本地文件)
2025-09-17 14:38:05 +08:00
/// 注意:这个函数**可能**会同步访问文件系统existsSync它只应在用户触发后调用不应在 build() 中被频繁调用。
2025-09-15 15:54:03 +08:00
List<File> _localFilesFromPaths(List<String>? paths) {
if (paths == null) return <File>[];
return paths
.map((e) => (e ?? '').toString().trim())
.where((s) => s.isNotEmpty && !_isNetworkPath(s))
2025-09-17 14:38:05 +08:00
.where((s) => File(s).existsSync())
2025-09-15 15:54:03 +08:00
.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 ----------
2025-07-11 11:03:21 +08:00
class MediaPickerRow extends StatefulWidget {
final int maxCount;
final MediaType mediaType;
2025-07-30 17:08:46 +08:00
final List<String>? initialMediaPaths;
2025-07-11 11:03:21 +08:00
final ValueChanged<List<File>> onChanged;
2025-07-30 17:08:46 +08:00
final ValueChanged<String>? onMediaAdded;
final ValueChanged<String>? onMediaRemoved;
2025-09-15 15:54:03 +08:00
final ValueChanged<String>? onMediaTapped;
final bool isEdit;
final bool isCamera;
/// 默认 false —— 仅在 initState 时读取 initialMediaPaths
final bool followInitialUpdates;
2025-08-27 16:14:50 +08:00
2025-07-11 11:03:21 +08:00
const MediaPickerRow({
Key? key,
this.maxCount = 4,
this.mediaType = MediaType.image,
2025-07-30 17:08:46 +08:00
this.initialMediaPaths,
2025-07-11 11:03:21 +08:00
required this.onChanged,
2025-07-30 17:08:46 +08:00
this.onMediaAdded,
this.onMediaRemoved,
2025-09-15 15:54:03 +08:00
this.onMediaTapped,
this.isEdit = true,
2025-08-27 16:14:50 +08:00
this.isCamera = false,
2025-09-15 15:54:03 +08:00
this.followInitialUpdates = false, // 默认 false
2025-07-11 11:03:21 +08:00
}) : super(key: key);
@override
2025-08-07 17:33:16 +08:00
_MediaPickerGridState createState() => _MediaPickerGridState();
2025-07-11 11:03:21 +08:00
}
2025-08-07 17:33:16 +08:00
class _MediaPickerGridState extends State<MediaPickerRow> {
2025-07-11 11:03:21 +08:00
final ImagePicker _picker = ImagePicker();
2025-07-30 17:08:46 +08:00
late List<String> _mediaPaths;
2025-09-15 15:54:03 +08:00
bool _isProcessing = false;
2025-07-30 17:08:46 +08:00
2025-09-17 14:38:05 +08:00
/// 缓存每个本地路径是否存在(避免在 build 中反复同步 IO
final Map<String, bool> _localExistsCache = {};
2025-07-30 17:08:46 +08:00
@override
void initState() {
super.initState();
2025-09-15 15:54:03 +08:00
// 初始化内部路径(保留网络路径与本地路径)
_mediaPaths = _normalizePaths(widget.initialMediaPaths).take(widget.maxCount).toList();
2025-09-17 14:38:05 +08:00
// 预先检查一次本地文件是否存在(只在 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;
}
}
2025-09-15 15:54:03 +08:00
// 仅在存在本地真实文件时才把 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();
2025-09-17 14:38:05 +08:00
// 更新本地存在缓存(同步检查,仅在更新时执行)
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;
}
}
}
2025-09-15 15:54:03 +08:00
if (mounted) setState(() {});
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onChanged(_localFilesFromPaths(_mediaPaths));
});
}
}
2025-07-30 17:08:46 +08:00
}
2025-08-29 09:52:48 +08:00
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) {
2025-09-05 09:16:54 +08:00
ToastUtil.showNormal(context, '视频转码失败');
2025-08-29 09:52:48 +08:00
}
return;
} finally {
if (mounted) setState(() => _isProcessing = false);
}
}
}
if (_mediaPaths.length < widget.maxCount) {
setState(() => _mediaPaths.add(finalPath));
2025-09-17 14:38:05 +08:00
// 记录缓存(只在添加时检查一次文件是否真实存在)
if (!_isNetworkPath(finalPath)) {
try {
_localExistsCache[finalPath] = File(finalPath).existsSync();
} catch (_) {
_localExistsCache[finalPath] = false;
}
} else {
_localExistsCache[finalPath] = false;
}
2025-09-15 15:54:03 +08:00
// 回调仅包含本地真实存在的文件(网络路径不会出现在此回调中)
widget.onChanged(_localFilesFromPaths(_mediaPaths));
2025-08-29 09:52:48 +08:00
widget.onMediaAdded?.call(finalPath);
}
} catch (e) {
debugPrint('处理选中媒体失败: $e');
}
}
2025-07-11 11:03:21 +08:00
Future<void> _showPickerOptions() async {
2025-09-15 15:54:03 +08:00
if (!widget.isEdit) return;
2025-08-14 15:05:48 +08:00
2025-07-11 11:03:21 +08:00
showModalBottomSheet(
context: context,
2025-08-11 17:40:03 +08:00
backgroundColor: Colors.white,
2025-09-15 15:54:03 +08:00
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();
},
2025-07-30 17:08:46 +08:00
),
2025-09-15 15:54:03 +08:00
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(),
),
],
),
),
2025-07-11 11:03:21 +08:00
);
}
Future<void> _pickCamera() async {
2025-08-14 15:05:48 +08:00
if (!widget.isEdit || _mediaPaths.length >= widget.maxCount) return;
2025-07-11 11:03:21 +08:00
try {
XFile? picked;
if (widget.mediaType == MediaType.image) {
picked = await _picker.pickImage(source: ImageSource.camera);
2025-08-29 09:52:48 +08:00
if (picked != null) {
final path = picked.path;
setState(() => _mediaPaths.add(path));
2025-09-17 14:38:05 +08:00
// 记录存在
try {
_localExistsCache[path] = File(path).existsSync();
} catch (_) {
_localExistsCache[path] = false;
}
2025-09-15 15:54:03 +08:00
widget.onChanged(_localFilesFromPaths(_mediaPaths));
2025-08-29 09:52:48 +08:00
widget.onMediaAdded?.call(path);
}
2025-07-11 11:03:21 +08:00
} else {
picked = await _picker.pickVideo(source: ImageSource.camera);
2025-08-29 09:52:48 +08:00
if (picked != null) {
await _handlePickedPath(picked.path);
}
2025-07-11 11:03:21 +08:00
}
} catch (e) {
2025-07-28 14:22:07 +08:00
debugPrint('拍摄失败: $e');
2025-07-11 11:03:21 +08:00
}
}
Future<void> _pickGallery() async {
2025-08-14 15:05:48 +08:00
if (!widget.isEdit || _mediaPaths.length >= widget.maxCount) return;
2025-09-01 17:25:55 +08:00
try {
if (Platform.isIOS) {
final permission = await PhotoManager.requestPermissionExtend();
debugPrint('iOS photo permission state: $permission');
2025-09-15 15:54:03 +08:00
if (permission != PermissionState.authorized && permission != PermissionState.limited) {
2025-09-01 17:25:55 +08:00
if (mounted) {
2025-09-05 09:16:54 +08:00
ToastUtil.showNormal(context, '请到设置中开启相册访问权限');
2025-09-01 17:25:55 +08:00
}
return;
}
final remaining = widget.maxCount - _mediaPaths.length;
final List<AssetEntity>? assets = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
2025-09-15 15:54:03 +08:00
requestType: widget.mediaType == MediaType.image ? RequestType.image : RequestType.video,
2025-09-01 17:25:55 +08:00
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(() {});
2025-09-15 15:54:03 +08:00
widget.onChanged(_localFilesFromPaths(_mediaPaths));
2025-09-01 17:25:55 +08:00
}
} else {
2025-09-05 09:16:54 +08:00
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) {
2025-09-15 15:54:03 +08:00
permissionStatus = await Permission.photos.request();
2025-09-05 09:16:54 +08:00
} else if (widget.mediaType == MediaType.video) {
2025-09-15 15:54:03 +08:00
permissionStatus = await Permission.videos.request();
2025-09-05 09:16:54 +08:00
} 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();
2025-09-01 17:25:55 +08:00
} else {
2025-09-05 09:16:54 +08:00
permissionStatus = await Permission.storage.request();
2025-09-01 17:25:55 +08:00
}
2025-09-05 09:16:54 +08:00
if (permissionStatus.isGranted) {
2025-09-01 17:25:55 +08:00
final remaining = widget.maxCount - _mediaPaths.length;
final List<AssetEntity>? assets = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
2025-09-05 09:16:54 +08:00
requestType: widget.mediaType == MediaType.image ? RequestType.image : RequestType.video,
2025-09-01 17:25:55 +08:00
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(() {});
2025-09-15 15:54:03 +08:00
widget.onChanged(_localFilesFromPaths(_mediaPaths));
2025-09-01 17:25:55 +08:00
}
2025-09-05 09:16:54 +08:00
} else if (permissionStatus.isPermanentlyDenied) {
2025-09-01 17:25:55 +08:00
if (mounted) {
2025-09-05 09:16:54 +08:00
ToastUtil.showNormal(context, '请到设置中开启相册访问权限');
2025-09-01 17:25:55 +08:00
}
await openAppSettings();
2025-09-05 09:16:54 +08:00
return;
2025-09-01 17:25:55 +08:00
} else {
if (mounted) {
2025-09-05 09:16:54 +08:00
ToastUtil.showNormal(context, '相册访问权限被拒绝');
2025-09-01 17:25:55 +08:00
}
2025-09-05 09:16:54 +08:00
return;
2025-09-01 17:25:55 +08:00
}
}
} catch (e, st) {
debugPrint('相册选择失败: $e\n$st');
if (mounted) {
2025-09-15 15:54:03 +08:00
ToastUtil.showNormal(context, '相册选择失败');
2025-09-01 17:25:55 +08:00
}
}
}
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) {
2025-09-15 15:54:03 +08:00
ToastUtil.showNormal(context, '相机权限被拒绝');
2025-09-01 17:25:55 +08:00
}
2025-07-11 11:03:21 +08:00
return;
}
try {
2025-09-01 17:25:55 +08:00
if (widget.mediaType == MediaType.image) {
XFile? picked = await _picker.pickImage(source: ImageSource.camera);
if (picked != null) {
final path = picked.path;
setState(() => _mediaPaths.add(path));
2025-09-17 14:38:05 +08:00
try {
_localExistsCache[path] = File(path).existsSync();
} catch (_) {
_localExistsCache[path] = false;
}
2025-09-15 15:54:03 +08:00
widget.onChanged(_localFilesFromPaths(_mediaPaths));
2025-09-01 17:25:55 +08:00
widget.onMediaAdded?.call(path);
}
} else {
XFile? picked = await _picker.pickVideo(source: ImageSource.camera);
if (picked != null) {
await _handlePickedPath(picked.path);
2025-07-11 11:03:21 +08:00
}
}
} catch (e) {
2025-09-01 17:25:55 +08:00
debugPrint('拍摄失败: $e');
if (mounted) {
2025-09-15 15:54:03 +08:00
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('拍摄失败,请检查相机权限或设备状态')));
2025-09-01 17:25:55 +08:00
}
2025-07-11 11:03:21 +08:00
}
}
2025-09-01 17:25:55 +08:00
void _removeMedia(int index) async {
final ok = await CustomAlertDialog.showConfirm(
context,
title: '温馨提示',
2025-09-15 15:54:03 +08:00
content: widget.mediaType == MediaType.image ? '确定要删除这张图片吗?' : '确定要删除这个视频吗?',
2025-09-01 17:25:55 +08:00
cancelText: '取消',
);
2025-09-15 15:54:03 +08:00
if (!ok) return;
final removed = _mediaPaths[index];
setState(() => _mediaPaths.removeAt(index));
2025-09-17 14:38:05 +08:00
// 从缓存中移除
_localExistsCache.remove(removed);
2025-09-15 15:54:03 +08:00
// 始终通知 onMediaRemoved用于父端业务逻辑
widget.onMediaRemoved?.call(removed);
// 只有当本地文件集合发生变化时才触发 onChanged避免因为删除网络路径导致父端把列表置空
final localFiles = _localFilesFromPaths(_mediaPaths);
widget.onChanged(localFiles);
2025-07-11 11:03:21 +08:00
}
@override
Widget build(BuildContext context) {
2025-08-14 15:05:48 +08:00
final showAddButton = widget.isEdit && _mediaPaths.length < widget.maxCount;
final itemCount = _mediaPaths.length + (showAddButton ? 1 : 0);
2025-09-17 14:38:05 +08:00
// 预计算缩略图解码宽度(减少内存开销)
final tileLogicalW = (MediaQuery.of(context).size.width / 4).round();
final cacheWidth = (tileLogicalW * MediaQuery.of(context).devicePixelRatio).round();
2025-08-29 09:52:48 +08:00
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) {
2025-09-15 15:54:03 +08:00
final raw = (_mediaPaths[index] ?? '').toString().trim();
final isNetwork = _isNetworkPath(raw);
2025-08-14 15:05:48 +08:00
2025-08-29 09:52:48 +08:00
return GestureDetector(
2025-09-15 15:54:03 +08:00
onTap: () => widget.onMediaTapped?.call(raw),
2025-08-29 09:52:48 +08:00
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
2025-09-01 17:25:55 +08:00
child: SizedBox.expand(
2025-09-15 15:54:03 +08:00
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,
2025-09-17 14:38:05 +08:00
// request a scaled decode to reduce memory
width: tileLogicalW.toDouble(),
height: tileLogicalW.toDouble(),
// errorBuilder for network errors
2025-09-15 15:54:03 +08:00
errorBuilder: (_, __, ___) => Container(
color: Colors.grey.shade200,
child: const Center(child: Icon(Icons.broken_image)),
),
)
2025-09-17 14:38:05 +08:00
: Image.file(
2025-09-15 15:54:03 +08:00
File(raw),
fit: BoxFit.cover,
2025-09-17 14:38:05 +08:00
width: tileLogicalW.toDouble(),
height: tileLogicalW.toDouble(),
// Use cacheWidth to ask the engine to decode a smaller bitmap (reduces memory).
cacheWidth: cacheWidth,
2025-09-15 15:54:03 +08:00
errorBuilder: (_, __, ___) => Container(
color: Colors.grey.shade200,
child: const Center(child: Icon(Icons.broken_image)),
),
2025-09-17 14:38:05 +08:00
))
2025-09-15 15:54:03 +08:00
: Container(
color: Colors.black12,
child: const Center(
child: Icon(Icons.videocam, color: Colors.white70),
),
)),
2025-08-14 15:05:48 +08:00
),
2025-07-30 17:08:46 +08:00
),
2025-08-29 09:52:48 +08:00
if (widget.isEdit)
Positioned(
top: -15,
right: -15,
child: IconButton(
2025-09-15 15:54:03 +08:00
icon: const Icon(Icons.cancel, size: 20, color: Colors.red),
2025-08-29 09:52:48 +08:00
onPressed: () => _removeMedia(index),
),
),
],
2025-07-11 11:03:21 +08:00
),
2025-08-29 09:52:48 +08:00
);
2025-09-15 15:54:03 +08:00
} else if (showAddButton) {
2025-08-29 09:52:48 +08:00
return GestureDetector(
onTap: widget.isCamera ? _cameraAction : _showPickerOptions,
child: Container(
2025-09-15 15:54:03 +08:00
decoration: BoxDecoration(border: Border.all(color: Colors.black12), borderRadius: BorderRadius.circular(5)),
child: const Center(child: Icon(Icons.camera_alt, color: Colors.black26)),
2025-08-29 09:52:48 +08:00
),
);
} else {
return const SizedBox.shrink();
}
},
),
if (_isProcessing)
Positioned.fill(
2025-08-07 17:33:16 +08:00
child: Container(
2025-08-29 09:52:48 +08:00
color: Colors.transparent,
2025-09-01 17:25:55 +08:00
child: const Center(child: CircularProgressIndicator()),
2025-08-07 17:33:16 +08:00
),
2025-08-29 09:52:48 +08:00
),
],
2025-07-11 11:03:21 +08:00
);
}
}
2025-09-15 15:54:03 +08:00
/// ---------- RepairedPhotoSection ----------
2025-07-31 17:33:26 +08:00
class RepairedPhotoSection extends StatefulWidget {
2025-07-11 11:03:21 +08:00
final int maxCount;
final MediaType mediaType;
final String title;
2025-07-30 17:08:46 +08:00
final List<String>? initialMediaPaths;
2025-07-11 11:03:21 +08:00
final ValueChanged<List<File>> onChanged;
2025-07-30 17:08:46 +08:00
final ValueChanged<String>? onMediaAdded;
final ValueChanged<String>? onMediaRemoved;
2025-09-15 15:54:03 +08:00
final ValueChanged<String>? onMediaTapped;
2025-07-11 11:03:21 +08:00
final VoidCallback onAiIdentify;
final bool isShowAI;
final double horizontalPadding;
2025-08-07 17:33:16 +08:00
final bool isRequired;
final bool isShowNum;
2025-09-15 15:54:03 +08:00
final bool isEdit;
final bool isCamera;
final String sectionKey;
final bool followInitialUpdates;
2025-08-27 16:14:50 +08:00
2025-07-11 11:03:21 +08:00
const RepairedPhotoSection({
Key? key,
this.maxCount = 4,
this.mediaType = MediaType.image,
required this.title,
2025-07-30 17:08:46 +08:00
this.initialMediaPaths,
2025-07-11 11:03:21 +08:00
this.isShowAI = false,
required this.onChanged,
required this.onAiIdentify,
2025-08-11 17:40:03 +08:00
this.horizontalPadding = 5,
2025-07-30 17:08:46 +08:00
this.onMediaAdded,
this.onMediaRemoved,
2025-09-15 15:54:03 +08:00
this.onMediaTapped,
2025-08-07 17:33:16 +08:00
this.isRequired = false,
this.isShowNum = true,
2025-09-15 15:54:03 +08:00
this.isEdit = true,
2025-08-27 16:14:50 +08:00
this.isCamera = false,
2025-09-15 15:54:03 +08:00
this.followInitialUpdates = false, // 默认 false
this.sectionKey = kAcceptVideoSectionKey,
2025-07-11 11:03:21 +08:00
}) : super(key: key);
2025-08-07 17:33:16 +08:00
2025-07-31 17:33:26 +08:00
@override
_RepairedPhotoSectionState createState() => _RepairedPhotoSectionState();
}
class _RepairedPhotoSectionState extends State<RepairedPhotoSection> {
late List<String> _mediaPaths;
2025-09-15 15:54:03 +08:00
StreamSubscription<MediaEvent>? _sub;
2025-07-31 17:33:26 +08:00
@override
void initState() {
super.initState();
2025-09-15 15:54:03 +08:00
_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));
}
}
2025-07-31 17:33:26 +08:00
});
}
2025-09-15 15:54:03 +08:00
@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();
}
2025-07-11 11:03:21 +08:00
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
2025-07-31 17:33:26 +08:00
padding: const EdgeInsets.only(left: 0, right: 10),
2025-07-11 11:03:21 +08:00
child: Column(
2025-07-31 17:33:26 +08:00
crossAxisAlignment: CrossAxisAlignment.start,
2025-07-11 11:03:21 +08:00
children: [
Padding(
2025-07-31 17:33:26 +08:00
padding: EdgeInsets.symmetric(horizontal: widget.horizontalPadding),
2025-07-11 11:03:21 +08:00
child: ListItemFactory.createRowSpaceBetweenItem(
2025-07-31 17:33:26 +08:00
leftText: widget.title,
2025-09-15 15:54:03 +08:00
rightText: widget.isShowNum ? '${_mediaPaths.length}/${widget.maxCount}' : '',
2025-08-07 17:33:16 +08:00
isRequired: widget.isRequired,
2025-07-11 11:03:21 +08:00
),
),
2025-07-31 17:33:26 +08:00
const SizedBox(height: 8),
2025-07-11 11:03:21 +08:00
Padding(
2025-07-31 17:33:26 +08:00
padding: EdgeInsets.symmetric(horizontal: widget.horizontalPadding),
2025-07-11 11:03:21 +08:00
child: MediaPickerRow(
2025-07-31 17:33:26 +08:00
maxCount: widget.maxCount,
mediaType: widget.mediaType,
initialMediaPaths: _mediaPaths,
2025-08-27 16:14:50 +08:00
isCamera: widget.isCamera,
2025-07-31 17:33:26 +08:00
onChanged: (files) {
final newPaths = files.map((f) => f.path).toList();
setState(() {
_mediaPaths = newPaths;
});
widget.onChanged(files);
},
onMediaAdded: widget.onMediaAdded,
onMediaRemoved: widget.onMediaRemoved,
2025-09-02 16:22:17 +08:00
onMediaTapped: (filePath) {
if (widget.mediaType == MediaType.image) {
presentOpaque(SingleImageViewer(imageUrl: filePath), context);
2025-09-15 15:54:03 +08:00
} else {
2025-09-02 16:22:17 +08:00
showDialog(
context: context,
barrierColor: Colors.black54,
2025-09-15 15:54:03 +08:00
builder: (_) => VideoPlayerPopup(videoUrl: filePath),
2025-09-02 16:22:17 +08:00
);
}
},
2025-09-15 15:54:03 +08:00
isEdit: widget.isEdit,
2025-07-11 11:03:21 +08:00
),
),
2025-09-01 17:25:55 +08:00
const SizedBox(height: 8),
2025-09-15 15:54:03 +08:00
if (widget.isShowAI && widget.isEdit)
2025-07-31 17:33:26 +08:00
Padding(
2025-09-15 15:54:03 +08:00
padding: EdgeInsets.symmetric(horizontal: widget.horizontalPadding),
2025-07-31 17:33:26 +08:00
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隐患识别与处理'),
],
2025-07-11 11:03:21 +08:00
),
),
2025-07-31 17:33:26 +08:00
),
2025-07-11 11:03:21 +08:00
),
],
),
);
}
2025-08-29 09:52:48 +08:00
}