diff --git a/.github/workflows/apk.yml b/.github/workflows/apk.yml index 661e537..070914c 100644 --- a/.github/workflows/apk.yml +++ b/.github/workflows/apk.yml @@ -44,13 +44,13 @@ jobs: - name: Rename APK with version run: | cd example/build/app/outputs/flutter-apk/ - mv app-release.apk document_scanner_example_v${{ steps.ver.outputs.VERSION }}.apk + mv app-release.apk DocumentScanner_v${{ steps.ver.outputs.VERSION }}.apk ls -la *.apk - name: Upload APK artifact uses: actions/upload-artifact@v4 with: - name: document_scanner_example_v${{ steps.ver.outputs.VERSION }}-apk - path: example/build/app/outputs/flutter-apk/document_scanner_example_v${{ steps.ver.outputs.VERSION }}.apk + name: DocumentScanner_v${{ steps.ver.outputs.VERSION }}-apk + path: example/build/app/outputs/flutter-apk/DocumentScanner_v${{ steps.ver.outputs.VERSION }}.apk if-no-files-found: error retention-days: 30 diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 0e9a019..c49240a 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -83,7 +83,7 @@ jobs: - name: Rename APK with version run: | cd example/build/app/outputs/flutter-apk/ - mv app-release.apk document_scanner_example_v${{ steps.get_version.outputs.VERSION }}.apk + mv app-release.apk DocumentScanner_v${{ steps.get_version.outputs.VERSION }}.apk ls -la *.apk - name: Generate release notes @@ -139,7 +139,7 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v3 with: - files: example/build/app/outputs/flutter-apk/document_scanner_example_v${{ steps.get_version.outputs.VERSION }}.apk + files: example/build/app/outputs/flutter-apk/DocumentScanner_v${{ steps.get_version.outputs.VERSION }}.apk body_path: release_notes.md name: Document Scanner v${{ steps.get_version.outputs.VERSION }} tag_name: ${{ github.ref_name }} diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 7873cef..4ba5d06 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -11,7 +11,7 @@ + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..23f4f69 100644 Binary files a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..3c909ee 100644 Binary files a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..596a326 100644 Binary files a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..5f1f891 100644 Binary files a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..b7bf5e2 100644 Binary files a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/example/assets/icon/icon.png b/example/assets/icon/icon.png new file mode 100644 index 0000000..fcde7ce Binary files /dev/null and b/example/assets/icon/icon.png differ diff --git a/example/assets/icon/icon_background.png b/example/assets/icon/icon_background.png new file mode 100644 index 0000000..992e9e9 Binary files /dev/null and b/example/assets/icon/icon_background.png differ diff --git a/example/assets/icon/icon_foreground.png b/example/assets/icon/icon_foreground.png new file mode 100644 index 0000000..96fd67d Binary files /dev/null and b/example/assets/icon/icon_foreground.png differ diff --git a/example/pubspec.lock b/example/pubspec.lock index 9771dd6..cad2015 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -89,6 +97,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -197,6 +221,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" flutter_lints: dependency: "direct dev" description: @@ -311,6 +343,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1+1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -692,6 +732,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.8.1 <4.0.0" flutter: ">=3.32.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 6d8d768..ca5bc7f 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -19,6 +19,17 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + flutter_launcher_icons: ^0.14.1 flutter: uses-material-design: true + +# Launcher icon: a sheet of paper with a smartphone scanning it. +# Regenerate with: dart run flutter_launcher_icons +flutter_launcher_icons: + android: true + ios: false + image_path: "assets/icon/icon.png" + adaptive_icon_foreground: "assets/icon/icon_foreground.png" + adaptive_icon_background: "assets/icon/icon_background.png" + min_sdk_android: 21 diff --git a/example/tool/make_icon.py b/example/tool/make_icon.py new file mode 100644 index 0000000..28877e2 --- /dev/null +++ b/example/tool/make_icon.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Generate the Document Scanner launcher icon: a sheet of paper with a +smartphone scanning it (green scan brackets + beam).""" +from PIL import Image, ImageDraw +import sys + +SS = 4 # supersample factor for anti-aliasing +S = 1024 # final size +W = S * SS # working size + +BLUE_TOP = (30, 136, 229) +BLUE_BOT = (13, 71, 161) +WHITE = (255, 255, 255, 255) +LINE = (207, 216, 220, 255) +PHONE = (38, 50, 56, 255) +SCREEN = (236, 239, 241, 255) +LENS = (96, 125, 139, 255) +GREEN = (0, 230, 118, 255) + + +def px(v): # scale a 1024-space value into working space + return int(v * SS) + + +def vgradient_rounded(size, top, bottom, radius): + base = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + grad = Image.new("RGBA", (size, size)) + gd = ImageDraw.Draw(grad) + for y in range(size): + t = y / (size - 1) + r = int(top[0] + (bottom[0] - top[0]) * t) + g = int(top[1] + (bottom[1] - top[1]) * t) + b = int(top[2] + (bottom[2] - top[2]) * t) + gd.line([(0, y), (size, y)], fill=(r, g, b, 255)) + mask = Image.new("L", (size, size), 0) + ImageDraw.Draw(mask).rounded_rectangle( + [0, 0, size - 1, size - 1], radius=radius, fill=255 + ) + base.paste(grad, (0, 0), mask) + return base + + +def make_subject(): + """Subject (sheet + scanning phone) on a transparent WxW canvas, centered.""" + canvas = Image.new("RGBA", (W, W), (0, 0, 0, 0)) + cx = W // 2 + + # --- document (back), tilted left --- + dw, dh = px(430), px(560) + doc = Image.new("RGBA", (dw, dh), (0, 0, 0, 0)) + dd = ImageDraw.Draw(doc) + dd.rounded_rectangle([0, 0, dw - 1, dh - 1], radius=px(26), fill=WHITE) + lx0, lx1 = px(58), dw - px(58) + ly, gap, lh = px(86), px(74), px(26) + for i in range(5): + x1 = lx1 if i != 4 else int(dw * 0.6) + dd.rounded_rectangle([lx0, ly, x1, ly + lh], radius=lh // 2, fill=LINE) + ly += gap + doc = doc.rotate(11, expand=True, resample=Image.BICUBIC) + canvas.alpha_composite( + doc, (cx - px(150) - doc.width // 2, W // 2 - px(70) - doc.height // 2) + ) + + # --- smartphone (front, tilted right) actively scanning --- + pw, ph = px(320), px(600) + phone = Image.new("RGBA", (pw, ph), (0, 0, 0, 0)) + pd = ImageDraw.Draw(phone) + pd.rounded_rectangle([0, 0, pw - 1, ph - 1], radius=px(58), fill=PHONE) + m = px(20) + pd.rounded_rectangle( + [m, px(64), pw - m, ph - px(64)], radius=px(40), fill=SCREEN + ) + pd.ellipse([pw // 2 - px(9), px(30), pw // 2 + px(9), px(48)], fill=LENS) + + # green scan corner brackets + beam on the phone screen + sx0, sy0 = m + px(30), px(140) + sx1, sy1 = pw - m - px(30), ph - px(140) + t, blen = px(15), px(66) + pd.rectangle([sx0, sy0, sx0 + blen, sy0 + t], fill=GREEN) + pd.rectangle([sx0, sy0, sx0 + t, sy0 + blen], fill=GREEN) + pd.rectangle([sx1 - blen, sy0, sx1, sy0 + t], fill=GREEN) + pd.rectangle([sx1 - t, sy0, sx1, sy0 + blen], fill=GREEN) + pd.rectangle([sx0, sy1 - t, sx0 + blen, sy1], fill=GREEN) + pd.rectangle([sx0, sy1 - blen, sx0 + t, sy1], fill=GREEN) + pd.rectangle([sx1 - blen, sy1 - t, sx1, sy1], fill=GREEN) + pd.rectangle([sx1 - t, sy1 - blen, sx1, sy1], fill=GREEN) + by = (sy0 + sy1) // 2 + pd.rectangle([sx0, by - px(5), sx1, by + px(5)], fill=(0, 230, 118, 200)) + + phone = phone.rotate(-11, expand=True, resample=Image.BICUBIC) + canvas.alpha_composite( + phone, (cx + px(150) - phone.width // 2, W // 2 + px(80) - phone.height // 2) + ) + return canvas + + +def fit_subject(subject, frac): + """Return a WxW canvas with the subject scaled to occupy `frac` of width.""" + bbox = subject.getbbox() + cropped = subject.crop(bbox) + target_w = int(W * frac) + scale = target_w / cropped.width + target_h = int(cropped.height * scale) + if target_h > int(W * frac): + scale = int(W * frac) / cropped.height + target_w = int(cropped.width * scale) + target_h = int(W * frac) + resized = cropped.resize((target_w, target_h), Image.LANCZOS) + out = Image.new("RGBA", (W, W), (0, 0, 0, 0)) + out.alpha_composite(resized, ((W - target_w) // 2, (W - target_h) // 2)) + return out + + +def main(): + legacy_path, fg_path, bg_path = sys.argv[1], sys.argv[2], sys.argv[3] + subject = make_subject() + + # Legacy full icon: gradient rounded-square bg + subject + bg = vgradient_rounded(W, BLUE_TOP, BLUE_BOT, radius=px(180)) + legacy = bg.copy() + legacy.alpha_composite(fit_subject(subject, 0.74)) + legacy.resize((S, S), Image.LANCZOS).save(legacy_path) + + # Adaptive foreground: subject fills most of the drawable; the adaptive + # XML adds a 16% inset for the safe zone (0.90 * 0.68 ≈ 0.61 of the icon). + fit_subject(subject, 0.90).resize((S, S), Image.LANCZOS).save(fg_path) + + # Adaptive background: full-bleed gradient (launcher applies the mask) + fullbg = Image.new("RGBA", (W, W)) + gd = ImageDraw.Draw(fullbg) + for y in range(W): + t = y / (W - 1) + gd.line( + [(0, y), (W, y)], + fill=( + int(BLUE_TOP[0] + (BLUE_BOT[0] - BLUE_TOP[0]) * t), + int(BLUE_TOP[1] + (BLUE_BOT[1] - BLUE_TOP[1]) * t), + int(BLUE_TOP[2] + (BLUE_BOT[2] - BLUE_TOP[2]) * t), + 255, + ), + ) + fullbg.resize((S, S), Image.LANCZOS).save(bg_path) + + print("wrote", legacy_path, fg_path, bg_path) + + +if __name__ == "__main__": + main() diff --git a/lib/src/ui/document_camera_screen.dart b/lib/src/ui/document_camera_screen.dart index 566ebae..166ccde 100644 --- a/lib/src/ui/document_camera_screen.dart +++ b/lib/src/ui/document_camera_screen.dart @@ -200,6 +200,15 @@ class _DocumentCameraScreenState extends State } Widget _buildCameraView() { + // `controller.value.aspectRatio` is the sensor ratio in *landscape* + // (>= 1). On a portrait screen we must invert it, otherwise the preview is + // forced into a wide/short box: small and horizontally squashed. + final sensorRatio = _controller!.value.aspectRatio; + final previewRatio = + MediaQuery.of(context).orientation == Orientation.portrait + ? 1 / sensorRatio + : sensorRatio; + return Stack( fit: StackFit.expand, children: [ @@ -207,7 +216,7 @@ class _DocumentCameraScreenState extends State // so that fractional corner positions map directly to the captured image. Center( child: AspectRatio( - aspectRatio: _controller!.value.aspectRatio, + aspectRatio: previewRatio, child: Stack( fit: StackFit.expand, children: [