2026-05-25 12:58:29 +08:00
|
|
|
import 'dart:io';
|
|
|
|
|
import 'package:flutter/material.dart';
|
2026-07-02 16:04:30 +08:00
|
|
|
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
2026-08-18 08:52:33 +08:00
|
|
|
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
|
2026-05-25 12:58:29 +08:00
|
|
|
import '../constants/report_constants.dart';
|
|
|
|
|
|
|
|
|
|
/// 媒体上传模块
|
|
|
|
|
class MediaUploader extends StatelessWidget {
|
|
|
|
|
final List<String> mediaFiles;
|
|
|
|
|
final VoidCallback onCameraTap;
|
|
|
|
|
final VoidCallback onVideoTap;
|
|
|
|
|
final VoidCallback onAddFromGallery;
|
|
|
|
|
final Function(int) onRemove;
|
|
|
|
|
final Function(int)? onTap;
|
|
|
|
|
|
|
|
|
|
const MediaUploader({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.mediaFiles,
|
|
|
|
|
required this.onCameraTap,
|
|
|
|
|
required this.onVideoTap,
|
|
|
|
|
required this.onAddFromGallery,
|
|
|
|
|
required this.onRemove,
|
|
|
|
|
this.onTap,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-07-02 16:04:30 +08:00
|
|
|
final loc = AppLocalizations.of(context);
|
2026-05-25 12:58:29 +08:00
|
|
|
return Container(
|
|
|
|
|
margin: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: AppDimensions.horizontalPadding,
|
|
|
|
|
),
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
decoration: BoxDecoration(
|
2026-08-18 08:52:33 +08:00
|
|
|
color: context.appColors.cardBackground,
|
2026-05-25 12:58:29 +08:00
|
|
|
borderRadius: BorderRadius.circular(AppDimensions.borderRadius),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
2026-08-18 08:52:33 +08:00
|
|
|
color: context.appColors.cardShadow,
|
2026-05-25 12:58:29 +08:00
|
|
|
blurRadius: AppDimensions.cardShadowBlur,
|
|
|
|
|
offset: const Offset(0, 2),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
2026-07-02 16:04:30 +08:00
|
|
|
loc.translate('report.upload_media'),
|
2026-08-18 08:52:33 +08:00
|
|
|
style: TextStyle(
|
2026-05-25 12:58:29 +08:00
|
|
|
fontSize: 16,
|
|
|
|
|
fontWeight: FontWeight.w600,
|
2026-08-18 08:52:33 +08:00
|
|
|
color: context.appColors.textPrimary,
|
2026-05-25 12:58:29 +08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
2026-08-07 08:49:29 +08:00
|
|
|
SizedBox(
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
child: SingleChildScrollView(
|
|
|
|
|
scrollDirection: Axis.horizontal,
|
|
|
|
|
physics: const ClampingScrollPhysics(),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
// 拍照按钮
|
|
|
|
|
_buildUploadButton(
|
2026-08-18 08:52:33 +08:00
|
|
|
context,
|
2026-08-07 08:49:29 +08:00
|
|
|
icon: Icons.camera_alt,
|
|
|
|
|
label: loc.translate('report.take_photo'),
|
|
|
|
|
onTap: onCameraTap,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
// 录像按钮
|
|
|
|
|
_buildUploadButton(
|
2026-08-18 08:52:33 +08:00
|
|
|
context,
|
2026-08-07 08:49:29 +08:00
|
|
|
icon: Icons.videocam,
|
|
|
|
|
label: loc.translate('report.record_video'),
|
|
|
|
|
onTap: onVideoTap,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
// 已上传的媒体预览
|
|
|
|
|
...mediaFiles.asMap().entries.map((entry) {
|
|
|
|
|
final String filePath = entry.value;
|
|
|
|
|
final bool isNetworkImage = filePath.startsWith('http');
|
|
|
|
|
final bool isVideo = _isVideoFile(filePath);
|
2026-07-02 16:04:30 +08:00
|
|
|
|
2026-08-07 08:49:29 +08:00
|
|
|
return GestureDetector(
|
|
|
|
|
onTap: () => onTap?.call(entry.key),
|
|
|
|
|
child: Stack(
|
|
|
|
|
children: [
|
|
|
|
|
Container(
|
|
|
|
|
width: 80,
|
|
|
|
|
height: 80,
|
|
|
|
|
margin: const EdgeInsets.only(right: 8),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
borderRadius: BorderRadius.circular(
|
|
|
|
|
AppDimensions.borderRadiusSmall,
|
2026-05-25 12:58:29 +08:00
|
|
|
),
|
2026-08-18 08:52:33 +08:00
|
|
|
color: context.appColors.fillBackground,
|
2026-08-07 08:49:29 +08:00
|
|
|
),
|
|
|
|
|
child: ClipRRect(
|
|
|
|
|
borderRadius: BorderRadius.circular(
|
|
|
|
|
AppDimensions.borderRadiusSmall,
|
2026-05-25 12:58:29 +08:00
|
|
|
),
|
2026-08-07 08:49:29 +08:00
|
|
|
child: isVideo
|
|
|
|
|
? _buildVideoThumbnail(filePath)
|
|
|
|
|
: (isNetworkImage
|
|
|
|
|
? Image.network(
|
|
|
|
|
filePath,
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
errorBuilder:
|
|
|
|
|
(context, error, stackTrace) {
|
|
|
|
|
return _buildPlaceholderIcon(
|
|
|
|
|
Icons.broken_image,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
: Image.file(
|
|
|
|
|
File(filePath),
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
errorBuilder:
|
|
|
|
|
(context, error, stackTrace) {
|
|
|
|
|
return _buildPlaceholderIcon(
|
|
|
|
|
Icons.broken_image,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
)),
|
2026-05-25 12:58:29 +08:00
|
|
|
),
|
|
|
|
|
),
|
2026-07-02 16:04:30 +08:00
|
|
|
Positioned(
|
2026-08-07 08:49:29 +08:00
|
|
|
top: 4,
|
|
|
|
|
right: 12,
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
onTap: () => onRemove(entry.key),
|
2026-07-02 16:04:30 +08:00
|
|
|
child: Container(
|
2026-08-07 08:49:29 +08:00
|
|
|
width: 20,
|
|
|
|
|
height: 20,
|
2026-07-02 16:04:30 +08:00
|
|
|
decoration: const BoxDecoration(
|
2026-08-07 08:49:29 +08:00
|
|
|
color: Colors.black54,
|
2026-07-02 16:04:30 +08:00
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
),
|
|
|
|
|
child: const Icon(
|
2026-08-07 08:49:29 +08:00
|
|
|
Icons.close,
|
|
|
|
|
size: 14,
|
2026-07-02 16:04:30 +08:00
|
|
|
color: Colors.white,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-08-07 08:49:29 +08:00
|
|
|
// 视频播放图标
|
|
|
|
|
if (isVideo)
|
|
|
|
|
Positioned(
|
|
|
|
|
child: Container(
|
|
|
|
|
width: 80,
|
|
|
|
|
height: 80,
|
|
|
|
|
margin: const EdgeInsets.only(right: 8),
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: Container(
|
|
|
|
|
padding: const EdgeInsets.all(8),
|
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
|
color: Colors.black38,
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
),
|
|
|
|
|
child: const Icon(
|
|
|
|
|
Icons.play_arrow,
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
size: 24,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}).toList(),
|
|
|
|
|
// 添加按钮
|
|
|
|
|
_buildUploadButton(
|
2026-08-18 08:52:33 +08:00
|
|
|
context,
|
2026-08-07 08:49:29 +08:00
|
|
|
icon: Icons.add,
|
|
|
|
|
label: '',
|
|
|
|
|
onTap: onAddFromGallery,
|
|
|
|
|
isAddButton: true,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-05-25 12:58:29 +08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 08:52:33 +08:00
|
|
|
Widget _buildUploadButton(
|
|
|
|
|
BuildContext context, {
|
2026-05-25 12:58:29 +08:00
|
|
|
required IconData icon,
|
|
|
|
|
required String label,
|
|
|
|
|
required VoidCallback onTap,
|
|
|
|
|
bool isAddButton = false,
|
|
|
|
|
}) {
|
|
|
|
|
return GestureDetector(
|
|
|
|
|
onTap: onTap,
|
|
|
|
|
child: Container(
|
|
|
|
|
width: 80,
|
|
|
|
|
height: 80,
|
|
|
|
|
decoration: BoxDecoration(
|
2026-08-18 08:52:33 +08:00
|
|
|
color: isAddButton
|
|
|
|
|
? context.appColors.fillBackground
|
|
|
|
|
: context.appColors.cardBackground,
|
2026-05-25 12:58:29 +08:00
|
|
|
borderRadius: BorderRadius.circular(AppDimensions.borderRadiusMedium),
|
|
|
|
|
border: Border.all(
|
2026-08-18 08:52:33 +08:00
|
|
|
color: isAddButton
|
|
|
|
|
? context.appColors.divider
|
|
|
|
|
: context.appColors.primary,
|
2026-05-25 12:58:29 +08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(
|
|
|
|
|
icon,
|
2026-08-18 08:52:33 +08:00
|
|
|
color: isAddButton
|
|
|
|
|
? context.appColors.textTertiary
|
|
|
|
|
: context.appColors.primary,
|
2026-05-25 12:58:29 +08:00
|
|
|
size: 24,
|
|
|
|
|
),
|
|
|
|
|
if (label.isNotEmpty) ...[
|
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
Text(
|
|
|
|
|
label,
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 12,
|
2026-08-18 08:52:33 +08:00
|
|
|
color: isAddButton
|
|
|
|
|
? context.appColors.textTertiary
|
|
|
|
|
: context.appColors.primary,
|
2026-05-25 12:58:29 +08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 判断是否为视频文件
|
|
|
|
|
bool _isVideoFile(String filePath) {
|
|
|
|
|
final lowerPath = filePath.toLowerCase();
|
|
|
|
|
return lowerPath.endsWith('.mp4') ||
|
|
|
|
|
lowerPath.endsWith('.mov') ||
|
|
|
|
|
lowerPath.endsWith('.avi') ||
|
|
|
|
|
lowerPath.endsWith('.mkv') ||
|
|
|
|
|
lowerPath.endsWith('.wmv') ||
|
|
|
|
|
lowerPath.endsWith('.flv');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 构建视频缩略图(显示占位符)
|
|
|
|
|
Widget _buildVideoThumbnail(String filePath) {
|
|
|
|
|
return Stack(
|
|
|
|
|
fit: StackFit.expand,
|
|
|
|
|
children: [
|
|
|
|
|
// 灰色背景
|
2026-07-02 16:04:30 +08:00
|
|
|
Container(color: Colors.grey[300]),
|
2026-05-25 12:58:29 +08:00
|
|
|
// 视频图标
|
2026-07-02 16:04:30 +08:00
|
|
|
const Center(child: Icon(Icons.videocam, color: Colors.grey, size: 32)),
|
2026-05-25 12:58:29 +08:00
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 构建占位图标
|
|
|
|
|
Widget _buildPlaceholderIcon(IconData icon) {
|
|
|
|
|
return Container(
|
|
|
|
|
color: Colors.grey[300],
|
2026-07-02 16:04:30 +08:00
|
|
|
child: Center(child: Icon(icon, color: Colors.grey, size: 32)),
|
2026-05-25 12:58:29 +08:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|