FlutterとfirebaseでWEBサービスを作る

趣味の開発や仕事で書いた、ちょっとしたコマンドやマクロなどを気軽にストックするためのWEBサービスを作りたいなと思っていたので、作ることにしました。

せっかくなので勉強もかねてFlutterとFirebaseを使ってみることにしました。

作成したWEBサービスの動作イメージです。

Webサービスの動作イメージ

なぜ、Flutter?

一時はJavaScriptに完敗だったFlutter(DART言語)が、息を吹き返したところが気になり、試しに使ってみたくて手を出すことにしました。

なぜ、Firebase?

普段はWebarena(Indigo)にDjango RFWを乗っけてバックエンドサービスを開発しています。

自由度が高い反面、OS環境構築から始まり、Dockerインストール、ドメインやSSLなどの付帯作業に色々と時間もかかる点が少しネックでした。リリースしてもサーバの管理は必要になり、利用者が増えればスケーリングなどの対応が必要になってきます。

上記のような課題を解決するために、Googleのmobile Backend as a Service(mBaaS)「 Firebase 」を利用してみることにしました。

今回の開発はGoogleに全乗っかりです。最近耳にするサーバレス開発ですね。

注意:まだFIrebaseのFlutterサポートはベストエフォートのようです。

Firebase では、Flutter などのフレームワークをベスト エフォート ベースでサポートしています。これらの統合は Firebase サポートの対象ではないため、公式 Firebase SDK と同等のすべての機能をご利用いただけない場合があります。

https://firebase.google.com/docs/flutter/setup?hl=ja&platform=android

でも気にせずやっていきます。実際、動くアプリが作れました。

Firebaseの利用

Googleのアカウントがあれば、すぐに始められます。

注意点としては、途中で「リージョン(地域)」の選択がありますが、これは一度選ぶと変更ができなくなります。利用ユーザが日本メインなら、東京リージョン(ASIA-NORTHEAST1)で良いと思います。

「プロジェクトを作成」を押して次に進みます。

プロジェクト名をつけて「続行」します。

Googleアナリティクスは有効にしておきます。

アカウントを選択し、「プロジェクトを作成」を進めます。

プロジェクトが作成できました。

Firebaseの利用機能を選定

Firebaseが提供する機能が多いため、必要そうな機能にあたりを付けてみます。本来はアプリの仕様が決めてからですが、雰囲気で選びます。

機能名称利用目的
認証Firebase Authenticationログイン機能は実装しようと思います。
DBCloud Firestoreテキストデータはこっちに保存
DBCloud Storage for Firebase×画像データはこっちに保存っぽいですが今回は使いません。
DBRealtime Database× Cloud Firestore の方が複雑なデータ構造で
保存できるようなので、こっちは使いません。
プッシュ通知Firebase Cloud Messaging(FCM)×ユーザへのお知らせ機能は不要。
機能Google Cloud Functions for Firebase×ここにロジックを記載します。
ホスティングFirebase Hostingここに出来上がったアプリをデプロイ予定。
利用予定のFirebase機能

Flutterのプロジェクト作成

次にFlutterを使った開発の準備をしていきます。

SDK の設定と構成

Flutterのプロジェクトを作成するためには、FlutterのSDKが必要になります。以下のサイトからダウンロードできます。(後の手順「 Authentication用ライブラリをインストール 」では、このSDKのバージョンと一致させる必要があります。後でやるので今は大丈夫です。)

https://flutter.dev/docs/get-started/install

利用しているOSに合わせてSDKのインストールを行います。そこまで難しくなく、私はWindows環境でしたので、

  1. SDKのZIPをダウンロードして解凍
  2. 解凍したフォルダをProgram Filesに移動(手順3でパスを通すので何処でもいい)
  3. システム環境変数のPathに追加

visual studio codeでの開発準備

開発のエディタはVS Codeを使います。本家サイトの手順にも書いていますが、VS Codeに「Flutter拡張機能」は適用した方がよいです。

エディタープラグインの1つを使用することをお勧めします。これらのプラグインは、コードの補完、構文の強調表示、ウィジェットの編集支援、実行とデバッグのサポートなどを提供します。

https://flutter.dev/docs/get-started/editor?tab=vscode

  1. VSCodeを起動します。
  2. ビューの呼び出し>コマンドパレット…
  3. 「install」と入力し、[ Extensions:InstallExtensions]を選択します。
  4. 拡張機能の検索フィールドに「flutter」と入力し、リストで[ Flutter ]を選択して、[ Install ]をクリックします。これにより、必要なDartプラグインもインストールされます。

VS Codeで新しいプロジェクトの作成

https://flutter.dev/docs/development/tools/vs-code

Flutterスターターアプリテンプレートから新しいFlutterプロジェクトを作成します。

  1. コマンドパレットを開きます(Ctrl+ Shift+ P(Cmd+ Shift+ PMacOSの上))。
  2. Flutter:New Application Projectコマンドを選択し、を押しEnterます。
  3. 希望のプロジェクト名を入力します。
  4. プロジェクトの場所を選択します。

プロジェクトを作成する場所を選択します。どこでもOKです。

フォルダを選択すると、雛形のフォルダが自動で作成されます。

ディレクトリ構成(アプリ内の役割分担、アーキテクチャ)

ディレクトリ構成は、以下の記事を参考にさせて頂きました。

https://qiita.com/osamu1203/items/526a13d730500decf58c

ディレクトリ役割
main.dartメイン(起動プログラム)のファイルです。
ui画面。Stateless Widgetを継承して作成することで、UIとロジックの分離を図る。
modelロジック。BlocやViewModelと呼ばれる層で、処理とステート(状態)を持つ。
Repository外部データインタフェース。modelが外からデータを取得時に利用。
dao外部リソースへのデータ取得。
entityアプリケーション内で扱われるオブジェクト群です。APIやDBなど外部リソースから取得したJSONなどのデータを、適切なオブジェクトへと変換します。
servicefirebaseとのやり取りや、メッセージ管理など。ユーティリティ的な処理を集約する。

認証機能:Firebase Authentication

FlutterでのFirebase利用の手始めに、FlutterからFirebaseの認証機能を呼び出してみます。

実装については、以下のサイトを参考にさせていただきました。

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

認証機能を有効にする

まずは、Firebaseの認証機能を有効にします。

プロバイダの一覧から、メール/パスワードを選択します。

「有効にする」ラジオボタンをオンにします。

Firebaseにアプリを追加

Flutterでアプリを作成する前に、Firebaseにアプリを追加します。

これにより、Firebase利用のためのスクリプトが生成されます。Flutterのindex.htmlに張り付けることでFirebaseを利用することができるようになります。

アプリの追加

SDKの設定と構成で「CDN」をチェックします。

Firebaseを呼び出すために必要な設定用のスクリプトが表示されます。

index.htmlへスクリプト貼り付け

Flutterで生成したindex.htmlに張り付けます。この時、Firebase JS SDKのバージョンと、自分のPCにインストールしたFlutter SDKのバージョンは一致させます。

Authentication用ライブラリをインストール

pubspec.yamlに以下を追記します。VSCodeであれば保存と同時にインストールが実行されました。

  # *** ここを追記 ***
  firebase_core: ^1.0.1
  firebase_auth: ^1.0.1

ユーザ登録とログイン機能の実装

実装については以下のサイトを参考にさせて頂きました。一部、Flutter のメジャーバージョンが “2” にアップデートされたことにより、 RaisedButton がdeprecated (非推奨) になった点や、UserCredentialが利用不可になった点は変更しました。

https://qiita.com/smiler5617/items/b004debc847ffed4e3af

画面は以下の4つを作成していきます。

  • アカウント登録画面
  • ログイン画面
  • ログイン後ホーム画面(コードの一覧画面)
  • コードの登録・編集・参照画面

アカウント登録画面を実装

もし以下のエラーが発生する場合は、DARTのSDKのバージョンとFLutterのバージョンが不一致の可能性があります。

Flutter web: tried to call a non-function, such as null: 'dart.global.firebase.storage

https://stackoverflow.com/questions/57242045/flutter-web-tried-to-call-a-non-function-such-as-null-dart-global-firebase-s

メイン(プログラムの開始)

Flutterはmain.dartのmin()から開始されます。由緒正しいC言語の系列の雰囲気を醸しています。

home: Login() にあるように、起動後は「ログイン画面」が表示されることになります。

main.dart

import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'ui/login.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:provider/provider.dart';

void main() async {
  // Firebase初期化
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(MyApp());
}

// 更新可能なデータ
class UserState extends ChangeNotifier {
  User? user;

  void setUser(User newUser) {
    user = newUser;
    notifyListeners();
  }
}

class MyApp extends StatelessWidget {
  // ユーザーの情報を管理するデータ
  final UserState userState = UserState();

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<UserState>(
      create: (context) => UserState(),
      child: MaterialApp(
        title: 'StockCodes',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        //home: MyHomePage(title: 'MyPages'),
        home: Login(),
      ),
    );
  }
}

ログイン画面

次にログイン画面です。アカウントが未登録の場合に「アカウントを作成する」ボタンを押すことで、先ほどの「アカウント登録画面」へ遷移します。アカウントが登録済みの場合は、ログインを押すことで、そのユーザのホーム画面に遷移します。

login.dart

上記のログイン画面のソースです。

import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/services.dart';
import 'package:stockcodes/model/code_model.dart';
import '../service/authentication_error.dart';
import 'registration.dart';
import 'home.dart';
import '../main.dart';
import 'package:provider/provider.dart';

class Login extends StatefulWidget {
  @override
  _LoginPage createState() => _LoginPage();
}

class _LoginPage extends State<Login> {
  String loginEmail = ""; // 入力されたメールアドレス
  String loginPassword = ""; // 入力されたパスワード
  String infoText = ""; // ログインに関する情報を表示

  // Firebase Authenticationを利用するためのインスタンス
  final FirebaseAuth auth = FirebaseAuth.instance;

  // エラーメッセージを日本語化するためのクラス
  final authError = AuthenticationError();

  @override
  Widget build(BuildContext context) {
    // ユーザー情報を受け取る
    final UserState userState = Provider.of<UserState>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text("Stock Codes"),
      ),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            // メールアドレスの入力フォーム
            ConstrainedBox(
                constraints: BoxConstraints(maxWidth: 400),
                child: TextFormField(
                  decoration: InputDecoration(labelText: "メールアドレス"),
                  onChanged: (String value) {
                    loginEmail = value;
                  },
                )),

            // パスワードの入力フォーム
            ConstrainedBox(
              constraints: BoxConstraints(maxWidth: 400),
              child: TextFormField(
                decoration: InputDecoration(labelText: "パスワード(8~20文字)"),
                obscureText: true, // パスワードが見えないようRにする
                maxLength: 20, // 入力可能な文字数
                maxLengthEnforcement:
                    MaxLengthEnforcement.enforced, // 入力可能な文字数の制限を超える場合の挙動の制御
                onChanged: (String value) {
                  loginPassword = value;
                },
              ),
            ),

            // ログイン失敗時のエラーメッセージ
            Padding(
              padding: EdgeInsets.fromLTRB(20.0, 0, 20.0, 5.0),
              child: Text(
                infoText,
                style: TextStyle(color: Colors.red),
              ),
            ),

            ButtonTheme(
              minWidth: 350.0,
              // height: 100.0,
              child: ElevatedButton(
                  child: Text(
                    'ログイン',
                    style: TextStyle(fontWeight: FontWeight.bold),
                  ),
                  style: ElevatedButton.styleFrom(
                    primary: Colors.blue,
                    onPrimary: Colors.white,
                  ),
                  onPressed: () async {
                    try {
                      // メール/パスワードでユーザー登録
                      UserCredential result =
                          await auth.signInWithEmailAndPassword(
                        email: loginEmail,
                        password: loginPassword,
                      );

                      // ログイン成功
                      // ユーザー情報を更新
                      userState.setUser(result.user!);
                      await Navigator.of(context).pushReplacement(
                        MaterialPageRoute(builder: (context) {
                          return ChangeNotifierProvider<CodeModel>(
                            create: (context) => CodeModel(result.user!),
                            child: MaterialApp(
                              title: 'StockCodes',
                              theme: ThemeData(
                                primarySwatch: Colors.blue,
                              ),
                              //home: MyHomePage(title: 'MyPages'),
                              home: Home(),
                            ),
                          );
                        }),
                      );
                    } catch (e) {
                      // ログインに失敗した場合
                      setState(() {
                        infoText = authError.loginErrorMsg(e.toString());
                      });
                    }
                  }),
            ),
          ],
        ),
      ),

      // 画面下にボタンの配置
      bottomNavigationBar:
          Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
        Padding(
          padding: const EdgeInsets.all(80.0),
          child: ButtonTheme(
            minWidth: 350.0,
            // height: 100.0,
            child: ElevatedButton(
                child: Text(
                  'アカウントを作成する',
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
                style: ElevatedButton.styleFrom(
                  primary: Colors.blue,
                  onPrimary: Colors.blue[50],
                ),
                // ボタンクリック後にアカウント作成用の画面の遷移する。
                onPressed: () {
                  Navigator.of(context).push(
                    MaterialPageRoute(
                      fullscreenDialog: true,
                      builder: (BuildContext context) => Registration(),
                    ),
                  );
                }),
          ),
        ),
      ]),
    );
  }
}

Registration(アカウント登録)

ユーザ登録画面です。ログイン画面の「アカウントを作成する」から遷移します。

registration.dart

上記画面を表示しているFlutterソースです。

import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/services.dart';
import '../service/authentication_error.dart';
import 'home.dart';
import '../main.dart';
import 'package:provider/provider.dart';

// アカウント登録ページ
class Registration extends StatefulWidget {
  @override
  _RegistrationState createState() => _RegistrationState();
}

class _RegistrationState extends State<Registration> {
  // Firebase Authenticationを利用するためのインスタンス
  final FirebaseAuth auth = FirebaseAuth.instance;

  String newEmail = ""; // 入力されたメールアドレス
  String newPassword = ""; // 入力されたパスワード
  String infoText = ""; // 登録に関する情報を表示
  bool pswdOK = false; // パスワードが有効な文字数を満たしているかどうか

  // エラーメッセージを日本語化するためのクラス
  final authError = AuthenticationError();

  @override
  Widget build(BuildContext context) {
    // ユーザー情報を受け取る
    final UserState userState = Provider.of<UserState>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text("Stock Codes"),
      ),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            ConstrainedBox(
                constraints: BoxConstraints(maxWidth: 400),
                child: Text('新規アカウントの作成',
                    style:
                        TextStyle(fontSize: 20, fontWeight: FontWeight.bold))),
            Padding(
              padding: EdgeInsets.all(16.0),
            ),

            // メールアドレスの入力フォーム
            ConstrainedBox(
                constraints: BoxConstraints(maxWidth: 400),
                child: TextFormField(
                  decoration: InputDecoration(labelText: "メールアドレス"),
                  onChanged: (String value) {
                    newEmail = value;
                  },
                )),

            // パスワードの入力フォーム
            ConstrainedBox(
              constraints: BoxConstraints(maxWidth: 400),
              child: TextFormField(
                  decoration: InputDecoration(labelText: "パスワード(8~20文字)"),
                  obscureText: true, // パスワードが見えないようRにする
                  maxLength: 20, // 入力可能な文字数
                  maxLengthEnforcement:
                      MaxLengthEnforcement.enforced, // 入力可能な文字数の制限を超える場合の挙動の制御
                  onChanged: (String value) {
                    if (value.length >= 8) {
                      newPassword = value;
                      pswdOK = true;
                    } else {
                      pswdOK = false;
                    }
                  }),
            ),

            // 登録失敗時のエラーメッセージ
            Padding(
              padding: EdgeInsets.fromLTRB(20.0, 0, 20.0, 5.0),
              child: Text(
                infoText,
                style: TextStyle(color: Colors.red),
              ),
            ),

            ButtonTheme(
              minWidth: 350.0,
              // height: 100.0,
              child: ElevatedButton(
                child: Text('登録'),
                style: ElevatedButton.styleFrom(
                  primary: Colors.blue,
                  onPrimary: Colors.white,
                ),
                onPressed: () async {
                  if (pswdOK) {
                    try {
                      // メール/パスワードでユーザー登録
                      UserCredential result =
                          await auth.createUserWithEmailAndPassword(
                        email: newEmail,
                        password: newPassword,
                      );

                      // 登録成功
                      // 登録したユーザー情報
                      // ユーザー情報を更新
                      userState.setUser(result.user!);
                      await Navigator.of(context).pushReplacement(
                        MaterialPageRoute(builder: (context) {
                          return Home();
                        }),
                      );
                    } catch (e) {
                      // 登録に失敗した場合
                      setState(() {
                        infoText = authError.registerErrorMsg(e.toString());
                      });
                    }
                  } else {
                    setState(() {
                      infoText = 'パスワードは8文字以上です。';
                    });
                  }
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

データモデル検討とデータ取得処理の実装

Cloud Firestore

データの取得と登録を行うために、pubspec.yamlに「cloud_firestore」を追加します。VS Codeであれば、保存と同時にライブラリがインストールされます。

cloud_firestoreを追加

データモデルの検討

データモデルを検討します。以下の記事が参考になりました。

https://qiita.com/TakeshiNickOsanai/items/a2dc728d3b854d6c2d76

今回は、ユーザごとにコードをコードメモを作っていくので、コレクションはusersにします。

コレクションドキュメントサブコレクションドキュメント
users{userID}codes{documentId}

挙動としては以下の通りです。

  • コレクション[users]は初期状態で存在する
  • ドキュメントは、Google認証が通ったユーザーのIDごとに自動で決定される
  • 各ユーザーのコードメモデータは、サブコレクション[codes]内に保存される
  • [codes]内に、任意のIDを持ったドキュメントが自動生成される
  • codesには以下のデータが保存される
    • タイトル
    • 概要
    • コード
    • タグ
    • カテゴリ
    • いいね
    • 非公開フラグ
    • 作成日時
    • コピー元コードID

entity

上記のデータを格納するエンティティとして「Code」クラスを定義します。Map(json形式)で処理を行うことが多いため、あらかじめMapからCodeに変換するための処理は、その逆のCodeをMapに変換する処理を作成しておきます。

import 'package:cloud_firestore/cloud_firestore.dart';

class Code {
  //ID
  String? id;
  //タイトル
  String? title;
  //概要
  String? overview;
  //コード
  String? code;
  //タグ
  List<String>? tags;
  //カテゴリ
  List<String>? categories;
  //いいね
  int? likes;
  //非公開フラグ
  bool? private;
  //作成日時
  DateTime? createat;
  //コピー元コードID
  String? fromCopiedID;

  Code(
      {this.id,
      this.title,
      this.overview,
      this.code,
      this.tags,
      this.categories,
      this.likes,
      this.private,
      this.createat,
      this.fromCopiedID});

  Code.fromMap(Map snapshot, String id)
      : id = id,
        title = snapshot['title'],
        overview = snapshot['overview'],
        code = snapshot['code'],
        tags = snapshot['tags'],
        categories = snapshot['categories'],
        likes = snapshot['likes'],
        private = snapshot['private'],
        createat = snapshot['createat'],
        fromCopiedID = snapshot['fromCopiedID'];

  factory Code.fromDatabaseJson(Map<String, dynamic> data) => Code(
      id: data['id'],
      title: data['title'],
      overview: data['overview'],
      code: data['code'],
      tags: data['tags'],
      categories: data['categories'],
      likes: data['likes'],
      private: data['private'],
      createat: data['createat'],
      fromCopiedID: data['fromCopiedID']);

  Map<String, dynamic> toDatabaseJson() => {
        "id": this.id,
        "title": this.title,
        "overview": this.overview,
        "code": this.code,
        "tags": this.tags,
        "categories": this.categories,
        "likes": this.likes,
        "private": this.private,
        "createat": this.createat,
        "fromCopiedID": this.fromCopiedID
      };
}

dao

データをFirebaseに格納する処理を記載しています。このクラスがFirebaseとのやり取りを引き受ける処理になります。CRUD処理(Create/Read/Update/Delete)や、利用頻度の高い処理を実装します。

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:stockcodes/entity/code.dart';

class CodeDao {
  final db = FirebaseFirestore.instance;
  String collection = "users";
  String subCollection = "codes";

  create(user, Code code) async {
    try {
      await db
          .collection(collection)
          .doc(user.uid)
          .collection(subCollection)
          .add(
            code.toDatabaseJson(),
          );
    } catch (e) {
      print(e.toString());
    }
  }

  deleteByID(user, codeID) async {
    try {
      await db
          .collection(collection)
          .doc(user.uid)
          .collection(subCollection)
          .doc(codeID)
          .delete();
    } catch (e) {
      print(e.toString());
    }
  }

  updateByID(user, codeID, Code code) async {
    try {
      await db
          .collection(collection)
          .doc(user.uid)
          .collection(subCollection)
          .doc(codeID)
          .update(code.toDatabaseJson());
    } catch (e) {
      print(e.toString());
    }
  }

  Future selectByUser(user) async {
    List<Code> codes = [];
    try {
      QuerySnapshot snapshot = await db
          .collection(collection)
          .doc(user.uid)
          .collection(subCollection)
          .get();
      snapshot.docs.forEach((element) {
        String id = element.id;
        String title = element.get('title'); 
        String overview = element.get('overview'); 
        String code = element.get('code');
        List<String> tags = element.get('tags').cast<String>() as List<String>;
        List<String> categories =
            element.get('categories').cast<String>() as List<String>;
        int likes = element.get('likes'); 
        bool private = element.get('private');
        var createataTmp = element.get('createat');
        DateTime createat;
        if (createataTmp is Timestamp) {
          // toDate()でDateTimeに変換
          createat = createataTmp.toDate();
        } else {
          createat = DateTime.now();
        }
        String fromCopiedID =
            element.get('fromCopiedID'); 

        codes.add(Code.fromDatabaseJson({
          'id': id,
          'title': title,
          'overview': overview,
          'code': code,
          'tags': tags,
          'categories': categories,
          'likes': likes,
          'private': private,
          'createat': createat,
          'fromCopiedID': fromCopiedID,
        }));
      });
      return codes;
    } catch (e) {
      print(e.toString());
    }
  }
}

repository(リポジトリ)

DAOの処理をFutureを使って非同期処理として定義します。

import 'package:stockcodes/dao/code_dao.dart';
import 'package:stockcodes/entity/code.dart';

class CodeRepository {
  final codeDao = CodeDao();
  Future getAllCades(user) => codeDao.selectByUser(user);
  Future insertCode(user, Code code) => codeDao.create(user, code);
  Future updateCode(user, id, Code code) => codeDao.updateByID(user, id, code);
  Future deleteCodeById(user, String id) => codeDao.deleteByID(user, id);
}

model

modelクラスの中で、repository(Firebaseとのやり取りはDAOが実施)を使ってデータを取得します。

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:stockcodes/Repository/code_repository.dart';
import 'package:stockcodes/entity/code.dart';

class CodeModel with ChangeNotifier {
  User user;

  List<Code> _allCodeList = [];

  List<Code> get allCodeList => _allCodeList;

  void reload() {
    _fetchAll();
  }

  final CodeRepository repo = CodeRepository();

  CodeModel(this.user) {
    _fetchAll();
  }

  void _fetchAll() async {
    _allCodeList = await repo.getAllCades(user);
    notifyListeners();
  }

  void add(Code code) async {
    await repo.insertCode(user, code);
    _fetchAll();
  }

  void update(Code code, String id) async {
    await repo.updateCode(user, id, code);
    _fetchAll();
  }
}

一覧画面(ホーム)の作成

ベースとなるモデルの検討は完了しましたので、ここからはログイン後に表示される画面(UI)の実装に移っていきたいと思います。

Home(一覧画面)

ログインしたユーザに紐づくデータを一覧で表示するための画面です。ユーザが登録したCode情報は、ListViewを使って一覧で表示します。この時、1行を「CordRowView」Widgetにして、可読性と保守性を向上される工夫をしています。

新しいコードを「登録」する機能を「floatingActionButton」に実装します。実際の処理は「CordDetailView」(後述にて説明)に記載します。

import 'package:flutter/material.dart';
import 'package:stockcodes/entity/code.dart';
import 'package:stockcodes/model/code_model.dart';
import 'package:stockcodes/ui/codeDetailView.dart';
import 'package:stockcodes/ui/codeRowView.dart';
import '../main.dart';
import 'package:provider/provider.dart';

// [Themelist] インスタンスにおける処理。
class Home extends StatelessWidget {
  Home();

  @override
  Widget build(BuildContext context) {
    // ユーザー情報を受け取る
    final UserState userState = Provider.of<UserState>(context);
    final CodeModel codeModel = Provider.of<CodeModel>(context);
    List<Code> codes = codeModel.allCodeList;

    return Scaffold(
      appBar: AppBar(
        title: Text('Stock Codes'),
      ),
      body: ListView.builder(
        itemCount: codes.length,
        itemBuilder: (BuildContext context, int index) {
          return CordRowView(codes[index]);
        },
      ),
      floatingActionButton: FloatingActionButton(
        backgroundColor: Colors.indigo,
        onPressed: () {},
        child: IconButton(
          icon: Icon(Icons.add),
          color: Colors.white,
          onPressed: () {
            Navigator.of(context).push(
              MaterialPageRoute(
                fullscreenDialog: true,
                builder: (BuildContext context) => CordDetailView(null, true),
              ),
            );
          },
        ),
      ),
    );
  }
}

一覧の1行用Widget( CordRowView )

リストには、タイトル(title)と概要(overview)のみを表示し、あとは「参照」「編集」「削除」のイベントを用意します。

  • 「参照」は行をタップ(Tap)することで詳細情報を表示する機能です。
  • 「編集」は画面上の編集ボタンを押すことで、登録済みのデータを更新する機能です。
  • 「削除」は画面上の削除ボタンを押すことで、該当行のデータをFirebase上からも削除する機能です。
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:stockcodes/Repository/code_repository.dart';
import 'package:stockcodes/entity/code.dart';
import 'package:stockcodes/model/code_model.dart';
import 'package:stockcodes/ui/codeDetailView.dart';
import 'package:stockcodes/ui/codeDetailView.dart';

import '../main.dart';

class CordRowView extends StatelessWidget {
  late Code code;
  late String title;
  late String overview;
  late IconData private;
  final CodeRepository repo = CodeRepository();

  CordRowView(this.code) {
    this.title = (this.code.title == null ? "No title" : code.title)!;
    this.overview =
        (this.code.overview == null ? "No overview" : code.overview)!;
    if (this.code.private == null) {
      this.private = Icons.public;
    } else {
      this.private =
          (this.code.private == false ? Icons.public : Icons.public_off);
    }
  }

  @override
  Widget build(BuildContext context) {
    final UserState userState = Provider.of<UserState>(context);
    final CodeModel codeModels = Provider.of<CodeModel>(context);

    return Card(
      child: Column(
        mainAxisSize: MainAxisSize.max,
        children: <Widget>[
          ListTile(
              leading: Icon(this.private),
              title: Text(this.title),
              subtitle: Text(this.overview),
              onTap: () async {
                Navigator.of(context).push(
                  MaterialPageRoute(
                    fullscreenDialog: true,
                    builder: (BuildContext context) =>
                        CordDetailView(code, false),
                  ),
                );
              }),
          Row(
            mainAxisAlignment: MainAxisAlignment.end,
            children: <Widget>[
              ElevatedButton(
                child: const Text('編集'),
                style: ElevatedButton.styleFrom(
                  primary: Colors.blue,
                  onPrimary: Colors.white,
                ),
                onPressed: () {
                  Navigator.of(context).push(
                    MaterialPageRoute(
                      fullscreenDialog: true,
                      builder: (BuildContext context) =>
                          CordDetailView(code, true),
                    ),
                  );
                },
              ),
              ElevatedButton(
                child: const Text('削除'),
                style: ElevatedButton.styleFrom(
                  primary: Colors.red,
                  onPrimary: Colors.white,
                ),
                onPressed: () {
                  showDialog(
                    context: context,
                    builder: (context) {
                      return AlertDialog(
                        title: Text('警告'),
                        content: Text('データを削除しますか'),
                        actions: <Widget>[
                          ElevatedButton(
                            child: Text("キャンセル"),
                            onPressed: () => Navigator.pop(context),
                          ),
                          ElevatedButton(
                              child: Text("OK"),
                              onPressed: () {
                                repo.deleteCodeById(userState.user, code.id!);
                                codeModels.reload();
                                Navigator.pop(context);
                              }),
                        ],
                      );
                    },
                  );
                },
              ),
            ],
          )
        ],
      ),
    );
  }
}

登録/参照/更新用のWidget(CordDetailView)

画面上に参照モード/編集モード(editflag)を持たせることでで1つのWidgetに機能を集約しています。

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:stockcodes/entity/code.dart';
import 'package:stockcodes/model/code_model.dart';
import 'package:stockcodes/ui/home.dart';

import '../main.dart';

class CordDetailView extends StatelessWidget {
  Code? code;

  late String buttonText;
  late String? id;
  late bool editflag;
  CordDetailView(code, editflag) {
    this.code = code;
    this.editflag = editflag;
  }

  @override
  Widget build(BuildContext context) {
    final UserState userState = Provider.of<UserState>(context);
    final CodeModel codeModels = Provider.of<CodeModel>(context);

    final User user = userState.user!;
    final CodeModel codeModel = CodeModel(user);
    final titleController = TextEditingController();
    final overviewController = TextEditingController();
    final codeController = TextEditingController();
    final tagsController = TextEditingController();
    final categoriesController = TextEditingController();
    if (code == null) {
      //新規登録の場合
      buttonText = "登録";
      editflag = true; //更新可能
      id = null;
    } else {
      if (editflag) {
        //更新の場合
        buttonText = "更新";
        editflag = true;
      } else {
        //参照の場合
        buttonText = "編集する";
        editflag = false;
      }
      id = code!.id;
      titleController.text = code!.title ?? "";
      overviewController.text = code!.overview ?? "";
      codeController.text = code!.code ?? "";
      tagsController.text = code!.tags!.join(",");
      categoriesController.text = code!.categories!.join(",");
    }
    return Scaffold(
      appBar: AppBar(
        title: Text('Stock Codes'),
      ),
      body: SingleChildScrollView(
        child: Form(
          child: Container(
              width: double.infinity,
              color: Colors.white,
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: Container(
                  decoration: BoxDecoration(
                    border: Border.all(color: Colors.grey),
                    borderRadius: BorderRadius.circular(5),
                  ),
                  child: Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 30.0),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        SizedBox(height: 20),
                        Text('タイトル'),
                        Card(
                            color: Colors.white,
                            child: Padding(
                              padding: EdgeInsets.all(8.0),
                              child: TextFormField(
                                enabled: editflag,
                                controller: titleController,
                                keyboardType: TextInputType.multiline,
                                maxLines: null,
                              ),
                            )),
                        SizedBox(height: 20),
                        Text('概要'),
                        Card(
                            color: Colors.white,
                            child: Padding(
                              padding: EdgeInsets.all(8.0),
                              child: TextFormField(
                                enabled: editflag,
                                controller: overviewController,
                                keyboardType: TextInputType.multiline,
                                maxLines: null,
                              ),
                            )),
                        SizedBox(height: 20),
                        Text('コード'),
                        Card(
                            color: Colors.white,
                            child: Padding(
                              padding: EdgeInsets.all(8.0),
                              child: TextFormField(
                                enabled: editflag,
                                controller: codeController,
                                keyboardType: TextInputType.multiline,
                                maxLines: null,
                              ),
                            )),
                        SizedBox(height: 20),
                        Text("タグ"),
                        Card(
                            color: Colors.white,
                            child: Padding(
                              padding: EdgeInsets.all(8.0),
                              child: TextFormField(
                                enabled: editflag,
                                controller: tagsController,
                              ),
                            )),
                        SizedBox(height: 20),
                        Text("カテゴリ"),
                        Card(
                            color: Colors.white,
                            child: Padding(
                              padding: EdgeInsets.all(8.0),
                              child: TextFormField(
                                enabled: editflag,
                                controller: categoriesController,
                              ),
                            )),
                      ],
                    ),
                  ),
                ),
              )),
        ),
      ),
      // 画面下にボタンの配置
      bottomNavigationBar:
          Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
        Padding(
          padding: const EdgeInsets.all(20.0),
          child: ButtonTheme(
            minWidth: 350.0,
            // height: 100.0,
            child: ElevatedButton(
                child: Text(
                  buttonText,
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
                style: ElevatedButton.styleFrom(
                  primary: Colors.blue,
                  onPrimary: Colors.blue[50],
                ),
                // ボタンクリック後にデータを登録して、一覧画面へ戻る。
                onPressed: () {
                  try {
                    DateTime _now = DateTime.now();
                    code = new Code.fromDatabaseJson({
                      'id': id,
                      'title': titleController.text,
                      'overview': overviewController.text,
                      'code': codeController.text,
                      'tags': [tagsController.text],
                      'categories': [categoriesController.text],
                      'likes': 0,
                      'private': false,
                      'createat': _now,
                      'fromCopiedID': ""
                    });

                    if (editflag) {
                      if (id == null) {
                        codeModel.add(code!);
                      } else {
                        codeModel.update(code!, id!);
                      }

                      codeModels.reload();

                      Navigator.popUntil(context, (route) => route.isFirst);
                    } else {
                      Navigator.of(context).push(
                        MaterialPageRoute(
                          fullscreenDialog: true,
                          builder: (BuildContext context) =>
                              CordDetailView(code, true),
                        ),
                      );
                    }
                  } catch (e) {
                    print(e.toString());
                  }
                }),
          ),
        ),
      ]),
    );
  }
}

本番環境(Firebaseのホスティング)へのデプロイ

作成したWEBサービスを公開する手順です。

  1. Firebase CLIの準備
  2. アプリケーションのビルド
  3. GitHubにソースをPush
  4. firebaseのデプロイ

FirebaseのCLIをインストール

最終的なデプロイはFirebaseのdeployコマンドを実行するため、CLIをインストールします。npmを使ってインストールを行うため、node.jsのインストールが前提になります。

コマンドを実行してインストールを行います。

npm install -g firebase-tools

次に、CLIでログインを行います。

今回作成したアプリのルートフォルダへ移動し、以下のコマンドを実行します。

firebase login

Googleの認証画面が表示されるので、認証を行います。

認証に成功すると、以下の画面が表示されます。ここまでで、firebaseのCLIツールの準備は完了です。

次に、firebaseの初期化を行っていきます。基本的には聞かれたとおり(デフォルト値)が提示されるので、Yesを選択して進めていきます。

firebase init

途中、Githubとの認証を求められるます。

認証画面が表示さるので、認証を行います。

認証に成功すると、後続の処理が進みます。

この時、GitHub workflowを聞かれるのですが、正しい「user/repository」を入力する必要があります。

ビルドの実行

ビルドのコマンドを実行することで、WEB用のアプリケーションがbuildフォルダ配下に作成されます。

GitHubへのPush

gitコマンドを実行して、資材をPushします。

git push -u origin main

デプロイの実行

最後にデプロイの実行です。

firebase deploy

最後に表示される「Hosting URL」にアクセスすると、作成したWEBアプリを確認できます。

所感

初めてFlutterとFirebaseを使ったため、つまづく点はありましたが一連の流れを経験すれば、開発スピードは上がると思いますし、WEBサービスのプロトタイプ作成にも有効な手段だと感じました。

実際にアプリがスケールアウトした場合に、運用コストがどのくらいかかるかは今回見ていませんが、個人サービス程度の規模であれば、全然問題ないと感じました。

<おまけ>Flutterで実装時に調べたメモ

Widgetってなに?

UIを構築しているパーツのこと。様々なWidgetを組み合わせることで複雑なUIを構築する。

https://flutter.dev/docs/development/ui/widgets

状態を持つWidgetって? StatefulWidget ?

ボタンを押したら表示する値を変更したり、データを元にWidgetを表示したい場合に使うWidget。 StatefulWidget と State で実現する。State にデータを持ち、それを元に StatefulWidget でUIを作る

Flutterで状態管理といえば、上記のUIがもつStateを管理すること。

状態管理が大変…→Providerを使います。

pubspec.yamlに追加します。

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2
  # *** ここを追記 ***
  firebase_core: ^1.0.1
  firebase_auth: ^1.0.1
  cloud_firestore: ^2.3.0
  provider: ^5.0.0 //←今回追加

利用するdartファイルの冒頭で、importします。

import 'package:provider/provider.dart';

画面遷移ってどうやるの?

Navigatorを使います。Flutterでは画面遷移の過去履歴がNavigatorを通して蓄積されます。

Navigator.push(context, PageB); // == Navigator.of(context).push(PageB);
Navigator.pushNamed(context, '/a'); // == Navigator.of(context).pushNamed('/a');
Navigator.pop(context); // == Navigator.of(context).pop();
Navigator.removeRoute(context, PageB); // == Navigator.of(context).pop(PageB);

https://itome.team/blog/2019/12/flutter-advent-calendar-day10/

About: ken


コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください