FlutterでQRコードリーダー／履歴管理アプリを作ってみた

# はじめに
flutterでQRコードを読み取り、そのデータを履歴として管理できるアプリを作ってみたので、自分用の備忘として記録を残します。
（なお筆者はflutterの初学者であるためお手柔らかにご覧いただけますと幸いです。誤っている点の指摘やコメントはウェルカムです！）

# 開発環境

- Windows 10 Pro 21H2 19044.1826
- Flutter 3.0.1
- Dart 2.17.1
- Android Studio Chipmunk 2021.2.1


# 画面遷移

本アプリは以下の画面から構成されています。

- 読み込み履歴一覧
- QRコード読み取り
- 履歴詳細
- ウェブブラウザ
![画面遷移図.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1278906/e4e5189c-db96-1867-b592-76fe9d3002ee.jpeg)


# 実装

プロジェクト作成時のテンプレートから変更した点を抜粋して記載します。
なお、今回のアプリでは履歴のデータを`firebase`で管理していますが、そちらの設定は割愛します。

## プロジェクト設定

ここには使用するパッケージの設定等を記載します。
`dependencies`に`firebase`との連携で必要になる`firebase_core`, `firebase_auth`, `cloud_firestore`やQRコードの生成・読み込みで必要になる`qr_flutter`, `qr_code_scanner`、QRコードから読み込んだURLにアクセスする際に必要となる`url_launcher`等を追加しています。

```yaml:pubspec.yaml
name: qr_scanner_history
description: A new Flutter project.

publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ">=2.17.1 <3.0.0"
dependencies:
  flutter:
    sdk: flutter
  flutter_slidable:
  shared_preferences:
  firebase_core: ^1.4.0
  firebase_auth: ^1.0.1
  cloud_firestore: ^2.4.0
  url_launcher: ^6.1.4
  cupertino_icons: ^1.0.2
  qr_flutter: ^4.0.0
  qr_code_scanner: ^1.0.0
dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
flutter:
  uses-material-design: true
```

## main関数

アプリが起動すると`runApp`が実行されます。
こちらの記載は最小限にしており、以降のファイルにそれぞれの画面の処理を記載しています。
```dart:main.dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';

import 'app.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(MyApp());
}
```


## 読み込み履歴一覧

ここには、これまでQRコードから読み込んだデータの一覧表示と、QRコード読み取り画面に遷移するためのボタン配置等が記載されています。

```dart:app.dart
import 'package:flutter/material.dart';
import 'dart:developer';

import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';

import 'details.dart';
import 'add.dart';

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // 右上に表示される"debug"ラベルを消す
      debugShowCheckedModeBanner: false,
      // アプリ名
      title: 'QR App',
      // ダークテーマ
      theme: ThemeData.dark(),
      // リスト一覧画面を表示
      home: QRListPage(),
    );
  }
}

// リスト一覧画面用Widget
class QRListPage extends StatefulWidget {
  @override
  _QRListPageState createState() => _QRListPageState();
}

class _QRListPageState extends State<QRListPage> {
  String? data;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark(),
      home: Scaffold(
        appBar: AppBar(
          title: Text('QR List'),
        ),
        body: StreamBuilder<QuerySnapshot>(
          stream: FirebaseFirestore.instance.collection('QR').snapshots(),
          builder:
              (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
            return ListView(
              children: snapshot.data!.docs.map((DocumentSnapshot document) {
                return Card(
                  child: ListTile(
                      leading: Icon(Icons.label_important),
                      title: Text(document.get('text')),
                      onTap: () {
                        Navigator.push(
                          context,
                          MaterialPageRoute(
                              builder: (context) =>
                                  DetailsPage(document.get('text'))),
                        );
                      },
                      trailing: IconButton(
                        icon: Icon(Icons.delete),
                        onPressed: () async {
                          // 対象のドキュメントを削除
                          await FirebaseFirestore.instance
                              .collection('QR')
                              .doc(document.id)
                              .delete();
                        },
                      )),
                );
              }).toList(),
            );
          },
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () async {
            final newListText = await Navigator.of(context).push(
              MaterialPageRoute(builder: (context) {
                // 遷移先の画面としてリスト追加画面を指定
                return QRAddPage();
              }),
            );
          },
          child: Icon(Icons.add),
        ),
      ),
    );
  }
}
```


## QRコード読み取り

ここにはQRコードを読み取り`firebase`にデータを追加する処理等が記載されています。


```dart:add.dart
import 'package:flutter/material.dart';
import 'dart:developer';

import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';

import 'package:flutter/foundation.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart';

class QRAddPage extends StatefulWidget {
  @override
  _QRAddPageState createState() => _QRAddPageState();
}

class _QRAddPageState extends State<QRAddPage> {
  Barcode? result;
  QRViewController? controller;
  final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
  String _text = '';
  final myController = TextEditingController();
  final upDateController = TextEditingController();
  var _selectedvalue;

  // データを元に表示するWidget
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('リスト追加'),
      ),
      body: Container(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            // 入力されたテキストを表示
            Text(_text, style: TextStyle(color: Colors.blue)),
            const SizedBox(height: 8),
            // テキスト入力
            TextField(
              // 入力されたテキストの値を受け取る（valueが入力されたテキスト）
              onChanged: (String value) {
                // データが変更したことを知らせる（画面を更新する）
                setState(() {
                  // データを変更
                  _text = value;
                });
              },
            ),

            const Text('赤枠の中にQRコードをかざしてください'),
            const SizedBox(height: 8),
            Container(
              // 横幅いっぱいに広げる
              width: double.infinity,
              // リスト追加ボタン
              child: ElevatedButton(
                onPressed: () async {
                  final date =
                  DateTime.now().toLocal().toIso8601String(); // 現在の日時

                  // Firebaseにデータを追加し、"pop"で前の画面に戻る
                  await FirebaseFirestore.instance
                      .collection('QR') // コレクションID
                      .doc() // ドキュメントID
                      .set({'text': _text, 'date': date});
                  Navigator.of(context).pop();
                },
                child: Text('リスト追加', style: TextStyle(color: Colors.white)),
              ),
            ),
            const SizedBox(height: 8),
            Container(
              // 横幅いっぱいに広げる
              width: double.infinity,
              // キャンセルボタン
              child: TextButton(
                // ボタンをクリックした時の処理
                onPressed: () {
                  // "pop"で前の画面に戻る
                  Navigator.of(context).pop();
                },
                child: Text('キャンセル'),
              ),
            ),
            Expanded(flex: 4, child: _buildQrView(context)),
            Expanded(
              flex: 1,
              child: FittedBox(
                fit: BoxFit.contain,
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: <Widget>[
                    Row(
                      mainAxisAlignment: MainAxisAlignment.center,
                      crossAxisAlignment: CrossAxisAlignment.center,
                      children: <Widget>[
                        Container(
                          margin: const EdgeInsets.all(8),
                          child: ElevatedButton(
                              onPressed: () async {
                                await controller?.toggleFlash();
                                setState(() {});
                              },
                              child: FutureBuilder(
                                future: controller?.getFlashStatus(),
                                builder: (context, snapshot) {
                                  // ライトの状態
                                  if (snapshot.data == true) {
                                    return Text('ライト点灯');
                                  } else {
                                    return Text('ライト消灯');
                                  }
                                },
                              )),
                        ),
                        Container(
                          margin: const EdgeInsets.all(8),
                          child: ElevatedButton(
                              onPressed: () async {
                                await controller?.flipCamera();
                                setState(() {});
                              },
                              child: FutureBuilder(
                                future: controller?.getCameraInfo(),
                                builder: (context, snapshot) {
                                  // カメラの状態
                                  if (snapshot.data != null &&
                                      (describeEnum(snapshot.data!)) ==
                                          'back') {
                                    return Text('アウトカメラ');
                                  } else if (snapshot.data != null &&
                                      (describeEnum(snapshot.data!)) ==
                                          'front') {
                                    return Text('インカメラ');
                                  } else {
                                    return Text('loading ${snapshot.data}');
                                  }
                                },
                              )),
                        )
                      ],
                    ),
                  ],
                ),
              ),
            )
          ],
        ),
      ),
    );
  }

  Widget _buildQrView(BuildContext context) {
    // デバイスの幅や高さを確認し、それに応じてscanAreaとoverlayを変更
    var scanArea = (MediaQuery.of(context).size.width < 400 ||
        MediaQuery.of(context).size.height < 400)
        ? 150.0
        : 300.0;
    // Scannerビューが回転した後、適切にサイズ変更されるようにするために
    //  Flutter SizeChanged 通知をリスニングし、コントローラを更新する必要があります。
    return QRView(
      key: qrKey,
      onQRViewCreated: _onQRViewCreated,
      overlay: QrScannerOverlayShape(
          borderColor: Colors.red,
          borderRadius: 10,
          borderLength: 30,
          borderWidth: 10,
          cutOutSize: scanArea),
      onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p),
    );
  }

  void _onQRViewCreated(QRViewController controller) {
    setState(() {
      this.controller = controller;
    });
    controller.scannedDataStream.listen((scanData) {
      setState(() {
        result = scanData;
        _text = scanData.code.toString();
      });
    });
  }

  void _onPermissionSet(BuildContext context, QRViewController ctrl, bool p) {
    log('${DateTime.now().toIso8601String()}_onPermissionSet $p');
    if (!p) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('no Permission')),
      );
    }
  }

  @override
  void dispose() {
    controller?.dispose();
    super.dispose();
  }
}
```

## 履歴詳細/ウェブブラウザ

ここには読み取ったデータとそのデータから生成したQRコードの表示、また読み取ったデータがURLの場合にブラウザでアクセスする処理等が記載されています。

```dart:details.dart
import 'package:flutter/material.dart';
import 'dart:developer';

import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';

class DetailsPage extends StatelessWidget {
  @override
  DetailsPage(this.name);
  String name;

  void _opneUrl(String _url,) async {
    final url = Uri.parse(_url);

    // URLが有効な場合は、「launchUrl」メソッドを実行
    if (await canLaunchUrl(url)) {
      await launchUrl(
        url,
        // デフォルトだとアプリ内WebViewになっておりブラウザを起動させたい場合はこの引数が必要
        // mode: LaunchMode.externalApplication,
      );
      // URLが無効の場合はエラーをスロー
    }  else {
      throw 'このURLにはアクセスできません';
    }
  }

  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('QR Details'),
        ),
        body: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Container(
              child: Card(
                child: ListTile(
                    leading: Icon(Icons.label_important),
                    title: Text(name),
                    onTap: () {
                      _opneUrl(name);
                    }),
              ),
            ),
            Container(
              width: double.infinity,
              color: Colors.grey[200],
              child: Column(
                children: <Widget>[
                  QrImage(
                    data: name,
                    size: 200,
                  ),
                ],
              ),
            ),
          ],
        ));
  }
}
```



# さいごに

今回、初めてflutterを触ってみた感想としては、外部パッケージが優秀で簡単な実装でQRコードの読み取り・生成やウェブブラウザでのアクセスができることに驚きました。
まだまだ理解が及んでいない点が多々ありますので、継続して実装や記事の投稿をしていきたいと考えています。

# 参考文献

## Qiita

https://qiita.com/blendthink/items/8057e27674058a0300b4


## Zenn

https://www.flutter-study.dev/

https://zenn.dev/tsuruo/articles/56f3abbb132f90


## Flutter Package

https://pub.dev/packages/qr_flutter

https://pub.dev/packages/qr_code_scanner

https://pub.dev/packages/url_launcher