import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:image/image.dart' as img; import 'package:path_provider/path_provider.dart'; import '../services/card_cropper.dart'; import '../services/card_detector.dart'; import '../services/feedback_store.dart'; import '../services/hash_library_asset.dart'; import '../services/phash_matcher.dart'; import '../services/rgb_phash.dart'; import '../widgets/guide_frame.dart'; /// Post-capture: detect / lock border on still image, then crop for preview. class PreviewScreen extends StatefulWidget { const PreviewScreen({ super.key, required this.imagePath, this.captureViewport, }); final String imagePath; /// Camera preview viewport used to map the shooting guide into image pixels. /// Null for gallery picks — guide is applied in image space directly. final Size? captureViewport; @override State createState() => _PreviewScreenState(); } class _PreviewScreenState extends State { final _detector = CardDetector(); final _phash = RgbPhash(); final _matcher = PhashMatcher(); bool _loading = true; String? _error; ui.Image? _sourceImage; CardBox? _locked; CropResult? _crop; String? _label; double? _confidence; List _matches = const []; String? _matchError; String? _guidePath; bool? _feedbackAccurate; bool _feedbackBusy = false; @override void initState() { super.initState(); _run(); } @override void dispose() { _sourceImage?.dispose(); _detector.dispose(); super.dispose(); } Future _run() async { try { // Decode with Flutter (handles gallery HEIC/EXIF well, keeps sharpness). final raw = await File(widget.imagePath).readAsBytes(); final displayCodec = await ui.instantiateImageCodec(raw); final displayFrame = await displayCodec.getNextFrame(); final fullImage = displayFrame.image; final rgba = await fullImage.toByteData( format: ui.ImageByteFormat.rawRgba, ); if (rgba == null) { fullImage.dispose(); throw StateError('无法读取像素'); } final fullRaster = img.Image.fromBytes( width: fullImage.width, height: fullImage.height, bytes: rgba.buffer, bytesOffset: rgba.offsetInBytes, numChannels: 4, order: img.ChannelOrder.rgba, ); fullImage.dispose(); // Initial crop to the shooting guide — exclude everything outside the frame. final imageSize = Size( fullRaster.width.toDouble(), fullRaster.height.toDouble(), ); final guide = widget.captureViewport != null ? GuideFrame.inCoverImage( viewport: widget.captureViewport!, imageSize: imageSize, ) : GuideFrame.inImage(imageSize); final gx = guide.left.round().clamp(0, fullRaster.width - 1); final gy = guide.top.round().clamp(0, fullRaster.height - 1); final gw = guide.width.round().clamp(1, fullRaster.width - gx); final gh = guide.height.round().clamp(1, fullRaster.height - gy); final raster = img.copyCrop( fullRaster, x: gx, y: gy, width: gw, height: gh, ); final dir = await getTemporaryDirectory(); final guidePath = '${dir.path}/card_guide_${DateTime.now().millisecondsSinceEpoch}.jpg'; await File(guidePath).writeAsBytes( img.encodeJpg(raster, quality: 100), flush: true, ); // Build a ui.Image for the guide-cropped preview. final guideBytes = img.encodeJpg(raster, quality: 100); final guideCodec = await ui.instantiateImageCodec(guideBytes); final guideFrame = await guideCodec.getNextFrame(); final displayImage = guideFrame.image; final boxes = await _detector.detectFile(guidePath); final guideSize = Size( displayImage.width.toDouble(), displayImage.height.toDouble(), ); var best = _detector.pickBest(boxes, imageSize: guideSize); if (best == null || boxes.isEmpty) { displayImage.dispose(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('未识别到卡牌边框,请重拍')), ); Navigator.of(context).pop(); return; } best = _detector.refineCorners(raster, best); final crop = await CardCropper.cropAndCorrect( imagePath: guidePath, box: best, ); String? matchError; var matches = []; try { final phashValue = _phash.fromJpegBytes(crop.bytes); final library = await loadHashLibraryAsset(); matches = _matcher.topN(phashValue, library, n: 3); } catch (e) { matchError = 'pHash 比对失败: $e'; } if (!mounted) { displayImage.dispose(); return; } setState(() { _sourceImage = displayImage; _locked = best; _crop = crop; _label = best?.label; _confidence = best?.confidence; _matches = matches; _matchError = matchError; _guidePath = guidePath; _loading = false; }); } catch (e) { if (!mounted) return; setState(() { _error = '处理失败: $e'; _loading = false; }); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('边框锁定预览'), backgroundColor: Colors.transparent, ), body: SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: _loading ? const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('正在锁定卡牌边框…'), ], ), ) : _error != null ? Center(child: Text(_error!, textAlign: TextAlign.center)) : _buildContent(context), ), ), ); } Widget _buildContent(BuildContext context) { final meta = _locked != null ? (_locked!.corners != null ? '已锁定倾斜边框${_label != null ? ' · $_label' : ''}' '${_confidence != null ? ' · ${(_confidence! * 100).toStringAsFixed(0)}%' : ''}' : '已锁定边框${_label != null ? ' · $_label' : ''}' '${_confidence != null ? ' · ${(_confidence! * 100).toStringAsFixed(0)}%' : ''}') : '未检出边框 · 已用引导框裁剪'; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( meta, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.white70, ), ), const SizedBox(height: 8), Expanded( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( height: 220, child: _LockedPhotoView( image: _sourceImage!, locked: _locked, ), ), const SizedBox(height: 8), Text( '裁剪结果', style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 6), SizedBox( height: 180, child: Center( child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), boxShadow: const [ BoxShadow( color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 12), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.memory( _crop!.bytes, fit: BoxFit.contain, filterQuality: FilterQuality.high, ), ), ), ), ), const SizedBox(height: 8), _buildMatchSection(context), const SizedBox(height: 10), _buildFeedbackRow(context), const SizedBox(height: 10), FilledButton.icon( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.camera_alt_outlined), label: const Text('重新拍摄'), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(48), ), ), const SizedBox(height: 8), ], ), ), ), ], ); } Widget _buildFeedbackRow(BuildContext context) { final submitted = _feedbackAccurate != null; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( submitted ? (_feedbackAccurate! ? '已记录:准确' : '已记录:不准确') : '匹配结果反馈', style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: submitted || _feedbackBusy ? null : () => _submitFeedback(accurate: true), icon: const Icon(Icons.thumb_up_alt_outlined, size: 18), label: const Text('准确'), style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFF22C55E), side: BorderSide( color: _feedbackAccurate == true ? const Color(0xFF22C55E) : Colors.white24, ), minimumSize: const Size.fromHeight(44), ), ), ), const SizedBox(width: 10), Expanded( child: OutlinedButton.icon( onPressed: submitted || _feedbackBusy ? null : () => _submitFeedback(accurate: false), icon: const Icon(Icons.thumb_down_alt_outlined, size: 18), label: const Text('不准确'), style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFFEF4444), side: BorderSide( color: _feedbackAccurate == false ? const Color(0xFFEF4444) : Colors.white24, ), minimumSize: const Size.fromHeight(44), ), ), ), ], ), ], ); } Future _submitFeedback({required bool accurate}) async { final crop = _crop; final guidePath = _guidePath; if (crop == null || guidePath == null) return; if (_feedbackBusy || _feedbackAccurate != null) return; setState(() => _feedbackBusy = true); try { final top = _matches.isEmpty ? null : _matches.first; await FeedbackStore.instance.add( accurate: accurate, photoSourcePath: guidePath, cropBytes: crop.bytes, matchCardId: top?.cardId, matchScore: top?.score, matchDistance: top?.distance, matchFilename: top?.filename, matchConfident: top?.confident, ); if (!mounted) return; setState(() { _feedbackAccurate = accurate; _feedbackBusy = false; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(accurate ? '已记录为准确' : '已记录为不准确'), duration: const Duration(seconds: 1), ), ); } catch (e) { if (!mounted) return; setState(() => _feedbackBusy = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('反馈保存失败: $e')), ); } } Widget _buildMatchSection(BuildContext context) { final theme = Theme.of(context).textTheme; if (_matchError != null) { return Text( _matchError!, style: theme.bodySmall?.copyWith(color: Colors.orangeAccent), ); } if (_matches.isEmpty) { return Text( '本地图库暂无匹配结果', style: theme.bodySmall?.copyWith(color: Colors.white54), ); } final top = _matches.first; final status = top.confident ? 'confident' : 'low'; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( '相似结果 · top1 ${top.cardId} ${top.score} ($status)', style: theme.titleSmall, ), const SizedBox(height: 8), SizedBox( height: 148, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: _matches.length, separatorBuilder: (_, __) => const SizedBox(width: 10), itemBuilder: (context, index) { final m = _matches[index]; return _MatchThumb(rank: index + 1, match: m); }, ), ), ], ); } } class _MatchThumb extends StatelessWidget { const _MatchThumb({required this.rank, required this.match}); final int rank; final PhashMatch match; @override Widget build(BuildContext context) { final theme = Theme.of(context).textTheme; final asset = match.assetPath; return SizedBox( width: 96, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(8), child: ColoredBox( color: Colors.black26, child: asset == null ? const Center( child: Icon(Icons.image_not_supported_outlined), ) : Image.asset( asset, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const Center( child: Icon(Icons.broken_image_outlined), ), ), ), ), ), const SizedBox(height: 4), Text( '#$rank ${match.score}', maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.labelSmall?.copyWith(color: Colors.white70), ), Text( match.cardId, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.labelSmall?.copyWith(color: Colors.white54), ), ], ), ); } } class _LockedPhotoView extends StatelessWidget { const _LockedPhotoView({ required this.image, required this.locked, }); final ui.Image image; final CardBox? locked; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final view = Size(constraints.maxWidth, constraints.maxHeight); final img = Size(image.width.toDouble(), image.height.toDouble()); final fitted = _contain(img, view); return ClipRRect( borderRadius: BorderRadius.circular(12), child: ColoredBox( color: Colors.black26, child: Stack( children: [ Positioned.fromRect( rect: fitted, child: RawImage( image: image, fit: BoxFit.fill, filterQuality: FilterQuality.high, ), ), if (locked != null) Positioned.fill( child: CustomPaint( painter: _LockPainter( box: locked!, imageSize: img, fitted: fitted, ), ), ), ], ), ), ); }, ); } static Rect _contain(Size image, Size view) { final scale = (view.width / image.width < view.height / image.height) ? view.width / image.width : view.height / image.height; final w = image.width * scale; final h = image.height * scale; return Rect.fromLTWH( (view.width - w) / 2, (view.height - h) / 2, w, h, ); } } class _LockPainter extends CustomPainter { _LockPainter({ required this.box, required this.imageSize, required this.fitted, }); final CardBox box; final Size imageSize; final Rect fitted; Offset _map(Offset p) => Offset( fitted.left + p.dx / imageSize.width * fitted.width, fitted.top + p.dy / imageSize.height * fitted.height, ); @override void paint(Canvas canvas, Size size) { final q = box.quad.map(_map).toList(growable: false); final path = Path() ..moveTo(q[0].dx, q[0].dy) ..lineTo(q[1].dx, q[1].dy) ..lineTo(q[2].dx, q[2].dy) ..lineTo(q[3].dx, q[3].dy) ..close(); final paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 3 ..color = const Color(0xFF22C55E); canvas.drawPath(path, paint); final corner = Paint() ..color = const Color(0xFF22C55E) ..strokeWidth = 4 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; const len = 22.0; for (var i = 0; i < 4; i++) { final p = q[i]; final prev = q[(i + 3) % 4]; final next = q[(i + 1) % 4]; final toPrev = prev - p; final toNext = next - p; final dPrev = toPrev.distance; final dNext = toNext.distance; if (dPrev < 1 || dNext < 1) continue; final a = p + toPrev * (len / dPrev); final b = p + toNext * (len / dNext); canvas.drawLine(p, a, corner); canvas.drawLine(p, b, corner); } } @override bool shouldRepaint(covariant _LockPainter old) { return old.box != box || old.fitted != fitted || old.imageSize != imageSize; } }