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/