351 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Dart
		
	
	
			
		
		
	
	
			351 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Dart
		
	
	
| import 'dart:io';
 | ||
| import 'package:flutter/material.dart';
 | ||
| import 'package:image_picker/image_picker.dart';
 | ||
| import 'package:wechat_assets_picker/wechat_assets_picker.dart';
 | ||
| import 'package:photo_manager/photo_manager.dart';
 | ||
| 
 | ||
| import 'ItemWidgetFactory.dart';
 | ||
| 
 | ||
| /// 媒体选择类型
 | ||
| enum MediaType { image, video }
 | ||
| 
 | ||
| /// 纵向滚动四列的媒体添加组件,支持拍摄、全屏相册多选,以及初始地址列表展示
 | ||
| /// 使用示例:
 | ||
| /// MediaPickerGrid(
 | ||
| ///   maxCount: 4,
 | ||
| ///   mediaType: MediaType.video,
 | ||
| ///   initialMediaPaths: ['https://...', '/local/path.png'],
 | ||
| ///   onChanged: (List<File> medias) {},
 | ||
| ///   onMediaAdded: (String path) {},
 | ||
| ///   onMediaRemoved: (String path) {},
 | ||
| /// ),
 | ||
| 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;
 | ||
| 
 | ||
|   const MediaPickerRow({
 | ||
|     Key? key,
 | ||
|     this.maxCount = 4,
 | ||
|     this.mediaType = MediaType.image,
 | ||
|     this.initialMediaPaths,
 | ||
|     required this.onChanged,
 | ||
|     this.onMediaAdded,
 | ||
|     this.onMediaRemoved,
 | ||
|   }) : super(key: key);
 | ||
| 
 | ||
|   @override
 | ||
|   _MediaPickerGridState createState() => _MediaPickerGridState();
 | ||
| }
 | ||
| 
 | ||
| class _MediaPickerGridState extends State<MediaPickerRow> {
 | ||
|   final ImagePicker _picker = ImagePicker();
 | ||
|   late List<String> _mediaPaths;
 | ||
| 
 | ||
|   @override
 | ||
|   void initState() {
 | ||
|     super.initState();
 | ||
|     _mediaPaths = widget.initialMediaPaths != null
 | ||
|         ? widget.initialMediaPaths!.take(widget.maxCount).toList()
 | ||
|         : [];
 | ||
|     WidgetsBinding.instance.addPostFrameCallback((_) {
 | ||
|       widget.onChanged(
 | ||
|         _mediaPaths.map((p) => p.startsWith('http') ? File('') : File(p)).toList(),
 | ||
|       );
 | ||
|     });
 | ||
|   }
 | ||
| 
 | ||
|   Future<void> _showPickerOptions() async {
 | ||
|     showModalBottomSheet(
 | ||
|       context: context,
 | ||
|       builder: (_) => SafeArea(
 | ||
|         child: Wrap(
 | ||
|           children: [
 | ||
|             ListTile(
 | ||
|               leading: Icon(
 | ||
|                 widget.mediaType == MediaType.image
 | ||
|                     ? Icons.camera_alt
 | ||
|                     : Icons.videocam,
 | ||
|               ),
 | ||
|               title: Text(
 | ||
|                 widget.mediaType == MediaType.image ? '拍照' : '拍摄视频',
 | ||
|               ),
 | ||
|               onTap: () {
 | ||
|                 Navigator.of(context).pop();
 | ||
|                 _pickCamera();
 | ||
|               },
 | ||
|             ),
 | ||
|             ListTile(
 | ||
|               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(
 | ||
|               leading: const Icon(Icons.close),
 | ||
|               title: const Text('取消'),
 | ||
|               onTap: () => Navigator.of(context).pop(),
 | ||
|             ),
 | ||
|           ],
 | ||
|         ),
 | ||
|       ),
 | ||
|     );
 | ||
|   }
 | ||
| 
 | ||
|   Future<void> _pickCamera() async {
 | ||
|     if (_mediaPaths.length >= widget.maxCount) return;
 | ||
|     try {
 | ||
|       XFile? picked;
 | ||
|       if (widget.mediaType == MediaType.image) {
 | ||
|         picked = await _picker.pickImage(source: ImageSource.camera);
 | ||
|       } else {
 | ||
|         picked = await _picker.pickVideo(source: ImageSource.camera);
 | ||
|       }
 | ||
|       if (picked != null) {
 | ||
|         final path = picked.path;
 | ||
|         setState(() => _mediaPaths.add(path));
 | ||
|         widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
 | ||
|         widget.onMediaAdded?.call(path);
 | ||
|       }
 | ||
|     } catch (e) {
 | ||
|       debugPrint('拍摄失败: $e');
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   Future<void> _pickGallery() async {
 | ||
|     if (_mediaPaths.length >= widget.maxCount) return;
 | ||
|     final permission = await PhotoManager.requestPermissionExtend();
 | ||
|     if (permission != PermissionState.authorized &&
 | ||
|         permission != PermissionState.limited) {
 | ||
|       ScaffoldMessenger.of(context).showSnackBar(
 | ||
|         const SnackBar(content: Text('请到设置中开启相册访问权限')),
 | ||
|       );
 | ||
|       return;
 | ||
|     }
 | ||
|     try {
 | ||
|       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;
 | ||
|           final file = await asset.file;
 | ||
|           if (file != null) {
 | ||
|             final path = file.path;
 | ||
|             _mediaPaths.add(path);
 | ||
|             widget.onMediaAdded?.call(path);
 | ||
|           }
 | ||
|         }
 | ||
|         setState(() {});
 | ||
|         widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
 | ||
|       }
 | ||
|     } catch (e) {
 | ||
|       debugPrint('相册选择失败: $e');
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   void _removeMedia(int index) {
 | ||
|     final removed = _mediaPaths[index];
 | ||
|     setState(() => _mediaPaths.removeAt(index));
 | ||
|     widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
 | ||
|     widget.onMediaRemoved?.call(removed);
 | ||
|   }
 | ||
| 
 | ||
|   @override
 | ||
|   Widget build(BuildContext context) {
 | ||
|     return GridView.builder(
 | ||
|       shrinkWrap: true,
 | ||
|       physics: const NeverScrollableScrollPhysics(),
 | ||
|       gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
 | ||
|         crossAxisCount: 4,
 | ||
|         crossAxisSpacing: 8,
 | ||
|         mainAxisSpacing: 8,
 | ||
|         childAspectRatio: 1,
 | ||
|         mainAxisExtent: 80,
 | ||
| 
 | ||
|       ),
 | ||
|       itemCount: _mediaPaths.length < widget.maxCount
 | ||
|           ? _mediaPaths.length + 1
 | ||
|           : widget.maxCount,
 | ||
|       itemBuilder: (context, index) {
 | ||
|         if (index < _mediaPaths.length) {
 | ||
|           final path = _mediaPaths[index];
 | ||
|           final isNetwork = path.startsWith('http');
 | ||
|           return Stack(
 | ||
|             children: [
 | ||
|               ClipRRect(
 | ||
|                 borderRadius: BorderRadius.circular(5),
 | ||
|                 child: widget.mediaType == MediaType.image
 | ||
|                     ? (isNetwork
 | ||
|                     ? Image.network(path, fit: BoxFit.cover,width: 80, height: 80,)
 | ||
|                     : Image.file(File(path), width: 80, height: 80, fit: BoxFit.cover))
 | ||
|                     : Container(
 | ||
|                   color: Colors.black12,
 | ||
|                   child: const Center(
 | ||
|                     child: Icon(
 | ||
|                       Icons.videocam,
 | ||
|                       color: Colors.white70,
 | ||
|                     ),
 | ||
|                   ),
 | ||
|                 ),
 | ||
|               ),
 | ||
|               Positioned(
 | ||
|                 top: -15,
 | ||
|                 right: -10,
 | ||
|                 child: IconButton(
 | ||
|                   icon: const Icon(Icons.cancel, size: 20, color: Colors.red),
 | ||
|                   onPressed: () => _removeMedia(index),
 | ||
|                 ),
 | ||
|               ),
 | ||
|             ],
 | ||
|           );
 | ||
|         } else {
 | ||
|           return GestureDetector(
 | ||
|             onTap: _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),
 | ||
|               ),
 | ||
|             ),
 | ||
|           );
 | ||
|         }
 | ||
|       },
 | ||
|     );
 | ||
|   }
 | ||
| }
 | ||
| 
 | ||
| /// 照片上传区域组件,使用纵向四列Grid展示
 | ||
| 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 VoidCallback onAiIdentify;
 | ||
|   final bool isShowAI;
 | ||
|   final double horizontalPadding;
 | ||
|   final bool isRequired;
 | ||
|   final bool isShowNum;
 | ||
| 
 | ||
|   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 = 10,
 | ||
|     this.onMediaAdded,
 | ||
|     this.onMediaRemoved,
 | ||
|     this.isRequired = false,
 | ||
|     this.isShowNum = true,
 | ||
|   }) : super(key: key);
 | ||
| 
 | ||
|   @override
 | ||
|   _RepairedPhotoSectionState createState() => _RepairedPhotoSectionState();
 | ||
| }
 | ||
| 
 | ||
| class _RepairedPhotoSectionState extends State<RepairedPhotoSection> {
 | ||
|   late List<String> _mediaPaths;
 | ||
| 
 | ||
|   @override
 | ||
|   void initState() {
 | ||
|     super.initState();
 | ||
|     _mediaPaths = widget.initialMediaPaths?.take(widget.maxCount).toList() ?? [];
 | ||
|     WidgetsBinding.instance.addPostFrameCallback((_) {
 | ||
|       widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
 | ||
|     });
 | ||
|   }
 | ||
| 
 | ||
|   @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,
 | ||
|               onChanged: (files) {
 | ||
|                 final newPaths = files.map((f) => f.path).toList();
 | ||
|                 setState(() {
 | ||
|                   _mediaPaths = newPaths;
 | ||
|                 });
 | ||
|                 widget.onChanged(files);
 | ||
|               },
 | ||
|               onMediaAdded: widget.onMediaAdded,
 | ||
|               onMediaRemoved: widget.onMediaRemoved,
 | ||
|             ),
 | ||
|           ),
 | ||
|           const SizedBox(height: 20),
 | ||
|           if (widget.isShowAI)
 | ||
|             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隐患识别与处理'),
 | ||
|                     ],
 | ||
|                   ),
 | ||
|                 ),
 | ||
|               ),
 | ||
|             ),
 | ||
|         ],
 | ||
|       ),
 | ||
|     );
 | ||
|   }
 | ||
| } |