FlutterとfirebaseでWEBサービスを作る
趣味の開発や仕事で書いた、ちょっとしたコマンドやマクロなどを気軽にストックするためのWEBサービスを作りたいなと思っていたので、作ることにしました。
せっかくなので勉強もかねてFlutterとFirebaseを使ってみることにしました。
作成した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 | 〇 | ログイン機能は実装しようと思います。 |
DB | Cloud Firestore | 〇 | テキストデータはこっちに保存 |
DB | Cloud Storage for Firebase | × | 画像データはこっちに保存っぽいですが今回は使いません。 |
DB | Realtime Database | × | Cloud Firestore の方が複雑なデータ構造で 保存できるようなので、こっちは使いません。 |
プッシュ通知 | Firebase Cloud Messaging(FCM) | × | ユーザへのお知らせ機能は不要。 |
機能 | Google Cloud Functions for Firebase | × | ここにロジックを記載します。 |
ホスティング | Firebase Hosting | 〇 | ここに出来上がったアプリをデプロイ予定。 |
Flutterのプロジェクト作成
次にFlutterを使った開発の準備をしていきます。
SDK の設定と構成
Flutterのプロジェクトを作成するためには、FlutterのSDKが必要になります。以下のサイトからダウンロードできます。(後の手順「 Authentication用ライブラリをインストール 」では、このSDKのバージョンと一致させる必要があります。後でやるので今は大丈夫です。)
https://flutter.dev/docs/get-started/install
利用しているOSに合わせてSDKのインストールを行います。そこまで難しくなく、私はWindows環境でしたので、
- SDKのZIPをダウンロードして解凍
- 解凍したフォルダをProgram Filesに移動(手順3でパスを通すので何処でもいい)
- システム環境変数のPathに追加
visual studio codeでの開発準備
開発のエディタはVS Codeを使います。本家サイトの手順にも書いていますが、VS Codeに「Flutter拡張機能」は適用した方がよいです。
エディタープラグインの1つを使用することをお勧めします。これらのプラグインは、コードの補完、構文の強調表示、ウィジェットの編集支援、実行とデバッグのサポートなどを提供します。
https://flutter.dev/docs/get-started/editor?tab=vscode
- VSCodeを起動します。
- ビューの呼び出し>コマンドパレット…
- 「install」と入力し、[ Extensions:InstallExtensions]を選択します。
- 拡張機能の検索フィールドに「flutter」と入力し、リストで[ Flutter ]を選択して、[ Install ]をクリックします。これにより、必要なDartプラグインもインストールされます。
VS Codeで新しいプロジェクトの作成
https://flutter.dev/docs/development/tools/vs-code
Flutterスターターアプリテンプレートから新しいFlutterプロジェクトを作成します。
- コマンドパレットを開きます(Ctrl+ Shift+ P(Cmd+ Shift+ PMacOSの上))。
- Flutter:New Application Projectコマンドを選択し、を押しEnterます。
- 希望のプロジェクト名を入力します。
- プロジェクトの場所を選択します。
プロジェクトを作成する場所を選択します。どこでもOKです。
フォルダを選択すると、雛形のフォルダが自動で作成されます。
ディレクトリ構成(アプリ内の役割分担、アーキテクチャ)
ディレクトリ構成は、以下の記事を参考にさせて頂きました。
https://qiita.com/osamu1203/items/526a13d730500decf58c
ディレクトリ | 役割 |
main.dart | メイン(起動プログラム)のファイルです。 |
ui | 画面。Stateless Widgetを継承して作成することで、UIとロジックの分離を図る。 |
model | ロジック。BlocやViewModelと呼ばれる層で、処理とステート(状態)を持つ。 |
Repository | 外部データインタフェース。modelが外からデータを取得時に利用。 |
dao | 外部リソースへのデータ取得。 |
entity | アプリケーション内で扱われるオブジェクト群です。APIやDBなど外部リソースから取得したJSONなどのデータを、適切なオブジェクトへと変換します。 |
service | firebaseとのやり取りや、メッセージ管理など。ユーティリティ的な処理を集約する。 |
認証機能: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
メイン(プログラムの開始)
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であれば、保存と同時にライブラリがインストールされます。
データモデルの検討
データモデルを検討します。以下の記事が参考になりました。
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サービスを公開する手順です。
- Firebase CLIの準備
- アプリケーションのビルド
- GitHubにソースをPush
- 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/