DartFlutter

QR Scanner App mit Flutter

Ich stand mit meinen fast 5 Jahre alten Mittelklasse Smartphone vor einen nicht mehr ganz zeitgemäßen Problem. Anders als heutzutage üblich hat die Kamera App keinen QR Scanner integriert.


Dieser Artikel wurde ursprünglich am 20.03.2022 erstellt und wurde auf einem aktuellen Stand gebracht.

Dabei wurden alle Abhängigkeiten aktualisiert, ein anderes Modul zur Kamerasteuerung und QR Code dekodieren gewählt, das wesentlich leichter zu implementieren ist und stabiler funktioniert und alle Klassen Konstruktoren wurden an Darts super Parameter Feature angepasst.


Man muss sich dafür eine extra App aus dem Play Store organisieren. Geld ausgeben wollte ich für sowas nicht und die kostenlosen erkaufen sich diesen Zustand mit ziemlich aufdringlicher Werbung. Also tat ich das was ich meist in solchen Fällen tue und habe mir eine Lösung dafür programmiert. Klein genug für ein Tutorial war die App auch noch, also habe ich mich entschlossen hier etwas darüber zu schreiben.

Funktionsumfang

Die App soll später QR Codes scannen und dekodieren. Worauf hin Sie in einem Bottom Sheet den dekodierten Text anzeigt und entweder nach dem Text auf DuckDuckGo sucht oder sollte es sich um eine URL handeln diese im Browser öffnet oder die entsprechende App.

Glücklicherweise gibt es Libraries zum Scannen/Dekodieren von QR Codes und auch welche zum öffnen von URLs. Flutter selbst bietet alle Komponenten, die für das User Interface benötigt werden. Wir müssen nur noch alles in einer "Komposition" zusammenführen.


Implementation

Um den Programmieraufwand in Grenzen zu halten werden wir auf einige Libraries setzen. Die Bibliothek flutter_qr_bar_scanner mobile_scanner bietet eine plattformunabhängige native Implementation der Kamera und dekodiert auch gleich QR Codes. Zum Starten von Apps bzw. einer URL im Browser setzen wir url_launcher ein. Damit App Bar und Bottom Sheet in einer stylischen Milchglas-Optik glänzen, nutzen wir glass.

pubspec.yaml
name: qr_scanning
description: QR scanning App.
version: 0.8.0+1

environment:
  sdk: ">=2.17.3 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  url_launcher: ^6.1.6
  glass: ^1.0.1+1
  package_info_plus: ^3.0.1
  mobile_scanner: ^2.0.0

dev_dependencies:
  flutter_launcher_icons:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0

asset:
  - assets/

flutter_icons:
  android: "launcher_icon"
  ios: true
  remove_alpha_ios: true
  image_path: "assets/icon/icon.png"

flutter:
  uses-material-design: true
  assets:
    - assets/icon.png

Komponenten

Um die Codebasis übersichtlich zuhalten, erstellen wir für App Bar, Bottom Sheet, und noch ein paar weitere Komponenten "Module", die in extra *.dart Dateien ausgelagert werden.

App Bar

Wir erstellen eine Klasse, die auf der AppBar Klasse des material Packages aufbaut. Die AppBar Klasse ist ein Child der StatefulWidget Klasse und implementiert das PreferredSizeWidget Interface. Dem Row Widget wird die Methode asGlass() angehangen um es mit dem Glasseffekt zu rendern. Die glass Library implementiert eine Extension der Widget Klasse und erweitert sie um die Methode asGlass(). Extensions sind ein eleganter weg existierende Bibliotheken um Funktionalität zu erweitern und wurden mit Dart 2.7 eingeführt.

src/glass_app_bar.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:glass/glass.dart';
import 'package:qr_scanning/src/about.dart';

class GlassAppBar extends AppBar {
  final Image icon;

  GlassAppBar({
    super.key,    
    required this.icon,
    required String title,
  }) : super.title = Text(title);

  @override
  State<GlassAppBar> createState() => _GlassAppBarState();
}

class _GlassAppBarState extends State<GlassAppBar> {
  @override
  Widget build(BuildContext context) {
    Image icon = widget.icon;
    Widget? title = widget.title;

    return AppBar(
      title: Padding(
        padding: const EdgeInsets.symmetric(
          vertical: 0.1,
          horizontal: 0,
        ),
        child: Row(
          children: [
            Opacity(
              opacity: 0.6,
              child: Image(
                image: icon.image,
                width: 64,
                height: 64,
              ),
            ),
            const SizedBox(width: 5),
            Opacity(
              opacity: 0.9,
              child: title,
            ),
          ],
        ),
      ),
      backgroundColor: Colors.white.withAlpha(100),
      flexibleSpace: Container(
        decoration: const BoxDecoration(),
      ).asGlass(),
      actions: [
        if (Platform.isAndroid || Platform.isIOS)
          TextButton(
            onPressed: () => showAbout(context),
            style: ButtonStyle(
              shape: MaterialStateProperty.resolveWith<RoundedRectangleBorder>(
                (states) => const RoundedRectangleBorder(),
              ),
              backgroundColor: MaterialStateProperty.resolveWith(
                (states) => states.contains(MaterialState.pressed)
                    ? Colors.black.withOpacity(0.25)
                    : Colors.black.withOpacity(0.1),
              ),
              foregroundColor: MaterialStateColor.resolveWith(
                (states) => Colors.white.withOpacity(0.5),
              ),
              overlayColor: MaterialStateProperty.resolveWith<Color?>(
                (states) => states.contains(MaterialState.pressed)
                    ? Colors.white.withOpacity(0.08)
                    : null,
              ),
            ),
            child: const Icon(
              Icons.info,
              size: 32,
            ),
          ),
      ],
    );
  }
}

Der About Dialog

Die showAbout() Funktion von dem onPress Events des Icon Buttons ist in der Datei components/about.dart definiert. Um es übersichtlich zu halten ist ein List<Widget> Objekt in der Datei components/license.dart deklariert und initialisiert. Es hält die Liste der Lizenz-Widgets. Von diesen Dialog sind auch alle weiteren Lizensen der genutzen Packages einsehbar. Das ist eine rechtliche Notwendigkeit.

src/license.dart
import 'package:flutter/material.dart';

List<Widget> license = [
  const Text('MIT License', style: TextStyle(fontWeight: FontWeight.bold)),
  const Padding(
    padding: EdgeInsets.symmetric(horizontal: 0, vertical: 10),
    child: Text('Copyright (c) 2022 Manfred Michaelis',
        style: TextStyle(fontStyle: FontStyle.italic)),
  ),
  Padding(
    padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 10),
    child: Column(
      children: const [
        Text(
            'Permission is hereby granted, free of charge, to any person obtaining a copy'),
        Text(
            'of this software and associated documentation files (the "Software"), to deal'),
        Text(
            'in the Software without restriction, including without limitation the rights'),
        Text(
            'to use, copy, modify, merge, publish, distribute, sublicense, and/or sell'),
        Text(
            'copies of the Software, and to permit persons to whom the Software is'),
        Text('furnished to do so, subject to the following conditions:'),
      ],
    ),
  ),
  const Padding(
    padding: EdgeInsets.symmetric(horizontal: 0, vertical: 10),
    child: Text(
        'The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.'),
  ),
  Padding(
    padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 10),
    child: Column(
      children: const [
        Text(
            'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR'),
        Text(
            'IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY'),
        Text(
            'FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE'),
        Text(
            'AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER'),
        Text(
            'LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,'),
        Text(
            'OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.'),
      ],
    ),
  ),
];
src/about.dart
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:qr_scanning/src/license.dart';

Future<void> showAbout(BuildContext context) async {
  PackageInfo info = await PackageInfo.fromPlatform();

  showAboutDialog(
      context: context,
      applicationName: info.appName,
      applicationVersion: info.version,
      applicationIcon: Image(
        image: Image.asset("assets/icon.png").image,
        width: 75,
        height: 75,
      ),
      applicationLegalese: 'Copyright (c) 2022 Manfred Michaelis',
      children: license,
    );
}

Mit der PackageInfo Klasse des package_info_plus Packets holen wir uns die aktuelle Version der App und deren Namen, die dann an die showAboutDialog() Funktion als Argumente übergeben werden.


Bottom Sheet

Das Bottom Sheet wird mit der Funktion showQrBottomSheet aufgerufen. Auf ihn wird der dekodierte QR Code dargestellt und es gibt ein Button zum Schließen und einen für das Suchen bzw. das Öffnen des Browser.

src/open_button.dart
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';

class OpenButton extends StatelessWidget {
  final String url;
  final double opacity;
  final Widget child;

  const OpenButton({
    super.key,
    double opacity = 0.75,
    required this.url,
    required this.child,
  });

  void _handlePressed(String url) async {
    if (await canLaunch(url)) {
      await launch(url);
    } else {
      await launch('https://duckduckgo.com/?q=$url');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Opacity(
      opacity: opacity,
      child: ElevatedButton(
        onPressed: () => _handlePressed(url),
        child: child,
      ),
    );
  }
}

In der _handlePressed() Methode wird geprüft ob die URL ausführbar ist. Wenn dem so ist, dann wird die entsprechende App gestartet und wenn nicht, dann wird mit dem standard Browser eine Suchanfrage an https://duckduckgo.com gesendet.

src/qr_modal_bottom_sheet.dart
import 'package:flutter/material.dart';
import 'package:qr_scanning/src/open_button.dart';
import 'package:glass/glass.dart';

Future showQrBottomSheet({
  required BuildContext context,
  required String code,
  required Function onClose,
}) {
  final height = MediaQuery.of(context).size.height;
  return showModalBottomSheet<void>(
    context: context,
    enableDrag: true,
    isDismissible: false,
    backgroundColor: Colors.white.withAlpha(100),
    constraints: BoxConstraints(
      minHeight: height,
    ),
    builder: (BuildContext context) => SizedBox(
      height: height,
      child: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            const Spacer(),
            Text(
              code,
              textAlign: TextAlign.center,
              style: const TextStyle(
                fontSize: 22,
                color: Colors.white,
                shadows: [
                  Shadow(
                    color: Color.fromRGBO(0, 0, 0, .5),
                    offset: Offset(2, 2),
                    blurRadius: 10,
                  ),
                ],
              ),
            ),
            const Spacer(),
            Padding(
              padding: const EdgeInsets.symmetric(
                vertical: 15,
                horizontal: 10,
              ),
              child: Row(
                children: [
                  const SizedBox(width: 10),
                  Expanded(
                    flex: 1,
                    child: Opacity(
                      opacity: 0.75,
                      child: ElevatedButton(
                        child: const Text('Close'),
                        onPressed: () => onClose(context),
                      ),
                    ),
                  ),
                  const SizedBox(width: 20),
                  Expanded(
                    flex: 1,
                    child: OpenButton(
                      url: code,
                      child: const Text('Open'),
                    ),
                  ),
                  const SizedBox(width: 10),
                ],
              ),
            ),
          ],
        ),
      ),
    ).asGlass(),
  );
}

Die showQrBottomSheet() Funktion ist an sich ein Wrapper für die showModalBottomSheet() Funktion. Wir übergeben nur die wirklich notwendigen Argumente und können so denn ganzen Boilerplate Code in eine extra Source - Datei auslagern. Alternativ hätte man auch nur die Builder Funktion in eine extra Datei definieren können. So wird allerdings die main.dart noch etwas schlanker.


Das App Widget

In der Datei main.dart führen wir nun alle Komponenten in der _ScanPageState Klasse zusammen.

main.dart
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:qr_scanning/src/glass_app_bar.dart';
import 'package:qr_scanning/src/qr_modal_bottom_sheet.dart';

void main() {
  runApp(const Qr());
}

class Qr extends StatelessWidget {
  const Qr({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'QR Scanning',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blueGrey,
        brightness: Brightness.light,
      ),
      home: const ScanPage(title: 'QR Scanning'),
    );
  }
}

class ScanPage extends StatefulWidget {
  const ScanPage({Key? key, this.title}) : super(key: key);
  final String? title;

  @override
  State<ScanPage> createState() => _ScanPageState();
}

class _ScanPageState extends State<ScanPage> {
  bool _scanned = false;

  @override
  void initState() {
    super.initState();
  }

  @override
  void dispose() {
    super.dispose();
  }

  void _closeSheet(BuildContext context) {
    Navigator.pop(context);
    setState(() => _scanned = false);
  }

  void _onDetectQr(Barcode barcode, MobileScannerArguments? args) async {
    if (!_scanned) {
      final decoded = barcode.rawValue;
      setState(() => _scanned = true);
      showQrBottomSheet(
        context: context,
        code: decoded ?? 'Can`t decode qr code',
        onClose: _closeSheet,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      extendBodyBehindAppBar: true,
      extendBody: true,
      appBar: GlassAppBar(
        title: widget.title!,
        icon: Image.asset("assets/icon.png"),
      ),
      body: SafeArea(
        bottom: false,
        top: false,
        child: SizedBox(
          height: null,
          width: null,
          child: MobileScanner(
            allowDuplicates: false,
            fit: BoxFit.cover,
            onDetect: _onDetectQr,
          ),
        ),
      ),
    );
  }
}

Falls _scanned = false ist wird nach dem Dekodieren die Funktion showQrBottomSheet() aufgerufen und weist _scanned = true zu. Die _closeSheet() Methode navigiert im Seitenverlauf zurück und weist _scanned = false zu. Sie wird showQrBottomSheet als Argument für das Pressed Event des Close Buttons übergeben.


Repository
Manfred Michaelis / qr-scanning · GitLab
GitLab Community Edition
image