2026-07-13 03:17:32 +00:00
|
|
|
import 'package:camera/camera.dart';
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:image_picker/image_picker.dart';
|
|
|
|
|
|
2026-07-15 05:51:07 +00:00
|
|
|
import '../services/card_scan_gate.dart';
|
2026-07-13 03:17:32 +00:00
|
|
|
import '../widgets/guide_overlay.dart';
|
2026-07-15 05:51:07 +00:00
|
|
|
import 'feedback_list_screen.dart';
|
2026-07-13 03:17:32 +00:00
|
|
|
import 'preview_screen.dart';
|
|
|
|
|
|
|
|
|
|
/// Capture only — no live ML detection. Locking happens on [PreviewScreen].
|
|
|
|
|
class CaptureScreen extends StatefulWidget {
|
|
|
|
|
const CaptureScreen({super.key});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<CaptureScreen> createState() => _CaptureScreenState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _CaptureScreenState extends State<CaptureScreen>
|
|
|
|
|
with WidgetsBindingObserver {
|
|
|
|
|
final _picker = ImagePicker();
|
|
|
|
|
|
|
|
|
|
CameraController? _camera;
|
|
|
|
|
List<CameraDescription> _cameras = const [];
|
|
|
|
|
bool _ready = false;
|
|
|
|
|
bool _busy = false;
|
|
|
|
|
String? _error;
|
|
|
|
|
Size? _previewSize;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
WidgetsBinding.instance.addObserver(this);
|
|
|
|
|
_initCamera();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
|
|
|
_camera?.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
|
|
|
final cam = _camera;
|
|
|
|
|
if (cam == null || !cam.value.isInitialized) return;
|
|
|
|
|
if (state == AppLifecycleState.inactive) {
|
|
|
|
|
cam.dispose();
|
|
|
|
|
_camera = null;
|
|
|
|
|
_ready = false;
|
|
|
|
|
} else if (state == AppLifecycleState.resumed) {
|
|
|
|
|
_initCamera();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _initCamera() async {
|
|
|
|
|
try {
|
|
|
|
|
_cameras = await availableCameras();
|
|
|
|
|
if (_cameras.isEmpty) {
|
|
|
|
|
setState(() => _error = '没有可用相机');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final back = _cameras.firstWhere(
|
|
|
|
|
(c) => c.lensDirection == CameraLensDirection.back,
|
|
|
|
|
orElse: () => _cameras.first,
|
|
|
|
|
);
|
|
|
|
|
final controller = CameraController(
|
|
|
|
|
back,
|
|
|
|
|
ResolutionPreset.high,
|
|
|
|
|
enableAudio: false,
|
|
|
|
|
);
|
|
|
|
|
await controller.initialize();
|
|
|
|
|
if (!mounted) {
|
|
|
|
|
await controller.dispose();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_camera = controller;
|
|
|
|
|
setState(() {
|
|
|
|
|
_ready = true;
|
|
|
|
|
_error = null;
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setState(() => _error = '相机初始化失败: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 05:51:07 +00:00
|
|
|
Future<void> _openPreviewIfBorderFound(String path) async {
|
|
|
|
|
final found = await CardScanGate.hasDetectableBorder(
|
|
|
|
|
imagePath: path,
|
|
|
|
|
captureViewport: _previewSize,
|
|
|
|
|
);
|
2026-07-13 03:17:32 +00:00
|
|
|
if (!mounted) return;
|
2026-07-15 05:51:07 +00:00
|
|
|
if (!found) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
const SnackBar(
|
|
|
|
|
content: Text('未识别到卡牌边框,请对准框内后重拍'),
|
|
|
|
|
duration: Duration(seconds: 2),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-13 03:17:32 +00:00
|
|
|
await Navigator.of(context).push(
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
builder: (_) => PreviewScreen(
|
|
|
|
|
imagePath: path,
|
|
|
|
|
captureViewport: _previewSize,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _capture() async {
|
|
|
|
|
if (_busy) return;
|
|
|
|
|
final cam = _camera;
|
|
|
|
|
if (cam == null || !cam.value.isInitialized) return;
|
|
|
|
|
|
|
|
|
|
setState(() => _busy = true);
|
|
|
|
|
try {
|
|
|
|
|
final shot = await cam.takePicture();
|
2026-07-15 05:51:07 +00:00
|
|
|
await _openPreviewIfBorderFound(shot.path);
|
2026-07-13 03:17:32 +00:00
|
|
|
} catch (e) {
|
|
|
|
|
if (mounted) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
SnackBar(content: Text('拍照失败: $e')),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (mounted) setState(() => _busy = false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _pickGallery() async {
|
|
|
|
|
if (_busy) return;
|
|
|
|
|
setState(() => _busy = true);
|
|
|
|
|
try {
|
|
|
|
|
final file = await _picker.pickImage(
|
|
|
|
|
source: ImageSource.gallery,
|
|
|
|
|
imageQuality: 100,
|
|
|
|
|
requestFullMetadata: true,
|
|
|
|
|
);
|
|
|
|
|
if (file == null) return;
|
2026-07-15 05:51:07 +00:00
|
|
|
await _openPreviewIfBorderFound(file.path);
|
2026-07-13 03:17:32 +00:00
|
|
|
} catch (e) {
|
|
|
|
|
if (mounted) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
SnackBar(content: Text('选图失败: $e')),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (mounted) setState(() => _busy = false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _switchCamera() async {
|
|
|
|
|
if (_cameras.length < 2 || _busy) return;
|
|
|
|
|
final current = _camera?.description;
|
|
|
|
|
final next = _cameras.firstWhere(
|
|
|
|
|
(c) => c.lensDirection != current?.lensDirection,
|
|
|
|
|
orElse: () => _cameras.first,
|
|
|
|
|
);
|
|
|
|
|
setState(() => _ready = false);
|
|
|
|
|
await _camera?.dispose();
|
|
|
|
|
final controller = CameraController(
|
|
|
|
|
next,
|
|
|
|
|
ResolutionPreset.high,
|
|
|
|
|
enableAudio: false,
|
|
|
|
|
);
|
|
|
|
|
await controller.initialize();
|
|
|
|
|
if (!mounted) {
|
|
|
|
|
await controller.dispose();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_camera = controller;
|
|
|
|
|
setState(() => _ready = true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return Scaffold(
|
|
|
|
|
body: Stack(
|
|
|
|
|
fit: StackFit.expand,
|
|
|
|
|
children: [
|
|
|
|
|
_buildPreview(),
|
|
|
|
|
SafeArea(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
2026-07-15 05:51:07 +00:00
|
|
|
child: Row(
|
2026-07-13 03:17:32 +00:00
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
2026-07-15 05:51:07 +00:00
|
|
|
Expanded(
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
'卡牌扫描',
|
|
|
|
|
style: Theme.of(context)
|
|
|
|
|
.textTheme
|
|
|
|
|
.headlineSmall
|
|
|
|
|
?.copyWith(
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
),
|
2026-07-13 03:17:32 +00:00
|
|
|
),
|
2026-07-15 05:51:07 +00:00
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
Text(
|
|
|
|
|
'将卡牌放入框内后拍照,识别不到边框会提示重拍',
|
|
|
|
|
style:
|
|
|
|
|
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
|
|
|
color: Colors.white70,
|
|
|
|
|
),
|
2026-07-13 03:17:32 +00:00
|
|
|
),
|
2026-07-15 05:51:07 +00:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
_RoundButton(
|
|
|
|
|
icon: Icons.rate_review_outlined,
|
|
|
|
|
onTap: _busy
|
|
|
|
|
? null
|
|
|
|
|
: () {
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
builder: (_) => const FeedbackListScreen(),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-07-13 03:17:32 +00:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
if (_busy)
|
|
|
|
|
const ColoredBox(
|
|
|
|
|
color: Color(0x66000000),
|
|
|
|
|
child: Center(child: CircularProgressIndicator()),
|
|
|
|
|
),
|
|
|
|
|
Positioned(
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
bottom: 0,
|
|
|
|
|
child: _buildControls(),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildPreview() {
|
|
|
|
|
if (_error != null) {
|
|
|
|
|
return Center(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(24),
|
|
|
|
|
child: Text(_error!, textAlign: TextAlign.center),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
final cam = _camera;
|
|
|
|
|
if (!_ready || cam == null || !cam.value.isInitialized) {
|
|
|
|
|
return const Center(child: CircularProgressIndicator());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return LayoutBuilder(
|
|
|
|
|
builder: (context, constraints) {
|
|
|
|
|
final previewSize = Size(constraints.maxWidth, constraints.maxHeight);
|
|
|
|
|
_previewSize = previewSize;
|
|
|
|
|
return Stack(
|
|
|
|
|
fit: StackFit.expand,
|
|
|
|
|
children: [
|
|
|
|
|
FittedBox(
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
width: cam.value.previewSize?.height ?? previewSize.width,
|
|
|
|
|
height: cam.value.previewSize?.width ?? previewSize.height,
|
|
|
|
|
child: CameraPreview(cam),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
GuideOverlay(size: previewSize),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildControls() {
|
|
|
|
|
return SafeArea(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(28, 12, 28, 24),
|
|
|
|
|
child: Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
|
children: [
|
|
|
|
|
_RoundButton(
|
|
|
|
|
icon: Icons.photo_library_outlined,
|
|
|
|
|
onTap: _busy ? null : _pickGallery,
|
|
|
|
|
),
|
|
|
|
|
GestureDetector(
|
|
|
|
|
onTap: _busy ? null : _capture,
|
|
|
|
|
child: Container(
|
|
|
|
|
width: 76,
|
|
|
|
|
height: 76,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
border: Border.all(color: Colors.white, width: 4),
|
|
|
|
|
),
|
|
|
|
|
padding: const EdgeInsets.all(5),
|
|
|
|
|
child: Container(
|
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
_RoundButton(
|
|
|
|
|
icon: Icons.flip_camera_ios_outlined,
|
|
|
|
|
onTap: _busy ? null : _switchCamera,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _RoundButton extends StatelessWidget {
|
|
|
|
|
const _RoundButton({required this.icon, this.onTap});
|
|
|
|
|
|
|
|
|
|
final IconData icon;
|
|
|
|
|
final VoidCallback? onTap;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return Material(
|
|
|
|
|
color: Colors.white12,
|
|
|
|
|
shape: const CircleBorder(),
|
|
|
|
|
child: InkWell(
|
|
|
|
|
customBorder: const CircleBorder(),
|
|
|
|
|
onTap: onTap,
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
width: 52,
|
|
|
|
|
height: 52,
|
|
|
|
|
child: Icon(icon, color: Colors.white),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|