290 lines
7.8 KiB
Dart
290 lines
7.8 KiB
Dart
import 'dart:io';
|
||
import 'package:camera/camera.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||
import 'package:image_picker/image_picker.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:permission_handler/permission_handler.dart';
|
||
import 'package:uuid/uuid.dart';
|
||
|
||
/// 自定义相机预览 + 拍照队列
|
||
/// 对应 Android CheckPhotoActivity(多张拍照取证)
|
||
class CameraView extends StatefulWidget {
|
||
final void Function(String path) onCaptured;
|
||
const CameraView({super.key, required this.onCaptured});
|
||
|
||
@override
|
||
State<CameraView> createState() => _CameraViewState();
|
||
}
|
||
|
||
class _CameraViewState extends State<CameraView>
|
||
with WidgetsBindingObserver {
|
||
CameraController? _controller;
|
||
List<CameraDescription> _cameras = [];
|
||
int _cameraIdx = 0;
|
||
bool _initializing = true;
|
||
String? _error;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addObserver(this);
|
||
_setup();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
WidgetsBinding.instance.removeObserver(this);
|
||
_controller?.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||
final c = _controller;
|
||
if (c == null || !c.value.isInitialized) return;
|
||
if (state == AppLifecycleState.inactive ||
|
||
state == AppLifecycleState.paused) {
|
||
c.dispose();
|
||
} else if (state == AppLifecycleState.resumed) {
|
||
_setup();
|
||
}
|
||
}
|
||
|
||
Future<void> _setup() async {
|
||
setState(() {
|
||
_initializing = true;
|
||
_error = null;
|
||
});
|
||
try {
|
||
final cam = await Permission.camera.request();
|
||
if (!cam.isGranted) {
|
||
setState(() {
|
||
_initializing = false;
|
||
_error = '未授予相机权限';
|
||
});
|
||
return;
|
||
}
|
||
_cameras = await availableCameras();
|
||
if (_cameras.isEmpty) {
|
||
setState(() {
|
||
_initializing = false;
|
||
_error = '未检测到摄像头';
|
||
});
|
||
return;
|
||
}
|
||
_cameraIdx = _cameras.indexWhere(
|
||
(c) => c.lensDirection == CameraLensDirection.back,
|
||
);
|
||
if (_cameraIdx < 0) _cameraIdx = 0;
|
||
await _initController();
|
||
} catch (e) {
|
||
setState(() {
|
||
_initializing = false;
|
||
_error = '相机初始化失败: $e';
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _initController() async {
|
||
final c = CameraController(
|
||
_cameras[_cameraIdx],
|
||
ResolutionPreset.high,
|
||
enableAudio: false,
|
||
);
|
||
await c.initialize();
|
||
if (!mounted) {
|
||
await c.dispose();
|
||
return;
|
||
}
|
||
setState(() {
|
||
_controller = c;
|
||
_initializing = false;
|
||
});
|
||
}
|
||
|
||
Future<void> _switchCamera() async {
|
||
if (_cameras.length < 2) return;
|
||
_cameraIdx = (_cameraIdx + 1) % _cameras.length;
|
||
await _controller?.dispose();
|
||
await _initController();
|
||
}
|
||
|
||
Future<void> _takePicture() async {
|
||
final c = _controller;
|
||
if (c == null || !c.value.isInitialized) return;
|
||
if (c.value.isTakingPicture) return;
|
||
try {
|
||
EasyLoading.show(status: '拍照中…');
|
||
final xfile = await c.takePicture();
|
||
EasyLoading.dismiss();
|
||
// 复制到应用缓存目录(防止临时文件被清理)
|
||
final dir = await getTemporaryDirectory();
|
||
final out =
|
||
'${dir.photos_cache_path()}/${const Uuid().v4()}.jpg';
|
||
await File(xfile.path).copy(out);
|
||
widget.onCaptured(out);
|
||
} catch (e) {
|
||
EasyLoading.dismiss();
|
||
EasyLoading.showError('拍照失败: $e');
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_error != null) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.no_photography, size: 64, color: Colors.white),
|
||
const SizedBox(height: 8),
|
||
Text(_error!, style: const TextStyle(color: Colors.white)),
|
||
TextButton(onPressed: _setup, child: const Text('重试')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
if (_initializing || _controller == null) {
|
||
return const Center(
|
||
child: CircularProgressIndicator(color: Colors.white),
|
||
);
|
||
}
|
||
return Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
CameraPreview(_controller!),
|
||
// 顶部切换镜头
|
||
Positioned(
|
||
top: 16,
|
||
right: 16,
|
||
child: SafeArea(
|
||
child: IconButton(
|
||
icon: const Icon(Icons.cameraswitch, color: Colors.white),
|
||
onPressed: _switchCamera,
|
||
),
|
||
),
|
||
),
|
||
// 底部拍照按钮
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 32,
|
||
child: Center(
|
||
child: GestureDetector(
|
||
onTap: _takePicture,
|
||
child: Container(
|
||
width: 72,
|
||
height: 72,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: Colors.white, width: 4),
|
||
),
|
||
child: const Center(
|
||
child: Icon(Icons.circle, color: Colors.white, size: 56),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
extension _DirPath on Directory {
|
||
String photos_cache_path() {
|
||
final p = path;
|
||
if (p.endsWith('/')) return '${p}photos';
|
||
return '$p/photos';
|
||
}
|
||
}
|
||
|
||
/// 底部照片缩略图横排(可删除)
|
||
class PhotoThumbStrip extends StatelessWidget {
|
||
final List<String> paths;
|
||
final ValueChanged<int> onRemove;
|
||
final VoidCallback? onAdd;
|
||
const PhotoThumbStrip({
|
||
super.key,
|
||
required this.paths,
|
||
required this.onRemove,
|
||
this.onAdd,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox(
|
||
height: 88,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
itemCount: paths.length + 1,
|
||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||
itemBuilder: (ctx, i) {
|
||
if (i == paths.length) {
|
||
return InkWell(
|
||
onTap: onAdd,
|
||
child: Container(
|
||
width: 72,
|
||
height: 72,
|
||
decoration: BoxDecoration(
|
||
color: Colors.black12,
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: const Icon(Icons.add_a_photo, color: Colors.white70),
|
||
),
|
||
);
|
||
}
|
||
return Stack(
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: Image.file(
|
||
File(paths[i]),
|
||
width: 72,
|
||
height: 72,
|
||
fit: BoxFit.cover,
|
||
),
|
||
),
|
||
Positioned(
|
||
top: 0,
|
||
right: 0,
|
||
child: GestureDetector(
|
||
onTap: () => onRemove(i),
|
||
child: Container(
|
||
decoration: const BoxDecoration(
|
||
color: Colors.black54,
|
||
shape: BoxShape.circle,
|
||
),
|
||
padding: const EdgeInsets.all(2),
|
||
child: const Icon(
|
||
Icons.close,
|
||
size: 14,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 备选:从相册选择(用 image_picker)
|
||
class AlbumPicker {
|
||
static final _picker = ImagePicker();
|
||
|
||
static Future<List<String>> pick({int max = 9}) async {
|
||
final files = await _picker.pickMultiImage(
|
||
imageQuality: 85,
|
||
maxWidth: 1920,
|
||
);
|
||
if (files.isEmpty) return const [];
|
||
return files.take(max).map((f) => f.path).toList();
|
||
}
|
||
}
|