2
Help us understand the problem. What are the problem?

posted at

updated at

M5StackとFirebaseとFlutterを連携してスマートロックみたいなものを作る!(おまけ)

できあがるもの

はじめに

前回までの記事でM5StackとFirebaseを組み合わせてスマートロックみたいなものが完成しました。
最終章であるこの記事では、Flutterというフレームワークを使ってスマホから入退場状況を見れるような
管理者用アプリを作製していきます。
ちなみにこの実装はおまけみたいなものなので、無くてもスマートロックは動きます。

過去記事:
M5StackとFirebaseを連携してスマートロックみたいなものを作る!①
M5StackとFirebaseを連携してスマートロックみたいなものを作る!②
M5StackとFirebaseを連携してスマートロックみたいなものを作る!③
M5StackとFirebaseを連携してスマートロックみたいなものを作る!④

Flutterとは?


FlutterはGoogle先生が開発しているマルチプラットフォーム開発フレームワークの1種で、
Dartという言語で書いていきます。
マルチプラットフォームなので、iOS、Android、Webアプリの3つを同時並行で作っていくことが可能です。
今のところWebアプリ開発も徐々に充実しつつあり、iOS、Androidであればかなり充実しています!

学習するようになった経緯としては、Web系は全然知らない自分でしたが、ある日「スマホアプリ作りたい!」と思い立ち色々探した結果、「FlutterできればiOS, Android網羅できて最強じゃん!」ってなって学習を初めた次第です。
最近はKotlinも学習をし始めていますが、UIを別ファイルで作るあたりなどがなんか慣れないですね笑
余談が過ぎましたが、ESP32とFlutterをコラボさせる動画も結構あります。
特にThat Projectさんのチャンネルはめちゃくちゃ参考になりました。

環境構築

環境構築は本記事では取り扱いませんので、下記記事を是非参考にしてください。

Flutterの環境整備

Flutterの環境構築は下記URLを参考にしてください。
Flutter 開発環境構築手順 (2019年 保存版)

Firebaseにアプリを登録

Firebaseに作っていくアプリを登録する必要があります。
下記URLなどを参考に登録してください。
Flutterに初めてのFirebase導入(Firebase Analytics)

ライブラリ

ファイル構成

  • main.dart
  • user_overview.dart

コード

main.dart

main.dart
import 'package:flutter/material.dart';

import './user_overview.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: 'M5Stack Smart Lock',
      home: Scaffold(
        appBar: AppBar(
          title: Text('M5Stack Smart Lock'),
        ),
        body: UserOverview(),
      ),
    );
  }
}

main.dartについては特に特記することはありません。

Firebase RDB読み出し部分

user_overview.dart
import 'package:firebase_database/ui/firebase_animated_list.dart';
import 'package:flutter/material.dart';
import 'package:firebase_database/firebase_database.dart';

class UserEntry {
  String key;
  String date;
  String id;
  bool entered;

  UserEntry(this.date, this.id, this.entered);

  UserEntry.fromSnapShot(DataSnapshot snapshot)
      : key = snapshot.key,
        id = snapshot.value["id"],
        date = snapshot.value["date"],
        entered = snapshot.value["entered"];

  toMap() {
    return {
      "id": id,
      "date": entered,
      "entered": entered,
    };
  }
}

class UserOverview extends StatefulWidget {
  @override
  _UserOverviewState createState() => _UserOverviewState();
}

class _UserOverviewState extends State<UserOverview> {
  final _mainReference = FirebaseDatabase.instance.reference().child('User');

  List users = new List();
  int enteredNum = 0;

  @override
  void initState() {
    super.initState();
    _mainReference.onChildAdded.listen(_onUserAdded);
    _mainReference.onChildChanged.listen(_onUserUpdated);
    _mainReference.onChildRemoved.listen(_onUserDeleted);
  }

  _onUserAdded(Event e) {
    setState(() {
      users.add(new UserEntry.fromSnapShot(e.snapshot));
    });
    enteredNum = 0;
    users.forEach((element) {
      if (element.entered) {
        enteredNum++;
      }
    });
  }

  _onUserUpdated(Event e) {
    var oldEntriesValue =
        users.singleWhere((user) => user.id == e.snapshot.value['id']);
    setState(() {
      users[users.indexOf(oldEntriesValue)] =
          new UserEntry.fromSnapShot(e.snapshot);
    });
    enteredNum = 0;
    users.forEach((element) {
      if (element.entered) {
        enteredNum++;
      }
    });
  }

  _onUserDeleted(Event e) {
    var deletedEntriesValue =
        users.singleWhere((user) => user.id == e.snapshot.value['id']);
    setState(() {
      users.removeAt(users.indexOf(deletedEntriesValue));
    });
    enteredNum = 0;
    users.forEach((element) {
      if (element.entered) {
        enteredNum++;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            buildStatistics(
              enteredNum.toString(),
              'ACTIVE',
              Colors.red,
            ),
            buildStatistics(
              users.length.toString(),
              'TOTAL',
              Colors.orange,
            )
          ],
        ),
        Divider(
          color: Colors.grey,
        ),
        Expanded(
          child: FirebaseAnimatedList(
            defaultChild: CircularProgressIndicator(),
            query: _mainReference,
            itemBuilder: (_, snapshot, Animation<double> animation, int index) {
              return Column(
                children: [
                  ListTile(
                    leading: CircleAvatar(
                      radius: 26,
                      foregroundColor: Colors.white,
                      backgroundColor: snapshot.value['entered']
                          ? Colors.red
                          : Colors.blueAccent,
                      child: Text(
                        snapshot.value['entered'] ? 'In' : 'Out',
                        style: TextStyle(
                          fontSize: 20,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ),
                    title: Text(
                      'UID:${snapshot.value['id']}',
                      style: TextStyle(fontSize: 20),
                    ),
                    subtitle: Text(
                      '登録日:${snapshot.value['date']}',
                      style: TextStyle(fontSize: 16),
                    ),
                  ),
                  Divider(
                    color: Colors.grey,
                  ),
                ],
              );
            },
          ),
        ),
      ],
    );
  }

  Padding buildStatistics(
      String circleText, String caption, Color circleColor) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Column(
        children: [
          CircleAvatar(
            radius: 40,
            backgroundColor: circleColor,
            foregroundColor: Colors.white,
            child: Text(
              circleText,
              style: TextStyle(
                fontSize: 24,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          Text(
            caption,
            style: TextStyle(
              fontSize: 24,
              fontWeight: FontWeight.bold,
            ),
          ),
        ],
      ),
    );
  }
}


firebase_databaseが便利すぎて感動しました。

データ構造部分

RDBで変化があった部分がここで成型される
class UserEntry {
  String key;
  String date;
  String id;
  bool entered;

  UserEntry(this.date, this.id, this.entered);

  UserEntry.fromSnapShot(DataSnapshot snapshot)
      : key = snapshot.key,
        id = snapshot.value["id"],
        date = snapshot.value["date"],
        entered = snapshot.value["entered"];

  toMap() {
    return {
      "id": id,
      "date": entered,
      "entered": entered,
    };
  }
}

FirebaseのRDB内で何かしら変化があったときDataSnapshotを受け取り、
変化があった部分のみをMap(json)の形式に変換してリターンするクラスです。

Firebase RDB内の変化をキャッチ

各種イベント
@override
  void initState() {
    super.initState();
    _mainReference.onChildAdded.listen(_onUserAdded);
    _mainReference.onChildChanged.listen(_onUserUpdated);
    _mainReference.onChildRemoved.listen(_onUserDeleted);
  }

  _onUserAdded(Event e) {
    setState(() {
      users.add(new UserEntry.fromSnapShot(e.snapshot));
    });
    enteredNum = 0;
    users.forEach((element) {
      if (element.entered) {
        enteredNum++;
      }
    });
  }

  _onUserUpdated(Event e) {
    var oldEntriesValue =
        users.singleWhere((user) => user.id == e.snapshot.value['id']);
    setState(() {
      users[users.indexOf(oldEntriesValue)] =
          new UserEntry.fromSnapShot(e.snapshot);
    });
    enteredNum = 0;
    users.forEach((element) {
      if (element.entered) {
        enteredNum++;
      }
    });
  }

  _onUserDeleted(Event e) {
    var deletedEntriesValue =
        users.singleWhere((user) => user.id == e.snapshot.value['id']);
    setState(() {
      users.removeAt(users.indexOf(deletedEntriesValue));
    });
    enteredNum = 0;
    users.forEach((element) {
      if (element.entered) {
        enteredNum++;
      }
    });
  }

RDB内でレコードの追加・削除、レコード内フィールド値の変更(今回の場合入退場)があった場合に各種_onUserAdded, _onUserDeleted_onUserUpdatedがコールされるようになります。
ここで、enteredNumに現在の入場者の人数を計算して格納していますが、計算する前に毎回enteredNumをリセットしています。
というのも、ここの挙動が1個レコードが作られるたびにコールされるので3つのレコードがあった場合、レコード1でコール、レコード1・2でコール、レコード1・2・3でコールされてしまうため、それぞれのコールでenteredNumが累積和されてしまうのです。
そのため、最後のコールでのenteredtrueな数を知れればいいので、こういう形になっています。

描画部分

入場者とトータルの人数を描画

入場者数と登録者数の可視化
     Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            buildStatistics(
              enteredNum.toString(),
              'ACTIVE',
              Colors.red,
            ),
            buildStatistics(
              users.length.toString(),
              'TOTAL',
              Colors.orange,
            )
          ],
        ),
buildStatistics
Padding buildStatistics(
      String circleText, String caption, Color circleColor) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Column(
        children: [
          CircleAvatar(
            radius: 40,
            backgroundColor: circleColor,
            foregroundColor: Colors.white,
            child: Text(
              circleText,
              style: TextStyle(
                fontSize: 24,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          Text(
            caption,
            style: TextStyle(
              fontSize: 24,
              fontWeight: FontWeight.bold,
            ),
          ),
        ],
      ),
    );
  }

入場者と登録者の数を描画する部分です。ここで入場者には前出のenteredNum、登録者にはusersの大きさを利用しています。
個人的にCircleAvatarが結構好きなので、つい多用してしまいます。

ユーザー全体の情報を可視化

FirebaseAnimatedListの使いやすさに感動
         FirebaseAnimatedList(
            defaultChild: CircularProgressIndicator(),
            query: _mainReference,
            itemBuilder: (_, snapshot, Animation<double> animation, int index) {
              return Column(
                children: [
                  ListTile(
                    leading: CircleAvatar(
                      radius: 26,
                      foregroundColor: Colors.white,
                      backgroundColor: snapshot.value['entered']
                          ? Colors.red
                          : Colors.blueAccent,
                      child: Text(
                        snapshot.value['entered'] ? 'In' : 'Out',
                        style: TextStyle(
                          fontSize: 20,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ),
                    title: Text(
                      'UID:${snapshot.value['id']}',
                      style: TextStyle(fontSize: 20),
                    ),
                    subtitle: Text(
                      '登録日:${snapshot.value['date']}',
                      style: TextStyle(fontSize: 16),
                    ),
                  ),
                  Divider(
                    color: Colors.grey,
                  ),
                ],
              );
            },
          ),

ここで使っているFirebaseAnimatedListは指定したqueryからAnimatedListを作ってくれます。
defaultChildにはRDB読込中に表示するWidgetを指定することが可能です。非常に使いやすいです。
ただ今回の場合usersに同じ情報が入っているので、FirebaseAnimatedListを使ったのは冗長だったかもしれません。
使い方として載せておきたかったので、とりあえず使いました笑

おわりに

これでM5Stackを使ったスマートロックとFlutterによるAdminアプリが作製できました。
せっかくFirebase使ってますし、Adminログインとユーザーの削除機能も作りたいですね。
あとは、IDだけじゃ判別がつかないので「M5Stack経由でカード登録」→「Admin側で名前などの属性情報も追加で登録」みたいな形にすると、
もっと使いやすくなると思います。

あと何気に今日でAdventカレンダー最終日です。たくさん書いたおかげでこの12月で、特にFlutter力が上がった気がします😂
会社ではあまりFlutter使う機会はないですが、個人的にめちゃくちゃ面白くなってきているので来年もたくさんFlutter使いたいと思います!
皆さん良いお年を!

参考にしたページ

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
2
Help us understand the problem. What are the problem?