2026-07-13 03:17:32 +00:00
|
|
|
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';
|
2026-07-15 05:51:07 +00:00
|
|
|
import '../services/feedback_store.dart';
|
|
|
|
|
import '../services/hash_library_asset.dart';
|
|
|
|
|
import '../services/phash_matcher.dart';
|
|
|
|
|
import '../services/rgb_phash.dart';
|
2026-07-13 03:17:32 +00:00
|
|
|
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<PreviewScreen> createState() => _PreviewScreenState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _PreviewScreenState extends State<PreviewScreen> {
|
|
|
|
|
final _detector = CardDetector();
|
2026-07-15 05:51:07 +00:00
|
|
|
final _phash = RgbPhash();
|
|
|
|
|
final _matcher = PhashMatcher();
|
2026-07-13 03:17:32 +00:00
|
|
|
|
|
|
|
|
bool _loading = true;
|
|
|
|
|
String? _error;
|
|
|
|
|
ui.Image? _sourceImage;
|
|
|
|
|
CardBox? _locked;
|
|
|
|
|
CropResult? _crop;
|
|
|
|
|
String? _label;
|
|
|
|
|
double? _confidence;
|
2026-07-15 05:51:07 +00:00
|
|
|
List<PhashMatch> _matches = const [];
|
|
|
|
|
String? _matchError;
|
|
|
|
|
String? _guidePath;
|
|
|
|
|
bool? _feedbackAccurate;
|
|
|
|
|
bool _feedbackBusy = false;
|
2026-07-13 03:17:32 +00:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_run();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_sourceImage?.dispose();
|
|
|
|
|
_detector.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _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);
|
2026-07-15 05:51:07 +00:00
|
|
|
if (best == null || boxes.isEmpty) {
|
|
|
|
|
displayImage.dispose();
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
const SnackBar(content: Text('未识别到卡牌边框,请重拍')),
|
|
|
|
|
);
|
|
|
|
|
Navigator.of(context).pop();
|
|
|
|
|
return;
|
2026-07-13 03:17:32 +00:00
|
|
|
}
|
2026-07-15 05:51:07 +00:00
|
|
|
best = _detector.refineCorners(raster, best);
|
2026-07-13 03:17:32 +00:00
|
|
|
|
|
|
|
|
final crop = await CardCropper.cropAndCorrect(
|
|
|
|
|
imagePath: guidePath,
|
|
|
|
|
box: best,
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-15 05:51:07 +00:00
|
|
|
String? matchError;
|
|
|
|
|
var matches = <PhashMatch>[];
|
|
|
|
|
try {
|
|
|
|
|
final phashValue = _phash.fromJpegBytes(crop.bytes);
|
|
|
|
|
final library = await loadHashLibraryAsset();
|
|
|
|
|
matches = _matcher.topN(phashValue, library, n: 3);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
matchError = 'pHash 比对失败: $e';
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 03:17:32 +00:00
|
|
|
if (!mounted) {
|
|
|
|
|
displayImage.dispose();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setState(() {
|
|
|
|
|
_sourceImage = displayImage;
|
|
|
|
|
_locked = best;
|
|
|
|
|
_crop = crop;
|
|
|
|
|
_label = best?.label;
|
|
|
|
|
_confidence = best?.confidence;
|
2026-07-15 05:51:07 +00:00
|
|
|
_matches = matches;
|
|
|
|
|
_matchError = matchError;
|
|
|
|
|
_guidePath = guidePath;
|
2026-07-13 03:17:32 +00:00
|
|
|
_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,
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-07-15 05:51:07 +00:00
|
|
|
const SizedBox(height: 8),
|
2026-07-13 03:17:32 +00:00
|
|
|
Expanded(
|
2026-07-15 05:51:07 +00:00
|
|
|
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),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-07-13 03:17:32 +00:00
|
|
|
),
|
|
|
|
|
),
|
2026-07-15 05:51:07 +00:00
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildFeedbackRow(BuildContext context) {
|
|
|
|
|
final submitted = _feedbackAccurate != null;
|
|
|
|
|
return Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
children: [
|
2026-07-13 03:17:32 +00:00
|
|
|
Text(
|
2026-07-15 05:51:07 +00:00
|
|
|
submitted
|
|
|
|
|
? (_feedbackAccurate! ? '已记录:准确' : '已记录:不准确')
|
|
|
|
|
: '匹配结果反馈',
|
2026-07-13 03:17:32 +00:00
|
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
2026-07-15 05:51:07 +00:00
|
|
|
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,
|
2026-07-13 03:17:32 +00:00
|
|
|
),
|
2026-07-15 05:51:07 +00:00
|
|
|
minimumSize: const Size.fromHeight(44),
|
|
|
|
|
),
|
2026-07-13 03:17:32 +00:00
|
|
|
),
|
2026-07-15 05:51:07 +00:00
|
|
|
),
|
|
|
|
|
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),
|
2026-07-13 03:17:32 +00:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-07-15 05:51:07 +00:00
|
|
|
],
|
2026-07-13 03:17:32 +00:00
|
|
|
),
|
2026-07-15 05:51:07 +00:00
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _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);
|
|
|
|
|
},
|
2026-07-13 03:17:32 +00:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 05:51:07 +00:00
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 03:17:32 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|