From ccf8eeb21dce8213cb4c9914f4faa6933bcf999f Mon Sep 17 00:00:00 2001 From: a1518 Date: Sun, 12 Jul 2026 20:17:32 -0700 Subject: [PATCH] Initial commit: Flutter card recognition demo Camera capture, corner detection/refinement, and preview crop pipeline for trading cards. Co-authored-by: Cursor --- .gitignore | 43 ++ .metadata | 33 + README.md | 16 + analysis_options.yaml | 28 + android/.gitignore | 13 + android/app/build.gradle | 59 ++ android/app/src/debug/AndroidManifest.xml | 7 + android/app/src/main/AndroidManifest.xml | 50 ++ .../com/carddex/carddex_demo/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + android/app/src/main/res/values/styles.xml | 18 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle | 18 + android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 5 + android/settings.gradle | 25 + carddex_flutter_demo_plan.md | 513 +++++++++++++++ docs/demo说明.md | 205 ++++++ ios/.gitignore | 34 + ios/Flutter/AppFrameworkInfo.plist | 26 + ios/Flutter/Debug.xcconfig | 2 + ios/Flutter/Release.xcconfig | 2 + ios/Podfile | 42 ++ ios/Runner.xcodeproj/project.pbxproj | 616 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 98 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + ios/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 122 ++++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + ios/Runner/Base.lproj/LaunchScreen.storyboard | 37 ++ ios/Runner/Base.lproj/Main.storyboard | 26 + ios/Runner/Info.plist | 53 ++ ios/Runner/Runner-Bridging-Header.h | 1 + ios/RunnerTests/RunnerTests.swift | 12 + lib/main.dart | 34 + lib/screens/capture_screen.dart | 306 +++++++++ lib/screens/preview_screen.dart | 376 +++++++++++ lib/services/card_corner_refiner.dart | 484 ++++++++++++++ lib/services/card_cropper.dart | 223 +++++++ lib/services/card_detector.dart | 165 +++++ lib/widgets/guide_frame.dart | 57 ++ lib/widgets/guide_overlay.dart | 44 ++ pubspec.lock | 554 ++++++++++++++++ pubspec.yaml | 25 + test/widget_test.dart | 10 + 76 files changed, 4526 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/com/carddex/carddex_demo/MainActivity.kt create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle create mode 100644 carddex_flutter_demo_plan.md create mode 100644 docs/demo说明.md create mode 100644 ios/.gitignore create mode 100644 ios/Flutter/AppFrameworkInfo.plist create mode 100644 ios/Flutter/Debug.xcconfig create mode 100644 ios/Flutter/Release.xcconfig create mode 100644 ios/Podfile create mode 100644 ios/Runner.xcodeproj/project.pbxproj create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 ios/Runner/AppDelegate.swift create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 ios/Runner/Base.lproj/Main.storyboard create mode 100644 ios/Runner/Info.plist create mode 100644 ios/Runner/Runner-Bridging-Header.h create mode 100644 ios/RunnerTests/RunnerTests.swift create mode 100644 lib/main.dart create mode 100644 lib/screens/capture_screen.dart create mode 100644 lib/screens/preview_screen.dart create mode 100644 lib/services/card_corner_refiner.dart create mode 100644 lib/services/card_cropper.dart create mode 100644 lib/services/card_detector.dart create mode 100644 lib/widgets/guide_frame.dart create mode 100644 lib/widgets/guide_overlay.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/widget_test.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29a3a50 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..bfb1e6f --- /dev/null +++ b/.metadata @@ -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' diff --git a/README.md b/README.md new file mode 100644 index 0000000..38c989b --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -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 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..6f56801 --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..af8d350 --- /dev/null +++ b/android/app/build.gradle @@ -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 = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..194286a --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/carddex/carddex_demo/MainActivity.kt b/android/app/src/main/kotlin/com/carddex/carddex_demo/MainActivity.kt new file mode 100644 index 0000000..d05aef4 --- /dev/null +++ b/android/app/src/main/kotlin/com/carddex/carddex_demo/MainActivity.kt @@ -0,0 +1,5 @@ +package com.carddex.carddex_demo + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..d2ffbff --- /dev/null +++ b/android/build.gradle @@ -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 +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..3b5b324 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c6a2952 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..5ae1247 --- /dev/null +++ b/android/settings.gradle @@ -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" diff --git a/carddex_flutter_demo_plan.md b/carddex_flutter_demo_plan.md new file mode 100644 index 0000000..7af05e0 --- /dev/null +++ b/carddex_flutter_demo_plan.md @@ -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 _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 createState() => _ScannerScreenState(); +} + +class _ScannerScreenState extends State { + late CameraController _controller; + List _detections = []; + bool _isProcessing = false; + + @override + void initState() { + super.initState(); + _controller = CameraController( + widget.camera, + ResolutionPreset.high, + ); + _controller.initialize().then((_) => setState(() {})); + } + + Future _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 _labels = []; + bool _isInitialized = false; + + // 模型输入尺寸(COCO SSD MobileNet 标准) + static const int inputSize = 300; + static const double confidenceThreshold = 0.5; + + Future 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> 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 = >[]; + final classes = []; + final scores = []; + + 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 suppress( + List> boxes, + List scores, { + double iouThreshold = 0.5, + }) { + // 按分数排序 + final indices = List.generate(scores.length, (i) => i); + indices.sort((a, b) => scores[b].compareTo(scores[a])); + + final selected = []; + 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 a, List 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 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 周 | diff --git a/docs/demo说明.md b/docs/demo说明.md new file mode 100644 index 0000000..4dba331 --- /dev/null +++ b/docs/demo说明.md @@ -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
相机 / 相册"] --> 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,而不是继续堆叠本仓库内的几何管线。 diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -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 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..2c51016 --- /dev/null +++ b/ios/Podfile @@ -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 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1360ebe --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 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 = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* 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 = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 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 = ""; + }; +/* 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 = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..8e3ca5d --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..9074fee --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -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) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 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 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 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 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -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" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -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. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..795da2c --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,53 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Carddex Demo + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + carddex_demo + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + NSCameraUsageDescription + Camera access is required to scan and crop trading cards. + NSPhotoLibraryUsageDescription + Photo library access is required to pick a card image. + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -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. + } + +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..e40dc34 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'screens/capture_screen.dart'; + +Future 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(), + ); + } +} diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart new file mode 100644 index 0000000..fa7deeb --- /dev/null +++ b/lib/screens/capture_screen.dart @@ -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 createState() => _CaptureScreenState(); +} + +class _CaptureScreenState extends State + with WidgetsBindingObserver { + final _picker = ImagePicker(); + + CameraController? _camera; + List _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 _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 _openPreview(String path) async { + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PreviewScreen( + imagePath: path, + captureViewport: _previewSize, + ), + ), + ); + } + + Future _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 _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 _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), + ), + ), + ); + } +} diff --git a/lib/screens/preview_screen.dart b/lib/screens/preview_screen.dart new file mode 100644 index 0000000..30fa37c --- /dev/null +++ b/lib/screens/preview_screen.dart @@ -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 createState() => _PreviewScreenState(); +} + +class _PreviewScreenState extends State { + 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 _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; + } +} diff --git a/lib/services/card_corner_refiner.dart b/lib/services/card_corner_refiner.dart new file mode 100644 index 0000000..99a540f --- /dev/null +++ b/lib/services/card_corner_refiner.dart @@ -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? 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.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.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 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 _coverAabbTop( + List 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 _padQuad( + List 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 _boxBlur3(List src, int w, int h) { + final tmp = List.filled(w * h, 0); + final out = List.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 gray) { + final hist = List.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? _largestComponent( + List mask, + int w, + int h, + int seedX, + int seedY, + ) { + final seen = List.filled(w * h, false); + List? bestEdge; + var bestCount = 0; + List? seededEdge; + var seededCount = 0; + final qx = List.filled(w * h, 0); + final qy = List.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 = []; + 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 _convexHull(List 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 = []; + 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 = []; + 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 = []; + 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? _minAreaRect(List hull) { + if (hull.length < 3) return null; + var bestArea = double.infinity; + List? 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 _orderCorners(List 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 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; + } +} diff --git a/lib/services/card_cropper.dart b/lib/services/card_cropper.dart new file mode 100644 index 0000000..19c48f7 --- /dev/null +++ b/lib/services/card_cropper.dart @@ -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 cropAndCorrect({ + required String imagePath, + CardBox? box, + double padding = 0.06, + }) async { + return computeCrop(imagePath, box, padding); + } + + static Future 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 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, + ); + } +} diff --git a/lib/services/card_detector.dart b/lib/services/card_detector.dart new file mode 100644 index 0000000..b9a56f6 --- /dev/null +++ b/lib/services/card_detector.dart @@ -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? corners; + + /// Effective lock quad (tilted when refined). + List 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? 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> detectFile(String path) async { + final input = InputImage.fromFilePath(path); + final objects = await _detector.processImage(input); + final boxes = []; + 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 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 dispose() => _detector.close(); +} diff --git a/lib/widgets/guide_frame.dart b/lib/widgets/guide_frame.dart new file mode 100644 index 0000000..6589ebb --- /dev/null +++ b/lib/widgets/guide_frame.dart @@ -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); +} diff --git a/lib/widgets/guide_overlay.dart b/lib/widgets/guide_overlay.dart new file mode 100644 index 0000000..e1df710 --- /dev/null +++ b/lib/widgets/guide_overlay.dart @@ -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; +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..0650b6e --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..19e6ec9 --- /dev/null +++ b/pubspec.yaml @@ -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 diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..133e1d5 --- /dev/null +++ b/test/widget_test.dart @@ -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); + }); +}