Initial commit: Flutter card recognition demo

Camera capture, corner detection/refinement, and preview crop pipeline for trading cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
a1518 2026-07-12 20:17:32 -07:00
commit ccf8eeb21d
76 changed files with 4526 additions and 0 deletions

43
.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

33
.metadata Normal file
View File

@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "5dcb86f68f239346676ceb1ed1ea385bd215fba1"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1
base_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1
- platform: android
create_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1
base_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1
- platform: ios
create_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1
base_revision: 5dcb86f68f239346676ceb1ed1ea385bd215fba1
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

16
README.md Normal file
View File

@ -0,0 +1,16 @@
# Card Scan Demo
拍照 / 选图 → **预览页** ML Kit 边框锁定 → 裁剪纠偏 → 看结果。
拍照页只显示引导框,不做实时检测。
完整说明(对照集换社反编译分析):[docs/demo说明.md](docs/demo说明.md)
## 运行
```bash
export PATH="$HOME/flutter/bin:$PATH"
cd /Users/a1518/Desktop/project/card-dex-demo
flutter pub get
flutter run
```

28
analysis_options.yaml Normal file
View File

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

13
android/.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
key.properties
**/*.keystore
**/*.jks

59
android/app/build.gradle Normal file
View File

@ -0,0 +1,59 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader("UTF-8") { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty("flutter.versionCode")
if (flutterVersionCode == null) {
flutterVersionCode = "1"
}
def flutterVersionName = localProperties.getProperty("flutter.versionName")
if (flutterVersionName == null) {
flutterVersionName = "1.0"
}
android {
namespace = "com.carddex.carddex_demo"
compileSdk = 35
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
defaultConfig {
applicationId = "com.carddex.carddex_demo"
minSdk = 26
targetSdk = 35
versionCode = flutterVersionCode.toInteger()
versionName = flutterVersionName
}
buildTypes {
release {
signingConfig = signingConfigs.debug
minifyEnabled false
shrinkResources false
}
}
}
flutter {
source = "../.."
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,50 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
<application
android:label="Card Scan Demo"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,5 @@
package com.carddex.carddex_demo
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

18
android/build.gradle Normal file
View File

@ -0,0 +1,18 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip

25
android/settings.gradle Normal file
View File

@ -0,0 +1,25 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.7.0" apply false
id "org.jetbrains.kotlin.android" version "2.0.21" apply false
}
include ":app"

View File

@ -0,0 +1,513 @@
# CardDex 卡牌检测 Demo — Flutter 开发方案
> **目标**:复刻 CardDex 本地 TF Lite 目标检测 + 卡片定位能力
> **周期**~3.5 天(单人)
> **依赖**CardDex APK 内提取的 `detect.tflite`COCO 通用检测模型4MB
---
## 一、架构概览
```
┌──────────────────────────────────────┐
│ Flutter UI │
│ camera_preview + detection_overlay │
├──────────────────────────────────────┤
│ TFLite Detector Service │
│ ├─ 模型加载 (detect.tflite) │
│ ├─ 图像预处理 (300×300 RGB) │
│ ├─ 推理 (输出 boxes/classes/scores) │
│ └─ NMS 后处理 (IoU 去重) │
├──────────────────────────────────────┤
│ Camera Plugin (拍照) │
└──────────────────────────────────────┘
```
**核心流程**:拍照 → 缩放到 300×300 → TF Lite 推理 → NMS 去重 → 画框展示
---
## 二、项目结构
```
carddex_demo/
├── pubspec.yaml
├── lib/
│ ├── main.dart # 入口 + 相机预览
│ ├── services/
│ │ ├── detector_service.dart # TF Lite 加载/推理
│ │ └── image_processor.dart # 图像预处理
│ ├── utils/
│ │ └── nms.dart # NMS 非极大值抑制
│ └── widgets/
│ └── detection_painter.dart # 检测框叠加渲染
└── assets/
└── ml/
├── detect.tflite # 从 CardDex APK 提取
└── labelmap.txt # COCO 90 类标签
```
---
## 三、依赖清单
```yaml
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
camera: ^0.10.5 # 相机采集
tflite_flutter: ^0.11.0 # TF Lite 推理
image: ^4.1.0 # Dart 侧图像处理
flutter:
assets:
- assets/ml/detect.tflite
- assets/ml/labelmap.txt
```
---
## 四、核心代码骨架
### 4.1 主入口 — `main.dart`
```dart
import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'services/detector_service.dart';
import 'widgets/detection_painter.dart';
late List<CameraDescription> _cameras;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
_cameras = await availableCameras();
await DetectorService.instance.initialize();
runApp(const CardDexDemo());
}
class CardDexDemo extends StatelessWidget {
const CardDexDemo({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: ScannerScreen(camera: _cameras.first),
);
}
}
class ScannerScreen extends StatefulWidget {
final CameraDescription camera;
const ScannerScreen({super.key, required this.camera});
@override
State<ScannerScreen> createState() => _ScannerScreenState();
}
class _ScannerScreenState extends State<ScannerScreen> {
late CameraController _controller;
List<Detection> _detections = [];
bool _isProcessing = false;
@override
void initState() {
super.initState();
_controller = CameraController(
widget.camera,
ResolutionPreset.high,
);
_controller.initialize().then((_) => setState(() {}));
}
Future<void> _takePicture() async {
if (_isProcessing) return;
setState(() => _isProcessing = true);
final xFile = await _controller.takePicture();
final bytes = await xFile.readAsBytes();
// 推理
final detections = await DetectorService.instance.detect(bytes);
setState(() {
_detections = detections;
_isProcessing = false;
});
}
@override
Widget build(BuildContext context) {
if (!_controller.value.isInitialized) {
return const Center(child: CircularProgressIndicator());
}
return Scaffold(
body: Stack(
fit: StackFit.expand,
children: [
CameraPreview(_controller),
if (_detections.isNotEmpty)
CustomPaint(
painter: DetectionPainter(_detections),
),
Positioned(
bottom: 40,
left: 0,
right: 0,
child: Center(
child: FloatingActionButton(
onPressed: _isProcessing ? null : _takePicture,
child: _isProcessing
? const CircularProgressIndicator(color: Colors.white)
: const Icon(Icons.camera),
),
),
),
],
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
```
### 4.2 检测服务 — `detector_service.dart`
```dart
import 'dart:io';
import 'package:tflite_flutter/tflite_flutter.dart';
import 'package:image/image.dart' as img;
import '../utils/nms.dart';
import 'image_processor.dart';
class Detection {
final Rect boundingBox;
final String label;
final double confidence;
Detection({
required this.boundingBox,
required this.label,
required this.confidence,
});
}
class DetectorService {
static final DetectorService instance = DetectorService._();
DetectorService._();
Interpreter? _interpreter;
List<String> _labels = [];
bool _isInitialized = false;
// 模型输入尺寸COCO SSD MobileNet 标准)
static const int inputSize = 300;
static const double confidenceThreshold = 0.5;
Future<void> initialize() async {
if (_isInitialized) return;
// 加载模型
_interpreter = await Interpreter.fromAsset('assets/ml/detect.tflite');
// 加载标签
final labelData = await rootBundle.loadString('assets/ml/labelmap.txt');
_labels = labelData
.split('\n')
.map((l) => l.trim())
.where((l) => l.isNotEmpty)
.toList();
_isInitialized = true;
}
Future<List<Detection>> detect(Uint8List imageBytes) async {
if (!_isInitialized) throw Exception('Detector not initialized');
// 1. 图像预处理
final input = ImageProcessor.preprocess(imageBytes, inputSize);
// 2. 分配输出张量
// SSD MobileNet 输出: [1, num_detections, 4] boxes, [1, num_detections] classes, [1, num_detections] scores
final outputBoxes = List.filled(1 * 10 * 4, 0.0).reshape([1, 10, 4]);
final outputClasses = List.filled(1 * 10, 0.0).reshape([1, 10]);
final outputScores = List.filled(1 * 10, 0.0).reshape([1, 10]);
final numDetections = List.filled(1, 0.0).reshape([1]);
// 3. 推理
_interpreter!.runForMultipleInputs([input], {
0: outputBoxes,
1: outputClasses,
2: outputScores,
3: numDetections,
});
// 4. 解析结果
final boxes = <List<double>>[];
final classes = <int>[];
final scores = <double>[];
final count = numDetections[0][0].toInt().clamp(0, 10);
for (int i = 0; i < count; i++) {
if (outputScores[0][i] >= confidenceThreshold) {
boxes.add([
outputBoxes[0][i][1], // ymin
outputBoxes[0][i][0], // xmin
outputBoxes[0][i][3], // ymax
outputBoxes[0][i][2], // xmax
]);
classes.add(outputClasses[0][i].toInt());
scores.add(outputScores[0][i]);
}
}
// 5. NMS 去重
final indices = NMS.suppress(boxes, scores, iouThreshold: 0.5);
// 6. 生成检测结果
return indices.map((i) {
final box = boxes[i];
return Detection(
boundingBox: Rect.fromLTRB(
box[1] * inputSize, // xmin * width
box[0] * inputSize, // ymin * height
box[3] * inputSize, // xmax * width
box[2] * inputSize, // ymax * height
),
label: classes[i] < _labels.length ? _labels[classes[i]] : 'unknown',
confidence: scores[i],
);
}).toList();
}
}
```
### 4.3 图像预处理 — `image_processor.dart`
```dart
import 'dart:typed_data';
import 'package:image/image.dart' as img;
class ImageProcessor {
/// 将拍照得到的 JPEG bytes 预处理为 TF Lite 输入张量
/// 返回 [1, 300, 300, 3] 的 Float32List归一化到 [0,1]
static Float32List preprocess(Uint8List jpegBytes, int targetSize) {
// 1. 解码 JPEG
final image = img.decodeJpg(jpegBytes);
if (image == null) throw Exception('Failed to decode image');
// 2. 缩放到 300×300
final resized = img.copyResize(image, width: targetSize, height: targetSize);
// 3. RGB → Float32List归一化到 [0, 1]
final input = Float32List(1 * targetSize * targetSize * 3);
int pixelIndex = 0;
for (int y = 0; y < targetSize; y++) {
for (int x = 0; x < targetSize; x++) {
final pixel = resized.getPixel(x, y);
input[pixelIndex++] = pixel.r / 255.0;
input[pixelIndex++] = pixel.g / 255.0;
input[pixelIndex++] = pixel.b / 255.0;
}
}
return input;
}
}
```
### 4.4 NMS 去重 — `nms.dart`
```dart
class NMS {
/// 贪心 NMS按 score 降序,保留 IoU < threshold 的框
static List<int> suppress(
List<List<double>> boxes,
List<double> scores, {
double iouThreshold = 0.5,
}) {
// 按分数排序
final indices = List.generate(scores.length, (i) => i);
indices.sort((a, b) => scores[b].compareTo(scores[a]));
final selected = <int>[];
final suppressed = List.filled(boxes.length, false);
for (final idx in indices) {
if (suppressed[idx]) continue;
selected.add(idx);
for (int j = 0; j < boxes.length; j++) {
if (j == idx || suppressed[j]) continue;
if (_iou(boxes[idx], boxes[j]) > iouThreshold) {
suppressed[j] = true;
}
}
}
return selected;
}
/// 计算两个框的 IoU
static double _iou(List<double> a, List<double> b) {
final x1 = max(a[1], b[1]);
final y1 = max(a[0], b[0]);
final x2 = min(a[3], b[3]);
final y2 = min(a[2], b[2]);
if (x2 <= x1 || y2 <= y1) return 0.0;
final interArea = (x2 - x1) * (y2 - y1);
final areaA = (a[3] - a[1]) * (a[2] - a[0]);
final areaB = (b[3] - b[1]) * (b[2] - b[0]);
return interArea / (areaA + areaB - interArea);
}
}
```
### 4.5 检测框渲染 — `detection_painter.dart`
```dart
import 'package:flutter/material.dart';
import '../services/detector_service.dart';
class DetectionPainter extends CustomPainter {
final List<Detection> detections;
DetectionPainter(this.detections);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.green
..style = PaintingStyle.stroke
..strokeWidth = 3.0;
final textStyle = TextStyle(
color: Colors.white,
fontSize: 14,
backgroundColor: Colors.green.withOpacity(0.8),
);
for (final det in detections) {
// 画框
canvas.drawRect(det.boundingBox, paint);
// 画标签
final tp = TextPainter(
text: TextSpan(
text: '${det.label} ${(det.confidence * 100).toStringAsFixed(0)}%',
style: textStyle,
),
textDirection: TextDirection.ltr,
);
tp.layout();
tp.paint(
canvas,
Offset(det.boundingBox.left, det.boundingBox.top - tp.height),
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
```
---
## 五、模型提取
```bash
# 从 CardDex APK 提取模型和标签
cd apk_analysis/recognize/apk_extracted_carddex
unzip com.card.dex.identifier.detect.apk \
"assets/flutter_assets/assets/ml/detect.tflite" \
"assets/flutter_assets/assets/ml/labelmap.txt" \
-d ../../../carddex_demo/assets/ml/
```
---
## 六、构建与运行
```bash
# 1. 创建 Flutter 项目
flutter create carddex_demo
cd carddex_demo
# 2. 添加依赖(编辑 pubspec.yaml
# 3. 复制模型文件到 assets/ml/
# 4. 替换 lib/ 下的代码
# 5. 运行(需要真机,模拟器无相机)
flutter run
```
---
## 七、关键注意事项
### 7.1 模型输出格式适配
CardDex 用的是 SSD MobileNet输出张量取决于具体的 TFLite 模型。如果上面的输出索引不对,需要先用工具查看:
```bash
# 查看模型输入输出信息
python3 -c "
import tensorflow as tf
interpreter = tf.lite.Interpreter(model_path='assets/ml/detect.tflite')
print('Input:', interpreter.get_input_details())
print('Output:', interpreter.get_output_details())
"
```
### 7.2 平台配置
**iOS** (`ios/Podfile`):确保最低版本 ≥ 12.0,并添加相机权限描述。
**Android** (`android/app/build.gradle`)`minSdkVersion` ≥ 21。
### 7.3 性能
- `detect.tflite` ~4MB推理耗时约 50-200ms取决于设备
- 拍照后单次推理,不做实时帧,性能无忧
- GPU delegate 可选,配置方法:`Interpreter.fromAsset('detect.tflite', options: InterpreterOptions()..useGpuDelegateWhenAvailable = true)`
### 7.4 局限
| 局限 | 说明 | 后续方向 |
|------|------|---------|
| 只能定位,不能分类 | COCO 标签里没有"卡牌",检测框内的物体不知道是什么卡 | 自训卡牌分类模型或接 API |
| 模型不针对卡牌优化 | 卡片是矩形平面物体COCO 模型对此场景精度一般 | 用卡牌数据 fine-tune |
| 多卡同框效果差 | SSD 对小目标和密集目标检测能力有限 | 换 YOLO-NAS 或 RT-DETR |
---
## 八、Demo 验收标准
- [ ] 打开 App 显示相机预览
- [ ] 对准一张卡牌拍照
- [ ] 画面出现绿色检测框 + 标签 + 置信度
- [ ] 对准其他物体(杯子、手机等)也能检测
- [ ] 无明显崩溃或 ANR
---
## 九、进阶方向Demo 后)
| 阶段 | 内容 | 预估 |
|------|------|------|
| **Phase 2** | 替换为卡牌专用检测模型(自训练) | 1 周 |
| **Phase 3** | 接入服务端 API 做卡牌分类 | 3 天 |
| **Phase 4** | 实时帧推理 + GPU 加速 | 3 天 |
| **Phase 5** | 多卡同框检测 + 批量识别 | 1 周 |

205
docs/demo说明.md Normal file
View File

@ -0,0 +1,205 @@
# Card Scan Demo 说明
> 对照《集换社_反编译分析报告》v3.23.15)与本仓库当前实现,说明 demo 定位、流水线与差异。
---
## 1. 背景:集换社里的「卡牌扫描」
集换社是 TCG 卡牌 **C2C 交易平台**(包名 `com.jihuanshe`),卡牌扫描只是完整业务中的一环,不是独立工具 App。
反编译报告里与扫描相关的关键结论:
| 项 | 集换社侧证据 |
|----|--------------|
| 入口 | `CardScanActivity` / `CardScanAlbumActivity` / `CaptureActivity` / `ScanActivity` |
| 相机 | CameraX`MetadataHolderService` 等) |
| 识别 | Google ML Kit`libmlkitcommonpipeline.so` ~8.4MB、`GraphicOverlay`、`MlKitInitProvider` |
| 裁剪 | uCrop`MCropActivity`)— 识别不佳时可手动纠偏后再跑 |
| 结果 | 匹配卡牌库 → `CardDetailActivity`(详情 / 价格)→ 购物车或上架 |
报告中的扫描流程可概括为:
```
相机 / 相册 → ML Kit 边框检测 →(可选)手动裁剪 → 匹配卡牌数据库 → 详情页
```
加固(爱加密)导致无法还原完整 Java/Kotlin 源码,上述链路主要来自 Manifest、Native 库与 Flutter 字符串等静态证据,**不是**可运行的源码级复刻。
本 demo **只复现「拍/选图 → 锁定卡边 → 裁剪纠偏」** 这一段视觉能力,不包含交易、匹配库、登录、支付等平台能力。
---
## 2. Demo 是什么
纯 Flutter 小应用(`carddex_demo` v2.0.0),竖屏暗色 UI。
**一句话**:拍照或相册选图 → 按 TCG 引导框裁切 → ML Kit 目标检测得到外接框 → 经典 CV 细化倾斜四边形 → 透视 / 轴对齐裁剪 → 预览结果。
**明确不做**
- 实时预览帧上的检测(拍摄页只有引导框)
- 卡牌身份识别 / 图库检索 / 价格
- 打包自研或从 APK 抽取的 TFLite 模型(早期方案已废弃)
- 集换社业务订单、IM、拍卖、仓储等
---
## 3. 与集换社扫描链路的对照
| 维度 | 集换社(反编译推断) | 本 Demo |
|------|----------------------|---------|
| 形态 | 交易 App 内嵌模块Native Activity | 独立 Flutter Demo |
| 采集 | CameraX + 相册 Activity | `camera` + `image_picker` |
| 检测时机 | 报告描述含实时预览标注(`GraphicOverlay` | **仅静帧**:进入预览页后跑一次 |
| 检测引擎 | Google ML Kit | 同为 Google ML Kit Object Detection |
| 边框形态 | 报告侧重 AABB + Overlay细节不可见 | AABB → 可选倾斜四边形细化 |
| 裁剪纠偏 | uCrop 手动为主 | 自动透视 / 轴对齐裁剪(无手动 uCrop |
| 下游 | 匹配卡库 → 详情 / 交易 | **无**:只展示锁定框与裁切图 |
| 架构上下文 | Native + Flutter Hybrid + 大量 SDK | 单应用、无后端、无加固 |
技术选型上demo 与集换社扫描模块最接近的一点是:**都用 ML Kit 做卡牌区域定位,而不是卡牌分类识别**。定位成功后集换社走业务匹配demo 停在图像几何处理。
早期仓库内的 `carddex_flutter_demo_plan.md` 曾计划复刻 CardDex 的本地 `detect.tflite`;当前实现已改为 ML Kit与集换社扫描栈更一致也避免依赖 APK 内模型资产。
---
## 4. 工程结构
```
lib/
├── main.dart # 入口、竖屏、暗色主题
├── screens/
│ ├── capture_screen.dart # 相机预览 + 引导框(无 ML
│ └── preview_screen.dart # 检测 / 细化 / 裁剪 / 展示
├── services/
│ ├── card_detector.dart # ML Kit 静帧检测 + 选框 + 调用细化
│ ├── card_corner_refiner.dart # Otsu / 连通域 / 最小外接四边形
│ └── card_cropper.dart # 透视或轴对齐裁剪,贴合 63:88
└── widgets/
├── guide_overlay.dart # 拍摄页暗角 + 白框
└── guide_frame.dart # 引导框几何(含 BoxFit.cover 映射)
```
依赖(与相机 / 图像相关):
| 包 | 作用 |
|----|------|
| `camera` | 后置预览与拍照 |
| `image_picker` | 相册 |
| `google_mlkit_object_detection` | 静帧目标检测 |
| `image` | 解码、模糊输入、透视 `copyRectify`、JPEG |
| `path_provider` | 引导裁切临时文件 |
`assets/` 为空:模型由 ML Kit 插件运行时提供,不随 App 打包自定义 `.tflite`
---
## 5. 用户流程
```mermaid
flowchart LR
A["CaptureScreen<br/>相机 / 相册"] --> B["PreviewScreen"]
B --> C["按引导框裁切全图"]
C --> D["ML Kit 静帧检测"]
D --> E["经典 CV 角点细化"]
E --> F["透视 / AABB 裁剪"]
F --> G["展示锁定框 + 结果图"]
G --> H["重新拍摄 → pop"]
```
| 页面 | 职责 |
|------|------|
| **CaptureScreen** | 后置相机、`ResolutionPreset.high`、引导遮罩、快门 / 相册 / 翻转。生命周期:切后台释放、回前台重建。 |
| **PreviewScreen** | 全部视觉处理与结果 UI绿框锁定、置信度 / 标签、裁切图、重拍)。 |
导航为一次 `Navigator.push`,无路由库、无状态管理包。
---
## 6. 处理流水线(实现细节)
### 6.1 引导框
TCG 常见比例 **63:88**。框约占视口高度 62%、宽度 78%,垂直中心约在 46%。
- 拍照:用预览视口 + `BoxFit.cover` 反算到照片像素(`GuideFrame.inCoverImage`)。
- 相册:直接在图像坐标系算引导矩形(`GuideFrame.inImage`)。
进入检测前先按引导框裁掉框外区域,减小背景干扰(隐含前提:卡已大致落在框内)。
### 6.2 ML Kit 检测(`CardDetector`
- `DetectionMode.single`,开启分类与多目标。
- 输出 `CardBox`AABB、置信度、标签通用 COCO 类名,**不是**卡牌 ID
- `pickBest`:分数 = `0.4 * confidence + 0.6 * fillRatio`,丢弃填充比 ∉ `[0.04, 0.98]` 的框。
这是通用物体检测,不是「宝可梦 / 游戏王」专用模型;能锁边框即可,标签仅作调试信息。
### 6.3 角点细化(`CardCornerRefiner`
在 AABB 邻域做经典视觉,把轴对齐框尽量收成倾斜四边形:
1. 顶部略抬高、ROI 外扩
2. 灰度 → 模糊 → Otsu保证 AABB 中心为前景
3. 最大连通域(优先覆盖种子点)
4. 凸包 → 最小面积矩形 → TL→TR→BR→BL
5. 面积校验失败则退回 AABB成功则外扩并照顾卡头区域
### 6.4 裁剪(`CardCropper`
| 条件 | 行为 |
|------|------|
| 有倾斜四边形 | `copyRectify` 透视变换 → 竖向校正 → 贴合 63:88 |
| 仅有 AABB | 外扩约 6% 后轴对齐裁切并贴合比例 |
| 无检测框 | 按引导框中心区域裁切(`usedDetection: false` |
大图最长边限制约 2560JPEG 质量约 98。
---
## 7. 能力边界(相对报告中的产品能力)
反编译报告中的集换社扫描落点是 **「识别成功 → 匹配数据库 → 交易」**。本 demo 停在几何裁切:
```
集换社: 采集 → ML Kit → (uCrop) → 匹配库 → 详情/交易
本 Demo: 采集 → 引导裁切 → ML Kit → 角点细化 → 自动裁切 → 预览
▲ ▲
多出的本地几何步骤 无业务下游
```
因此:
- 能验证「静帧 + ML Kit 锁边 + 透视纠偏」是否够用做上架 / 鉴定前处理。
- **不能**验证卡牌检索准确率、价格、多卡桌面、强反光 / 极端角度等产品级指标。
- 检测失败时仍可按引导框出图,便于继续调试 UI但不代表识别成功。
---
## 8. 运行
```bash
export PATH="$HOME/flutter/bin:$PATH"
cd /Users/a1518/Desktop/project/card-dex-demo
flutter pub get
flutter run
```
Android `minSdk` 为 26需相机与媒体读取权限。真机效果明显优于模拟器。
---
## 9. 相关文档
| 文档 | 说明 |
|------|------|
| [集换社_反编译分析报告.md](../../apk_analysis/recognize/202607_analysis/集换社_反编译分析报告.md) | APK 静态分析(扫描相关见 §4.8、§6.2、§9.1 |
| [carddex_flutter_demo_plan.md](../carddex_flutter_demo_plan.md) | 早期 TFLite 方案(已与当前实现不一致,仅作历史参考) |
| [README.md](../README.md) | 最短运行说明 |
---
## 10. 小结
本 demo 是对照集换社扫描模块做的 **视觉前处理实验床**:同样依赖 Google ML Kit 做区域定位,但用 Flutter 实现「引导框 → 静帧检测 → 角点细化 → 透视裁切」闭环,刻意剥离交易与图库匹配。若后续要对齐集换社产品路径,需要在裁切结果之上另接卡牌检索 / 版本匹配 API而不是继续堆叠本仓库内的几何管线。

34
ios/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
</dict>
</plist>

View File

@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

42
ios/Podfile Normal file
View File

@ -0,0 +1,42 @@
platform :ios, '12.0'
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. Run flutter pub get first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0'
end
end
end

View File

@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.carddex.carddexDemo;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.carddex.carddexDemo.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.carddex.carddexDemo.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.carddex.carddexDemo.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.carddex.carddexDemo;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.carddex.carddexDemo;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,13 @@
import Flutter
import UIKit
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

53
ios/Runner/Info.plist Normal file
View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Carddex Demo</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>carddex_demo</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Camera access is required to scan and crop trading cards.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Photo library access is required to pick a card image.</string>
</dict>
</plist>

View File

@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

34
lib/main.dart Normal file
View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'screens/capture_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
runApp(const CardScanDemoApp());
}
class CardScanDemoApp extends StatelessWidget {
const CardScanDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Card Scan Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF22C55E),
brightness: Brightness.dark,
),
useMaterial3: true,
scaffoldBackgroundColor: const Color(0xFF0B0F0C),
),
home: const CaptureScreen(),
);
}
}

View File

@ -0,0 +1,306 @@
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../widgets/guide_overlay.dart';
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');
}
}
Future<void> _openPreview(String path) async {
if (!mounted) return;
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();
await _openPreview(shot.path);
} 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;
await _openPreview(file.path);
} 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),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'卡牌扫描',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'将卡牌放入框内后拍照,仅框内区域参与识别',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white70,
),
),
],
),
),
),
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),
),
),
);
}
}

View File

@ -0,0 +1,376 @@
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 '../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();
bool _loading = true;
String? _error;
ui.Image? _sourceImage;
CardBox? _locked;
CropResult? _crop;
String? _label;
double? _confidence;
@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);
if (best != null) {
best = _detector.refineCorners(raster, best);
}
final crop = await CardCropper.cropAndCorrect(
imagePath: guidePath,
box: best,
);
if (!mounted) {
displayImage.dispose();
return;
}
setState(() {
_sourceImage = displayImage;
_locked = best;
_crop = crop;
_label = best?.label;
_confidence = best?.confidence;
_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: 12),
Expanded(
flex: 5,
child: _LockedPhotoView(
image: _sourceImage!,
locked: _locked,
),
),
const SizedBox(height: 12),
Text(
'裁剪结果',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Expanded(
flex: 4,
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: 16),
FilledButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.camera_alt_outlined),
label: const Text('重新拍摄'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
),
],
);
}
}
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;
}
}

View File

@ -0,0 +1,484 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:image/image.dart' as img;
/// Refine an axis-aligned detection into a tilted card quadrilateral.
///
/// Returns corners ordered TL TR BR BL in full-image pixel coords,
/// or `null` if refinement fails (caller should keep the AABB).
class CardCornerRefiner {
/// [aabb] is the ML Kit box in the same pixel space as [image].
static List<Offset>? refine(img.Image image, Rect aabb) {
final pad = math.max(aabb.width, aabb.height) * 0.12;
final roi = Rect.fromLTRB(
(aabb.left - pad).clamp(0, image.width - 1.0),
(aabb.top - pad).clamp(0, image.height - 1.0),
(aabb.right + pad).clamp(1.0, image.width.toDouble()),
(aabb.bottom + pad).clamp(1.0, image.height.toDouble()),
);
final x0 = roi.left.floor();
final y0 = roi.top.floor();
final rw = math.max(1, roi.width.ceil());
final rh = math.max(1, roi.height.ceil());
if (rw < 24 || rh < 24) return null;
final gray = List<int>.filled(rw * rh, 0);
for (var y = 0; y < rh; y++) {
final sy = (y0 + y).clamp(0, image.height - 1);
for (var x = 0; x < rw; x++) {
final sx = (x0 + x).clamp(0, image.width - 1);
final p = image.getPixel(sx, sy);
gray[y * rw + x] = (0.299 * p.r + 0.587 * p.g + 0.114 * p.b).round();
}
}
// Light blur to stabilize edges.
final blurred = _boxBlur3(gray, rw, rh);
final thr = _otsu(blurred);
final mask = List<bool>.filled(rw * rh, false);
for (var i = 0; i < blurred.length; i++) {
mask[i] = blurred[i] >= thr;
}
// Prefer polarity where the center of the AABB is foreground.
final cx = ((aabb.center.dx - x0).round()).clamp(0, rw - 1);
final cy = ((aabb.center.dy - y0).round()).clamp(0, rh - 1);
if (!mask[cy * rw + cx]) {
for (var i = 0; i < mask.length; i++) {
mask[i] = !mask[i];
}
}
final component = _largestComponent(mask, rw, rh, cx, cy);
if (component == null || component.length < 40) return null;
final hull = _convexHull(component);
if (hull.length < 3) return null;
final quad = _minAreaRect(hull);
if (quad == null) return null;
// Map back to full-image coords and order TL, TR, BR, BL.
var ordered = _orderCorners([
for (final p in quad) Offset(x0 + p.dx, y0 + p.dy),
]);
// Reject half-card blobs (e.g. Energy art only) before padding.
if (!_looksLikeFullCard(ordered, aabb)) return null;
// Light outward pad so the physical border isn't clipped (avoid big gaps).
ordered = _padQuad(
ordered,
scale: 1.05,
topExtra: 0.03,
imageW: image.width.toDouble(),
imageH: image.height.toDouble(),
);
// Only pull top up if it still sits clearly below the ML box top.
ordered = _coverAabbTop(
ordered,
aabb,
image.width.toDouble(),
image.height.toDouble(),
);
if (!_looksLikeFullCard(ordered, aabb)) return null;
return ordered;
}
/// TCG portrait ~63:88. Reject quads that only cover artwork / half the AABB.
static bool _looksLikeFullCard(List<Offset> q, Rect aabb) {
final topW = (q[1] - q[0]).distance;
final botW = (q[2] - q[3]).distance;
final leftH = (q[3] - q[0]).distance;
final rightH = (q[2] - q[1]).distance;
final w = (topW + botW) / 2;
final h = (leftH + rightH) / 2;
if (w < 8 || h < 8) return false;
final short = math.min(w, h);
final long = math.max(w, h);
final ratio = short / long;
// Portrait card 0.72; half-art blobs are often nearer square (~0.91.0).
if (ratio < 0.55 || ratio > 0.88) return false;
final aabbArea = aabb.width * aabb.height;
if (aabbArea < 1) return false;
final area = _quadArea(q);
if (area < aabbArea * 0.55 || area > aabbArea * 1.55) return false;
var minY = q[0].dy, maxY = q[0].dy;
var minX = q[0].dx, maxX = q[0].dx;
for (final p in q) {
if (p.dy < minY) minY = p.dy;
if (p.dy > maxY) maxY = p.dy;
if (p.dx < minX) minX = p.dx;
if (p.dx > maxX) maxX = p.dx;
}
final quadBounds = Rect.fromLTRB(minX, minY, maxX, maxY);
final inter = quadBounds.intersect(aabb);
final cover =
(inter.width * inter.height) / aabbArea;
if (cover < 0.65) return false;
// Must span most of the detection height (blocks upper-half-only locks).
if ((maxY - minY) < aabb.height * 0.72) return false;
return true;
}
/// Extend TL/TR when ML Kit's box still reaches above the refined top.
static List<Offset> _coverAabbTop(
List<Offset> q,
Rect aabb,
double imageW,
double imageH,
) {
final topY = (q[0].dy + q[1].dy) / 2;
final targetTop = aabb.top;
final need = topY - targetTop;
// Ignore tiny gaps; only fix real header clipping.
if (need < aabb.height * 0.02) return q;
final topMid = Offset((q[0].dx + q[1].dx) / 2, (q[0].dy + q[1].dy) / 2);
final botMid = Offset((q[2].dx + q[3].dx) / 2, (q[2].dy + q[3].dy) / 2);
final up = topMid - botMid;
final len = up.distance;
if (len < 1) return q;
final bump = up * (need / len);
Offset clamp(Offset p) => Offset(
p.dx.clamp(0.0, imageW - 1),
p.dy.clamp(0.0, imageH - 1),
);
return [
clamp(q[0] + bump),
clamp(q[1] + bump),
q[2],
q[3],
];
}
/// Grow quad from its center; [topExtra] adds more margin on TL/TR.
static List<Offset> _padQuad(
List<Offset> q, {
required double scale,
required double topExtra,
required double imageW,
required double imageH,
}) {
final c = Offset(
(q[0].dx + q[1].dx + q[2].dx + q[3].dx) / 4,
(q[0].dy + q[1].dy + q[2].dy + q[3].dy) / 4,
);
final expanded = [
for (final p in q)
Offset(
c.dx + (p.dx - c.dx) * scale,
c.dy + (p.dy - c.dy) * scale,
),
];
// Push top edge further along card height (away from bottom).
final topMid = Offset(
(expanded[0].dx + expanded[1].dx) / 2,
(expanded[0].dy + expanded[1].dy) / 2,
);
final botMid = Offset(
(expanded[2].dx + expanded[3].dx) / 2,
(expanded[2].dy + expanded[3].dy) / 2,
);
final up = topMid - botMid;
final bump = Offset(up.dx * topExtra, up.dy * topExtra);
expanded[0] = expanded[0] + bump;
expanded[1] = expanded[1] + bump;
Offset clamp(Offset p) => Offset(
p.dx.clamp(0.0, imageW - 1),
p.dy.clamp(0.0, imageH - 1),
);
return expanded.map(clamp).toList(growable: false);
}
static List<int> _boxBlur3(List<int> src, int w, int h) {
final tmp = List<int>.filled(w * h, 0);
final out = List<int>.filled(w * h, 0);
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
var s = 0;
var n = 0;
for (var dx = -1; dx <= 1; dx++) {
final xx = x + dx;
if (xx < 0 || xx >= w) continue;
s += src[y * w + xx];
n++;
}
tmp[y * w + x] = s ~/ n;
}
}
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
var s = 0;
var n = 0;
for (var dy = -1; dy <= 1; dy++) {
final yy = y + dy;
if (yy < 0 || yy >= h) continue;
s += tmp[yy * w + x];
n++;
}
out[y * w + x] = s ~/ n;
}
}
return out;
}
static int _otsu(List<int> gray) {
final hist = List<int>.filled(256, 0);
for (final v in gray) {
hist[v.clamp(0, 255)]++;
}
final total = gray.length;
var sum = 0;
for (var i = 0; i < 256; i++) {
sum += i * hist[i];
}
var sumB = 0;
var wB = 0;
var best = 0.0;
var thr = 128;
for (var t = 0; t < 256; t++) {
wB += hist[t];
if (wB == 0) continue;
final wF = total - wB;
if (wF == 0) break;
sumB += t * hist[t];
final mB = sumB / wB;
final mF = (sum - sumB) / wF;
final between = wB * wF * (mB - mF) * (mB - mF);
if (between > best) {
best = between;
thr = t;
}
}
return thr;
}
/// BFS largest component; prefer one covering seed if large enough.
static List<Offset>? _largestComponent(
List<bool> mask,
int w,
int h,
int seedX,
int seedY,
) {
final seen = List<bool>.filled(w * h, false);
List<Offset>? bestEdge;
var bestCount = 0;
List<Offset>? seededEdge;
var seededCount = 0;
final qx = List<int>.filled(w * h, 0);
final qy = List<int>.filled(w * h, 0);
for (var sy = 0; sy < h; sy++) {
for (var sx = 0; sx < w; sx++) {
final start = sy * w + sx;
if (!mask[start] || seen[start]) continue;
var head = 0;
var tail = 0;
qx[tail] = sx;
qy[tail] = sy;
tail++;
seen[start] = true;
final edgePts = <Offset>[];
var containsSeed = false;
var count = 0;
while (head < tail) {
final x = qx[head];
final y = qy[head];
head++;
count++;
var onEdge = false;
for (final d in const [
[-1, 0],
[1, 0],
[0, -1],
[0, 1],
]) {
final nx = x + d[0];
final ny = y + d[1];
if (nx < 0 ||
ny < 0 ||
nx >= w ||
ny >= h ||
!mask[ny * w + nx]) {
onEdge = true;
break;
}
}
if (onEdge) edgePts.add(Offset(x.toDouble(), y.toDouble()));
if (x == seedX && y == seedY) containsSeed = true;
for (final d in const [
[-1, 0],
[1, 0],
[0, -1],
[0, 1],
]) {
final nx = x + d[0];
final ny = y + d[1];
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
final ni = ny * w + nx;
if (!mask[ni] || seen[ni]) continue;
seen[ni] = true;
qx[tail] = nx;
qy[tail] = ny;
tail++;
}
}
if (edgePts.length < 8) continue;
if (count > bestCount) {
bestCount = count;
bestEdge = edgePts;
}
if (containsSeed) {
seededCount = count;
seededEdge = edgePts;
}
}
}
if (seededEdge != null && seededCount >= bestCount * 0.35) {
return seededEdge;
}
return bestEdge;
}
static List<Offset> _convexHull(List<Offset> pts) {
if (pts.length <= 2) return List.of(pts);
final sorted = [...pts]..sort((a, b) {
final c = a.dx.compareTo(b.dx);
return c != 0 ? c : a.dy.compareTo(b.dy);
});
// Deduplicate densely: subsample for speed on large blobs.
final sample = <Offset>[];
final step = math.max(1, sorted.length ~/ 800);
for (var i = 0; i < sorted.length; i += step) {
sample.add(sorted[i]);
}
if (sample.last != sorted.last) sample.add(sorted.last);
double cross(Offset o, Offset a, Offset b) =>
(a.dx - o.dx) * (b.dy - o.dy) - (a.dy - o.dy) * (b.dx - o.dx);
final lower = <Offset>[];
for (final p in sample) {
while (lower.length >= 2 &&
cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
lower.removeLast();
}
lower.add(p);
}
final upper = <Offset>[];
for (final p in sample.reversed) {
while (upper.length >= 2 &&
cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
upper.removeLast();
}
upper.add(p);
}
lower.removeLast();
upper.removeLast();
return [...lower, ...upper];
}
/// Rotating-calipers style min-area rectangle from convex hull.
static List<Offset>? _minAreaRect(List<Offset> hull) {
if (hull.length < 3) return null;
var bestArea = double.infinity;
List<Offset>? best;
for (var i = 0; i < hull.length; i++) {
final a = hull[i];
final b = hull[(i + 1) % hull.length];
final edge = b - a;
final len = edge.distance;
if (len < 1e-6) continue;
final ux = edge.dx / len;
final uy = edge.dy / len;
final vx = -uy;
final vy = ux;
var minU = double.infinity, maxU = -double.infinity;
var minV = double.infinity, maxV = -double.infinity;
for (final p in hull) {
final u = (p.dx - a.dx) * ux + (p.dy - a.dy) * uy;
final v = (p.dx - a.dx) * vx + (p.dy - a.dy) * vy;
if (u < minU) minU = u;
if (u > maxU) maxU = u;
if (v < minV) minV = v;
if (v > maxV) maxV = v;
}
final area = (maxU - minU) * (maxV - minV);
if (area < bestArea) {
bestArea = area;
// Corners in edge-aligned space image space.
Offset corner(double u, double v) => Offset(
a.dx + u * ux + v * vx,
a.dy + u * uy + v * vy,
);
best = [
corner(minU, minV),
corner(maxU, minV),
corner(maxU, maxV),
corner(minU, maxV),
];
}
}
return best;
}
static List<Offset> _orderCorners(List<Offset> corners) {
assert(corners.length == 4);
final c = Offset(
(corners[0].dx + corners[1].dx + corners[2].dx + corners[3].dx) / 4,
(corners[0].dy + corners[1].dy + corners[2].dy + corners[3].dy) / 4,
);
final sorted = [...corners]
..sort((a, b) {
final aa = math.atan2(a.dy - c.dy, a.dx - c.dx);
final bb = math.atan2(b.dy - c.dy, b.dx - c.dx);
return aa.compareTo(bb);
});
// After atan2 sort (CCW from +x), pick the top-left-most as start.
var start = 0;
var bestScore = double.infinity;
for (var i = 0; i < 4; i++) {
final score = sorted[i].dx + sorted[i].dy;
if (score < bestScore) {
bestScore = score;
start = i;
}
}
return [
sorted[start],
sorted[(start + 1) % 4],
sorted[(start + 2) % 4],
sorted[(start + 3) % 4],
];
}
static double _quadArea(List<Offset> q) {
// Shoelace.
var s = 0.0;
for (var i = 0; i < 4; i++) {
final a = q[i];
final b = q[(i + 1) % 4];
s += a.dx * b.dy - b.dx * a.dy;
}
return s.abs() / 2;
}
}

View File

@ -0,0 +1,223 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui';
import 'package:image/image.dart' as img;
import 'card_detector.dart';
/// TCG card aspect (width / height), ~63×88 mm.
const double kCardAspect = 63 / 88;
class CropResult {
const CropResult({
required this.bytes,
required this.width,
required this.height,
required this.usedDetection,
});
final Uint8List bytes;
final int width;
final int height;
final bool usedDetection;
}
/// Crop + orientation correct from detection box (or guide fallback).
class CardCropper {
/// [box] in source image pixel coords. If null, uses centered guide.
/// When [box] has tilted [CardBox.corners], crops the quad interior
/// via perspective warp (exactly the green lock region).
static Future<CropResult> cropAndCorrect({
required String imagePath,
CardBox? box,
double padding = 0.06,
}) async {
return computeCrop(imagePath, box, padding);
}
static Future<CropResult> computeCrop(
String imagePath,
CardBox? box,
double padding,
) async {
final raw = await File(imagePath).readAsBytes();
var decoded = img.decodeImage(raw);
if (decoded == null) {
throw StateError('Failed to decode image: $imagePath');
}
// Bake EXIF orientation into pixels.
decoded = img.bakeOrientation(decoded);
final usedDetection = box != null;
img.Image out;
if (box != null && box.corners != null && box.corners!.length == 4) {
out = _perspectiveCrop(decoded, box.quad);
} else if (box != null) {
final w = decoded.width.toDouble();
final h = decoded.height.toDouble();
var cropRect = _expandAndClamp(box.rect, w, h, padding);
cropRect = _fitAspect(cropRect, w, h, kCardAspect);
out = _axisCrop(decoded, cropRect);
} else {
final w = decoded.width.toDouble();
final h = decoded.height.toDouble();
out = _axisCrop(decoded, _guideRect(w, h, kCardAspect));
}
// Mild straighten: if wider than tall but we expect portrait card, rotate.
if (out.width > out.height * 1.05) {
out = img.copyRotate(out, angle: 90);
}
// Keep high-res for sharp preview; only downscale huge images.
const maxSide = 2560;
if (out.width > maxSide || out.height > maxSide) {
if (out.width >= out.height) {
out = img.copyResize(
out,
width: maxSide,
interpolation: img.Interpolation.cubic,
);
} else {
out = img.copyResize(
out,
height: maxSide,
interpolation: img.Interpolation.cubic,
);
}
}
final bytes = Uint8List.fromList(img.encodeJpg(out, quality: 98));
return CropResult(
bytes: bytes,
width: out.width,
height: out.height,
usedDetection: usedDetection,
);
}
/// Perspective-warp the interior of TLTRBRBL into a flat card image.
///
/// Corner order from detection is image-space; a tilted shot can map the
/// card's long edge as "top". Re-orient so output is always portrait.
static img.Image _perspectiveCrop(img.Image src, List<Offset> quad) {
var tl = quad[0];
var tr = quad[1];
var br = quad[2];
var bl = quad[3];
double avg(double a, double b) => (a + b) / 2;
var topW = avg((tr - tl).distance, (br - bl).distance);
var sideH = avg((bl - tl).distance, (br - tr).distance);
// Short edge = card width, long edge = card height (TCG portrait).
if (topW > sideH) {
final nTl = tr;
final nTr = br;
final nBr = bl;
final nBl = tl;
tl = nTl;
tr = nTr;
br = nBr;
bl = nBl;
final t = topW;
topW = sideH;
sideH = t;
}
// Prefer the edge that sits higher in the photo as the card top.
final topY = avg(tl.dy, tr.dy);
final botY = avg(bl.dy, br.dy);
if (topY > botY) {
final nTl = br;
final nTr = bl;
final nBr = tl;
final nBl = tr;
tl = nTl;
tr = nTr;
br = nBr;
bl = nBl;
}
var outW = topW.round().clamp(32, 4096);
var outH = sideH.round().clamp(32, 4096);
// Prefer portrait TCG aspect while preserving measured size scale.
const targetAspect = kCardAspect;
if (outW / outH > targetAspect * 1.08) {
outW = (outH * targetAspect).round().clamp(32, 4096);
} else if (outW / outH < targetAspect / 1.08) {
outH = (outW / targetAspect).round().clamp(32, 4096);
}
final dst = img.Image(width: outW, height: outH);
var out = img.copyRectify(
src,
topLeft: img.Point(tl.dx, tl.dy),
topRight: img.Point(tr.dx, tr.dy),
bottomLeft: img.Point(bl.dx, bl.dy),
bottomRight: img.Point(br.dx, br.dy),
interpolation: img.Interpolation.cubic,
toImage: dst,
);
// Safety net if mapping still came out landscape.
if (out.width > out.height * 1.05) {
out = img.copyRotate(out, angle: 90);
}
return out;
}
static img.Image _axisCrop(img.Image decoded, Rect cropRect) {
final x = cropRect.left.round().clamp(0, decoded.width - 1);
final y = cropRect.top.round().clamp(0, decoded.height - 1);
final cw = cropRect.width.round().clamp(1, decoded.width - x);
final ch = cropRect.height.round().clamp(1, decoded.height - y);
return img.copyCrop(decoded, x: x, y: y, width: cw, height: ch);
}
static Rect _expandAndClamp(Rect r, double w, double h, double pad) {
final dx = r.width * pad;
final dy = r.height * pad;
return Rect.fromLTRB(
(r.left - dx).clamp(0, w),
(r.top - dy).clamp(0, h),
(r.right + dx).clamp(0, w),
(r.bottom + dy).clamp(0, h),
);
}
/// Grow/shrink rect to match [aspect] (width/height), stay inside image.
static Rect _fitAspect(Rect r, double w, double h, double aspect) {
final cx = r.center.dx;
final cy = r.center.dy;
var rw = r.width;
var rh = r.height;
if (rw / rh > aspect) {
rh = rw / aspect;
} else {
rw = rh * aspect;
}
// If exceeds bounds, scale down.
final scale = math.min(1.0, math.min(w / rw, h / rh));
rw *= scale;
rh *= scale;
return Rect.fromCenter(center: Offset(cx, cy), width: rw, height: rh)
.intersect(Rect.fromLTWH(0, 0, w, h));
}
static Rect _guideRect(double w, double h, double aspect) {
// ~70% of the shorter side as card height.
final cardH = math.min(h * 0.72, w / aspect * 0.9);
final cardW = cardH * aspect;
return Rect.fromCenter(
center: Offset(w / 2, h / 2),
width: cardW,
height: cardH,
);
}
}

View File

@ -0,0 +1,165 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:google_mlkit_object_detection/google_mlkit_object_detection.dart';
import 'package:image/image.dart' as img;
import 'card_corner_refiner.dart';
/// TCG card aspect (width / height), ~63×88 mm.
const double _kCardAspect = 63 / 88;
/// Detected card-like region in image pixel coordinates.
class CardBox {
const CardBox({
required this.rect,
required this.confidence,
this.label,
this.corners,
});
/// Axis-aligned outer bounds (always set).
final Rect rect;
final double confidence;
final String? label;
/// Tilted card quad in image pixels, ordered TL TR BR BL.
/// When null, [rect] corners are used.
final List<Offset>? corners;
/// Effective lock quad (tilted when refined).
List<Offset> get quad {
final c = corners;
if (c != null && c.length == 4) return c;
return [
rect.topLeft,
rect.topRight,
rect.bottomRight,
rect.bottomLeft,
];
}
CardBox copyWith({
Rect? rect,
double? confidence,
String? label,
List<Offset>? corners,
bool clearCorners = false,
}) {
return CardBox(
rect: rect ?? this.rect,
confidence: confidence ?? this.confidence,
label: label ?? this.label,
corners: clearCorners ? null : (corners ?? this.corners),
);
}
}
/// ML Kit object detection on still images (post-capture lock).
class CardDetector {
CardDetector() {
_detector = ObjectDetector(
options: ObjectDetectorOptions(
mode: DetectionMode.single,
classifyObjects: true,
multipleObjects: true,
),
);
}
late final ObjectDetector _detector;
Future<List<CardBox>> detectFile(String path) async {
final input = InputImage.fromFilePath(path);
final objects = await _detector.processImage(input);
final boxes = <CardBox>[];
for (final obj in objects) {
final conf = obj.labels.isEmpty
? 0.5
: obj.labels
.map((l) => l.confidence)
.reduce((a, b) => a > b ? a : b);
final label = obj.labels.isEmpty ? null : obj.labels.first.text;
boxes.add(
CardBox(
rect: Rect.fromLTRB(
obj.boundingBox.left.toDouble(),
obj.boundingBox.top.toDouble(),
obj.boundingBox.right.toDouble(),
obj.boundingBox.bottom.toDouble(),
),
confidence: conf,
label: label,
),
);
}
boxes.sort((a, b) => b.confidence.compareTo(a.confidence));
return boxes;
}
/// Prefer largest reasonably confident box; bias toward TCG portrait aspect.
CardBox? pickBest(List<CardBox> boxes, {Size? imageSize}) {
if (boxes.isEmpty) return null;
CardBox? best;
var bestScore = -1.0;
for (final b in boxes) {
final area = b.rect.width * b.rect.height;
final imgArea = imageSize == null
? area
: (imageSize.width * imageSize.height).clamp(1.0, double.infinity);
final fill = area / imgArea;
if (fill < 0.04 || fill > 0.98) continue;
final aspect = b.rect.width / b.rect.height;
final short = math.min(aspect, 1 / aspect);
final long = math.max(aspect, 1 / aspect);
final ratio = short / long;
// Closer to 63/88 (~0.72) scores higher; square half-art scores lower.
final aspectScore =
1.0 - ((ratio - _kCardAspect).abs() / _kCardAspect).clamp(0.0, 1.0);
final score = b.confidence * 0.3 + fill * 0.4 + aspectScore * 0.3;
if (score > bestScore) {
bestScore = score;
best = b;
}
}
return best ?? boxes.first;
}
/// When ML only framed the colorful upper half, grow height to portrait TCG.
static Rect expandToCardAspect(Rect r, Size imageSize) {
final aspect = r.width / math.max(r.height, 1.0);
// Already tall enough for a portrait card.
if (aspect <= _kCardAspect * 1.12) {
return r.intersect(Rect.fromLTWH(0, 0, imageSize.width, imageSize.height));
}
final targetH = r.width / _kCardAspect;
// Keep the detected top (art header) and extend downward into the text box.
var top = r.top;
var bottom = top + targetH;
if (bottom > imageSize.height) {
bottom = imageSize.height;
top = (bottom - targetH).clamp(0.0, imageSize.height);
}
if (top < 0) {
top = 0;
bottom = math.min(imageSize.height, targetH);
}
return Rect.fromLTRB(r.left, top, r.right, bottom)
.intersect(Rect.fromLTWH(0, 0, imageSize.width, imageSize.height));
}
/// Refine [box] into a tilted quad using pixel edges inside the AABB.
/// Falls back to an aspect-normalized AABB when the blob looks like half a card.
CardBox refineCorners(img.Image image, CardBox box) {
final imageSize = Size(image.width.toDouble(), image.height.toDouble());
final normalized = expandToCardAspect(box.rect, imageSize);
final corners = CardCornerRefiner.refine(image, normalized);
if (corners == null) {
return box.copyWith(rect: normalized, clearCorners: true);
}
return box.copyWith(rect: normalized, corners: corners);
}
Future<void> dispose() => _detector.close();
}

View File

@ -0,0 +1,57 @@
import 'dart:math' as math;
import 'dart:ui';
/// Shared capture guide geometry (same as the on-screen shooting frame).
class GuideFrame {
static const double aspect = 63 / 88;
static const double maxHeightFraction = 0.62;
static const double maxWidthFraction = 0.78;
static const double centerYFraction = 0.46;
/// Guide rect inside a viewport (preview overlay coordinates).
static Rect inViewport(Size size) {
final maxH = size.height * maxHeightFraction;
final maxW = size.width * maxWidthFraction;
var h = maxH;
var w = h * aspect;
if (w > maxW) {
w = maxW;
h = w / aspect;
}
return Rect.fromCenter(
center: Offset(size.width / 2, size.height * centerYFraction),
width: w,
height: h,
);
}
/// Map the viewport guide into image pixels when the image is shown with
/// [BoxFit.cover] in [viewport] (camera preview).
static Rect inCoverImage({
required Size viewport,
required Size imageSize,
}) {
final guide = inViewport(viewport);
final scale = math.max(
viewport.width / imageSize.width,
viewport.height / imageSize.height,
);
final dispW = imageSize.width * scale;
final dispH = imageSize.height * scale;
final ox = (viewport.width - dispW) / 2;
final oy = (viewport.height - dispH) / 2;
final mapped = Rect.fromLTRB(
(guide.left - ox) / scale,
(guide.top - oy) / scale,
(guide.right - ox) / scale,
(guide.bottom - oy) / scale,
);
return mapped.intersect(
Rect.fromLTWH(0, 0, imageSize.width, imageSize.height),
);
}
/// Guide applied directly in image space (gallery / no viewport).
static Rect inImage(Size imageSize) => inViewport(imageSize);
}

View File

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'guide_frame.dart';
/// Capture-only guide frame (no live detection boxes).
class GuideOverlay extends StatelessWidget {
const GuideOverlay({super.key, required this.size});
final Size size;
@override
Widget build(BuildContext context) {
return CustomPaint(
size: size,
painter: _GuidePainter(),
);
}
}
class _GuidePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final rect = GuideFrame.inViewport(size);
final dim = Paint()..color = Colors.black.withOpacity(0.45);
final path = Path()
..addRect(Offset.zero & size)
..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(10)))
..fillType = PathFillType.evenOdd;
canvas.drawPath(path, dim);
final border = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..color = Colors.white70;
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(10)),
border,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

554
pubspec.lock Normal file
View File

@ -0,0 +1,554 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
async:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
camera:
dependency: "direct main"
description:
name: camera
sha256: dfa8fc5a1adaeb95e7a54d86a5bd56f4bb0e035515354c8ac6d262e35cec2ec8
url: "https://pub.dev"
source: hosted
version: "0.10.6"
camera_android:
dependency: transitive
description:
name: camera_android
sha256: "32f04948a284b71d938fe275616faf4957d07f9b3aab8021bfc8c418301a289e"
url: "https://pub.dev"
source: hosted
version: "0.10.9+11"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: ca36181194f429eef3b09de3c96280f2400693f9735025f90d1f4a27465fdd72
url: "https://pub.dev"
source: hosted
version: "0.9.19"
camera_platform_interface:
dependency: transitive
description:
name: camera_platform_interface
sha256: "2f757024a48696ff4814a789b0bd90f5660c0fb25f393ab4564fb483327930e2"
url: "https://pub.dev"
source: hosted
version: "2.10.0"
camera_web:
dependency: transitive
description:
name: camera_web
sha256: "595f28c89d1fb62d77c73c633193755b781c6d2e0ebcd8dc25b763b514e6ba8f"
url: "https://pub.dev"
source: hosted
version: "0.3.5"
characters:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
clock:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
url: "https://pub.dev"
source: hosted
version: "1.1.1"
collection:
dependency: transitive
description:
name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
version: "1.18.0"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
url: "https://pub.dev"
source: hosted
version: "0.3.4+2"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev"
source: hosted
version: "1.0.8"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
ffi:
dependency: transitive
description:
name: ffi
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33"
url: "https://pub.dev"
source: hosted
version: "0.9.3+2"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc"
url: "https://pub.dev"
source: hosted
version: "0.9.4+2"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b
url: "https://pub.dev"
source: hosted
version: "2.6.2"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b"
url: "https://pub.dev"
source: hosted
version: "0.9.3+4"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "9ee02950848f61c4129af3d6ec84a1cfc0e47931abc746b03e7a3bc3e8ff6eda"
url: "https://pub.dev"
source: hosted
version: "2.0.22"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
google_mlkit_commons:
dependency: transitive
description:
name: google_mlkit_commons
sha256: "8f40fbac10685cad4715d11e6a0d86837d9ad7168684dfcad29610282a88e67a"
url: "https://pub.dev"
source: hosted
version: "0.11.0"
google_mlkit_object_detection:
dependency: "direct main"
description:
name: google_mlkit_object_detection
sha256: "0f740f046d74faf81d9c44cdbe4accf33888ed9f877e30efbfad4578d45ebfcd"
url: "https://pub.dev"
source: hosted
version: "0.15.0"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
image:
dependency: "direct main"
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "8c5abf0dcc24fe6e8e0b4a5c0b51a5cf30cefdf6407a3213dae61edc75a70f56"
url: "https://pub.dev"
source: hosted
version: "0.8.12+12"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100"
url: "https://pub.dev"
source: hosted
version: "0.8.12+2"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9"
url: "https://pub.dev"
source: hosted
version: "0.2.1+2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1"
url: "https://pub.dev"
source: hosted
version: "0.2.1+2"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0"
url: "https://pub.dev"
source: hosted
version: "2.10.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb"
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a"
url: "https://pub.dev"
source: hosted
version: "10.0.4"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
lints:
dependency: transitive
description:
name: lints
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
url: "https://pub.dev"
source: hosted
version: "3.0.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
url: "https://pub.dev"
source: hosted
version: "0.8.0"
meta:
dependency: transitive
description:
name: meta
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136"
url: "https://pub.dev"
source: hosted
version: "1.12.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
path:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "6f01f8e37ec30b07bc424b4deabac37cacb1bc7e2e515ad74486039918a37eb7"
url: "https://pub.dev"
source: hosted
version: "2.2.10"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
url: "https://pub.dev"
source: hosted
version: "6.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
source_span:
dependency: transitive
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.2"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://pub.dev"
source: hosted
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
url: "https://pub.dev"
source: hosted
version: "1.3.2"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
url: "https://pub.dev"
source: hosted
version: "14.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
sdks:
dart: ">=3.4.0 <4.0.0"
flutter: ">=3.22.0"

25
pubspec.yaml Normal file
View File

@ -0,0 +1,25 @@
name: carddex_demo
description: Card scan demo — capture, lock border, crop/correct, preview.
publish_to: 'none'
version: 2.0.0+1
environment:
sdk: '>=3.4.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.6
camera: 0.10.6
google_mlkit_object_detection: ^0.15.0
image: ^4.3.0
image_picker: ^1.1.2
path_provider: ^2.1.4
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true

10
test/widget_test.dart Normal file
View File

@ -0,0 +1,10 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:carddex_demo/main.dart';
void main() {
testWidgets('app builds', (tester) async {
await tester.pumpWidget(const CardScanDemoApp());
expect(find.text('卡牌扫描'), findsOneWidget);
});
}