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>
43
.gitignore
vendored
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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 = "../.."
|
||||
}
|
||||
7
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||
50
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@ -0,0 +1,5 @@
|
||||
package com.carddex.carddex_demo
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity()
|
||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal 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>
|
||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal 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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal 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>
|
||||
18
android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
7
android/app/src/profile/AndroidManifest.xml
Normal 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
@ -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
|
||||
}
|
||||
3
android/gradle.properties
Normal file
@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@ -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"
|
||||
513
carddex_flutter_demo_plan.md
Normal 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
@ -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`) |
|
||||
|
||||
大图最长边限制约 2560;JPEG 质量约 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
@ -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
|
||||
26
ios/Flutter/AppFrameworkInfo.plist
Normal 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>
|
||||
2
ios/Flutter/Debug.xcconfig
Normal file
@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
2
ios/Flutter/Release.xcconfig
Normal file
@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
42
ios/Podfile
Normal 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
|
||||
616
ios/Runner.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@ -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>
|
||||
@ -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>
|
||||
98
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal 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>
|
||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@ -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>
|
||||
@ -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>
|
||||
13
ios/Runner/AppDelegate.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal 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.
|
||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal 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>
|
||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal 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
@ -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>
|
||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
12
ios/RunnerTests/RunnerTests.swift
Normal 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
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
306
lib/screens/capture_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
376
lib/screens/preview_screen.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
484
lib/services/card_corner_refiner.dart
Normal 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.9–1.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;
|
||||
}
|
||||
}
|
||||
223
lib/services/card_cropper.dart
Normal 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 TL→TR→BR→BL 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
165
lib/services/card_detector.dart
Normal 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();
|
||||
}
|
||||
57
lib/widgets/guide_frame.dart
Normal 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);
|
||||
}
|
||||
44
lib/widgets/guide_overlay.dart
Normal 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
@ -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
@ -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
@ -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);
|
||||
});
|
||||
}
|
||||