FlutterでQRコードを読むアプリを作ってみる


[Flutter始めました](https://qiita.com/fukuit/items/78d02135b76cfa52eb21) のイキオイで、QRコードを読むアプリ(2020年4月末版)を作ってみる。

## 環境

- macOS Mojave 10.14.6
- Android Studio 3.6.3

なお、```flutter --version```の結果は、以下の通り。

```bash
% flutter --version
Flutter 1.12.13+hotfix.9 • channel stable •
https://github.com/flutter/flutter.git
Framework • revision f139b11009 (4 weeks ago) • 2020-03-30 13:57:30 -0700
Engine • revision af51afceb8
Tools • Dart 2.7.2
```

## 準備

まずは、Android Studioにて、```New Flutter Project...```する。
いつもの、ボタンをクリックすると数字がカウントアップされるアプリの雛形が生成される。

![20200428-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15553/99ed5bed-37a4-b6bd-32f0-b6abb32d6b6d.png "20200428-01.png")

### やること

1. デザインの変更
2. ライブラリのimport
3. 実機でテスト

## デザインの変更

デザインの変更といっても、大したことはしない。

### ボタンの変更

ボタンのデザインをカメラにして、tooltipも変えておく。

```dart:lib/main.dart
...
floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Scan',
        child: Icon(Icons.camera),
      ), 
...
```

タイトルなどの変更もする。

```dart:lib/main.dart
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'QR Scanner',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'QRcode Scanner'),
    );
  }
}
```

![20200428-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15553/63e9ffb9-7021-96ab-68f0-fd3b2e94ad46.png "20200428-02.png")


とりあえず実行してみるが、カメラアイコンのボタンを押すと、ただ数字がカウントアップされるというちぐはぐ状態だ。

## ライブラリのimport
### QRコード読み取り実装のための準備

[barcode_scan](https://pub.dev/packages/barcode_scan) を使う。
ドキュメントに従い、Android用の設定と、iOS用の設定を行う。

まずは、カメラシステムを使うための設定。

```AndroidManifest.xml```の```<manifest...```内に、```<uses-permission android:name="android.permission.CAMERA" />```を追記する。

```xml:android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="jp.gr.javaconf.fukuit.qrscanner">
    <uses-permission android:name="android.permission.CAMERA" />
...
```

ドキュメントによると、```android/build.gradle```と```android/app/build.gradle```にkotlinを使う設定を追記すべしということになっているが、デフォルトで記載済になっていた。
おそらく、Android StudioにKotlinプラグインがインストールされているからだと思うけれど、詳細は不明。とにかく、記載済なのだから問題ない。

次に、```pubspec.yaml```のdependenciesの項に、```barcode_scan```のことを追記する。

```yaml:pubspec.yaml
dependencies:
...
    # https://pub.dev/packages/barcode_scan
    barcode_scan: any
...
```

また、iOSでも使えるようにするために、```ios/Runner/Info.plist```にもカメラ使用許可のための記載を追記する。

```xml:ios/Runner/Info.plist
<dict>
...
    <key>NSCameraUsageDescription</key>
    <string>Camera permission is required for barcode scanning.</string>
...
</dict>
```

ここまでやったら、```flutter packages get```する。

```bash
% flutter packages get
Running "flutter pub get" in qr_scanner...                          2.1s
```

### barcode_scan.dartのimport

```barcode_scan.dart``` をimportして、QRコード読み取り部の実装をする。

とりあえず、ボタンを押したらカメラが起動して、QRコードを読み込んだら、その内容をテキストで表示するだけのアプリとして実装する。
ということで、ひな形のアプリの```void _incrementCounter()```を_scan()で置き換える方針で。

まずは、```barcode_scan.dart```のimport文を追加する。

```dart:lib/main.dart
import 'package:barcode_scan/barcode_scan.dart';
```

次に、```class _MyHomePageState extends State<MyHomePage>```を書き換える。[barcode_scanのサンプルコード](https://pub.dev/packages/barcode_scan#-example-tab-)を参考にする。


```dart:lib/main.dart
class _MyHomePageState extends State<MyHomePage> {
  ScanResult scanResult;

  Future _scan() async {
    try {
      var result = await BarcodeScanner.scan();
      setState(() => scanResult = result);
    } on PlatformException catch (e) {
      var result = ScanResult(
        type: ResultType.Error,
        format: BarcodeFormat.unknown,
      );
      if (e.code == BarcodeScanner.cameraAccessDenied) {
        setState(() {
          result.rawContent = 'カメラへのアクセスが許可されていません!';
        });
      } else {
        result.rawContent = 'エラー: $e';
      }
      setState(() {
        scanResult = result;
      });
    }
  }
  ...
}
```

上記のような、```_scan()```関数を作成する。```BarcodeScanner.scan()```の戻り値は```ScanResult```型で、```type```、```rawContent```、```format```、```formatNote```というプロパティを持ち、```rawContent```にQRコードの内容が保持されている。

非同期処理を行うための戻り値としての```Future```型とか```async```キーワードとかに注意。```async```するために、```dart:async```をimportする。また、この関数内の処理で、例外としてPlatformExceptionをcatchするので、```package:flutter/services.dart```をimportする。

ここで、```scanResult```にQRコードを読み込んだ結果を保存するようにしたので、```scanResult```に読み込んだ結果が入っているときはその結果を、そうでない時は「ボタンを押すとカメラが起動します」的なメッセージを表示したい。
そこで、Widgetの定義をする関数のなかで、if文を使いたい。というか、[barcode_scanのサンプルコード](https://pub.dev/packages/barcode_scan#-example-tab-)もそうなっている。

#### pubspec.yamlの修正

ここまで来ると、以下のような警告が表示される。

```
info: The for, if, and spread elements weren't supported until version 2.3.0, but this code is required to be able to run on earlier versions.
```

このメッセージのとおり、```pubspec.yaml```の```sdk:">=2.1.2 < 3.0.0"```のところを修正しておく。

```yaml:pubspec.yaml
...
environment:
  sdk: ">=2.3.0 <3.0.0"
...
```

#### build.gradleの修正

Android用にbuildしたところ、次のようなエラーが発生した。

```
...
AndroidManifest.xml as the library might be using APIs not available in 16
	Suggestion: use a compatible library with a minSdk of at most 16,
		or increase this project's minSdk version to at least 18,
...
```

AndroidManifest.xmlにminSDKの設定があるのかと思いきや、そのような設定は存在しなかった。
そこで、探してみると、```android/app/build.gradle```の```defaultConfig```の中で```minSdkVersion```が定義されていたため、これを16から18に変更した。

```gradle:android/app/build.gradle
    defaultConfig {
...
        minSdkVersion 18
...
    }

```


### lib/main.dartの完成

以上を踏まえて、以下のように```lib/main.dart```を編集した。

```dart:lib/main.dart
  @override
  Widget build(BuildContext context) {
    var contentList = <Widget>[
      if (scanResult != null)
        Card(
          child: Column(
            children: <Widget>[
              ListTile(
                title: Text("Result Type"),
                subtitle: Text(scanResult.type?.toString() ?? ""),
              ),
              ListTile(
                title: Text("RawContent"),
                subtitle: Text(scanResult.rawContent ?? ""),
              ),
              ListTile(
                title: Text("Format"),
                subtitle: Text(scanResult.format?.toString() ?? ""),
              ),
              ListTile(
                title: Text("Format note"),
                subtitle: Text(scanResult.formatNote ?? ""),
              ),
            ],
          ),
        ),
      ListTile(
        title: Text("ボタンを押してカメラを起動してください"),
        subtitle: Text("カメラをQRコードに向けてください"),
      ),
    ];
    return MaterialApp(
        debugShowCheckedModeBanner: false,
        home: Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: ListView(
            scrollDirection: Axis.vertical,
            shrinkWrap: true,
            children: contentList,
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: _scan,
            tooltip: 'Scan',
            child: Icon(Icons.camera),
          ),
        ));
  }
```

これを実行すると、以下のようになる。

![20200428-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15553/7b371727-7300-ee65-10bf-275586941210.png "20200428-03.png")


## 実機でテスト

ここまではAVDで実行結果を見ながらコーディングを進めてきたけれど、AVDではカメラを使ったテストができないので、iPhone8実機で試してみる。

「設定」の「プロファイルとデバイス管理」で、自分のサインを信頼した上で、実行する。

読み込むQRコード画像は、```python```の```qrcode```パッケージを使って作成した[^1]。

![hello.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15553/8fbe695a-d893-c55c-1d2d-d00e6c63ef85.png "hello.png")

### 実行結果

起動すると、次の様な画面になる。

![20200429-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15553/ebc764af-c39b-47d3-25fa-f56bec4a4ac6.png "20200429-01.png")

カメラボタンを押すと、QRコードを読み込むためのカメラが起動する。ちょうど、バーコードを読み込むところのスクリーンショットを撮りたかったんだけれど、なんどやってもタイミングが合わなかった。

![20200429-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15553/c86b94c6-9490-6597-f4f9-3123635417bf.png "20200429-02.png")

読み込んだ結果は、次の様に表示される。

![20200429-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15553/ce502bd4-4c05-f4d7-d3ee-2ccf9c4554cd.png "20200429-03.png")

読み取り結果については、```contentlist.addAll()```で、QRコードを読み込むたびに履歴として追記していくこともできるんじゃなかろうか（試してない）。

## 本日のコード

```xml:android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="jp.gr.javaconf.fukuit.qrscanner">
    <uses-permission android:name="android.permission.CAMERA" />
    <application
        android:name="io.flutter.app.FlutterApplication"
        android:label="qrscanner"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            android:launchMode="singleTop"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
</manifest>
```
```yaml:pubspec.yaml
name: qrscanner
description: QRcode Scanner Application

version: 1.0.0+1

environment:
  sdk: ">=2.3.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^0.1.2

  # https://pub.dev/packages/barcode_scan
  barcode_scan: any

dev_dependencies:
  flutter_test:
    sdk: flutter

# The following section is specific to Flutter.
flutter:
  uses-material-design: true
```

```xml:ios/Runner/Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleDevelopmentRegion</key>
	<string>$(DEVELOPMENT_LANGUAGE)</string>
	<key>CFBundleExecutable</key>
	<string>$(EXECUTABLE_NAME)</string>
	<key>CFBundleIdentifier</key>
	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
	<key>CFBundleInfoDictionaryVersion</key>
	<string>6.0</string>
	<key>CFBundleName</key>
	<string>qrscanner</string>
	<key>CFBundlePackageType</key>
	<string>APPL</string>
	<key>CFBundleShortVersionString</key>
	<string>$(FLUTTER_BUILD_NAME)</string>
	<key>CFBundleSignature</key>
	<string>????</string>
	<key>CFBundleVersion</key>
	<string>$(FLUTTER_BUILD_NUMBER)</string>
	<key>LSRequiresIPhoneOS</key>
	<true/>
	<key>UILaunchStoryboardName</key>
	<string>LaunchScreen</string>
	<key>UIMainStoryboardFile</key>
	<string>Main</string>
	<key>UISupportedInterfaceOrientations</key>
	<array>
		<string>UIInterfaceOrientationPortrait</string>
		<string>UIInterfaceOrientationLandscapeLeft</string>
		<string>UIInterfaceOrientationLandscapeRight</string>
	</array>
	<key>UISupportedInterfaceOrientations~ipad</key>
	<array>
		<string>UIInterfaceOrientationPortrait</string>
		<string>UIInterfaceOrientationPortraitUpsideDown</string>
		<string>UIInterfaceOrientationLandscapeLeft</string>
		<string>UIInterfaceOrientationLandscapeRight</string>
	</array>
	<key>UIViewControllerBasedStatusBarAppearance</key>
	<false/>
    <key>NSCameraUsageDescription</key>
    <string>Camera permission is required for barcode scanning.</string>
</dict>
</plist>
```

```dart:lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:barcode_scan/barcode_scan.dart';
import 'package:flutter/services.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'QR Scanner',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'QR code Scanner'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  ScanResult scanResult;

  Future _scan() async {
    try {
      var result = await BarcodeScanner.scan();
      setState(() => scanResult = result);
    } on PlatformException catch (e) {
      var result = ScanResult(
        type: ResultType.Error,
        format: BarcodeFormat.unknown,
      );
      if (e.code == BarcodeScanner.cameraAccessDenied) {
        setState(() {
          result.rawContent = 'カメラへのアクセスが許可されていません!';
        });
      } else {
        result.rawContent = 'エラー: $e';
      }
      setState(() {
        scanResult = result;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    var contentList = <Widget>[
      if (scanResult != null)
        Card(
          child: Column(
            children: <Widget>[
              ListTile(
                title: Text("Result Type"),
                subtitle: Text(scanResult.type?.toString() ?? ""),
              ),
              ListTile(
                title: Text("RawContent"),
                subtitle: Text(scanResult.rawContent ?? ""),
              ),
              ListTile(
                title: Text("Format"),
                subtitle: Text(scanResult.format?.toString() ?? ""),
              ),
              ListTile(
                title: Text("Format note"),
                subtitle: Text(scanResult.formatNote ?? ""),
              ),
            ],
          ),
        ),
      ListTile(
          title: Text("ボタンを押してカメラを起動してください"),
          subtitle: Text("カメラをQRコードに向けてください")),
    ];
    return MaterialApp(
        debugShowCheckedModeBanner: false,
        home: Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: ListView(
            scrollDirection: Axis.vertical,
            shrinkWrap: true,
            children: contentList,
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: _scan,
            tooltip: 'Scan',
            child: Icon(Icons.camera),
          ),
        ));
  }
}
```

----

次の予定としては、読み込んだQRコードの内容を、どこかにストアして後から読み出せるようにする仕組み作りかな。

[^1]: qrcodeを```% pip install -U qrcode```でインストールしてから、```% qr 'こんにちは、世界' > hello.png``` して作成した。