概要
サーバーサイドのエンジニアですがFlutterについて勉強する機会をもらえたので、Twitterクローンを作って遊んでいます。今回は、Twitterクローン作成中にNavigator
あたりの挙動をいろいろ確かめたかったのでコンパクトに作った検証用プロジェクトをQiitaに投稿します。
この記事ではSqfliteを使って以下のことをしています。
- レコードの保存、
- レコードの問い合わせ
- レコードの削除
挙動はTwitterアプリ(Android版)の「下書き機能」を参考にしています。Twitterの「下書き機能」の仕様上、update
は使われてなさそうだったので今回の記事では実装していません。
フロントやネイティブの知識がなく、手探りで読み漁って実装しましたがいろいろ勉強になりました。
仕様
投稿ページ
input
フォームにテキストを入力し、送信ボタンを押すとそのテキストが保存されます。
右上のリストアイコンで保存されたテキストのリストページに遷移します。
テキスト一覧ページ
保存されているテキストの一覧が表示されます。
- リストを長押し:「削除」か「編集」のダイアログが出現します。
- リストをタップ:選んだテキストがホームの投稿ページのフォームに挿入され、選ばれたテキストはローカルストレージから削除されます。
ダイアログ
- 削除する: レコードが削除されます。
- 編集する: ホーム画面に遷移し、選択した下書きがテキストフォームにフィルインされます。そしてそのレコード自体は削除されます。
実装
まずは、必要なライブラリのインストールです。
pubspec.yml
のdependencies
に追加し、flutter pub get
コマンドを叩きます。
dependencies:
flutter:
sdk: flutter
sqflite:
path_provider:
path:
以降の実装でdb_provider
やrepository
と名付けていますが、
自分の中で概念がまだふわふわしているので名付けが相応しくないかも...
DBProvider
sqfliteの初期化とデータ操作ができるDBProvider
クラスを作成します。
import 'dart:io';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart';
class DBProvider {
final _databaseName = "MyDatabase.db";
final _databaseVersion = 1;
// make this singleton class
DBProvider._();
static final DBProvider instance = DBProvider._();
// only have a single app-wide reference to the database
Database _database;
Future<Database> get database async {
if (_database != null) return _database;
// lazily instantate the db the first time it is accessed
_database = await _initDatabase();
return _database;
}
void _createTableV1(Batch batch) {
batch.execute('''
CREATE TABLE input_text(
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
) ''');
}
//this opens the database (and creates it if it doesn't exist)
_initDatabase() async {
final Directory documentsDirectory =
await getApplicationDocumentsDirectory();
final String path = join(documentsDirectory.path, _databaseName);
return await openDatabase(
path,
version: _databaseVersion,
onCreate: (db, version) async {
var batch = db.batch();
_createTableV1(batch);
await batch.commit();
},
onDowngrade: onDatabaseDowngradeDelete,
);
}
}
せっかくなのでモデルを定義し、これを利用するようにしました。
普段は動的型付け言語を使っているのですが、出来る限り厳密な型付けにチャレンジ。
Sqflite(Sqlite)は日付型としてレコードを格納できないようです。
なので保存するときはTextに変換し、Modelとして扱う時はDateTime型
に変換して使うようにしました。
InputText モデル
import 'package:flutter/foundation.dart';
class InputText {
const InputText({
@required this.id,
@required this.body,
@required this.createdAt,
@required this.updatedAt,
}) : assert(id != null),
assert(body != null),
assert(createdAt != null),
assert(updatedAt != null);
final int id;
final String body;
final DateTime createdAt;
final DateTime updatedAt;
int get getId => id;
String get getBody => '$body';
DateTime get getUpdatedAt => updatedAt;
Map<String, dynamic> toMap() => {
"id": id,
"title": body,
"created_at": createdAt.toUtc().toIso8601String(),
"dreated_at": updatedAt.toUtc().toIso8601String(),
};
factory InputText.fromMap(Map<String, dynamic> json) => InputText(
id: json["id"],
body: json["body"],
createdAt: DateTime.parse(json["created_at"]).toLocal(),
updatedAt: DateTime.parse(json["updated_at"]).toLocal(),
);
}
InputTextRepository
今回ローカルストレージで扱うtableはinput_text
だけです。複数のテーブルを扱う訳ではないのでDBProvider
と切り離して実装する必要もないのですが、複数tableを扱う場合、どのように実装すると良いかなど考えて遊んでいたため、このようになっています...
import 'package:sqflite_demo/db/db_provider.dart';
import 'package:sqflite_demo/model/input_text.dart';
class InputTextRepository {
static String table = 'input_text';
static DBProvider instance = DBProvider.instance;
static Future<InputText> create(String text) async {
DateTime now = DateTime.now();
final Map<String, dynamic> row = {
'body': text,
'created_at': now.toString(),
'updated_at': now.toString(),
};
final db = await instance.database;
final id = await db.insert(table, row);
return InputText(
id: id,
body: row["body"],
createdAt: now,
updatedAt: now,
);
}
static Future<List<InputText>> getAll() async {
final db = await instance.database;
final rows =
await db.rawQuery('SELECT * FROM $table ORDER BY updated_at DESC');
if (rows.isEmpty) return null;
return rows.map((e) => InputText.fromMap(e)).toList();
}
static Future<InputText> single(int id) async {
final db = await instance.database;
final rows = await db.rawQuery('SELECT * FROM $table WHERE id = ?', [id]);
if (rows.isEmpty) return null;
return InputText.fromMap(rows.first);
}
static Future<int> update({int id, String text}) async {
String now = DateTime.now().toString();
final row = {
'id': id,
'body': text,
'updated_at': now,
};
final db = await instance.database;
return await db.update(table, row, where: 'id = ?', whereArgs: [id]);
}
static Future<int> delete(int id) async {
final db = await instance.database;
return db.delete(table, where: 'id = ?', whereArgs: [id]);
}
}
ページの実装
テキストリストページのリストを長押しする「編集」か「削除」が選べるダイヤログが出現します。編集を選んだ場合はホーム画面のテキストフォームにフィルインされる仕様です。
試しにNavigator
を2回使ってみたらホーム画面のテキストフォームにフィルインされ、無事成功。少々無理やりな気がしますが...
非同期処理メソッドの返り値Future型
の扱いにもなかなか苦戦しました。FutureBuilder
を利用することで上手くリストを表示することができるのですね。(たどり着くのに時間がかかった)
import 'package:flutter/material.dart';
import 'package:sqflite_demo/db/input_text_repository.dart';
import 'package:sqflite_demo/model/input_text.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
TextEditingController _textController;
@override
void initState() {
super.initState();
_textController = TextEditingController();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Sqflte'),
actions: [
IconButton(
icon: Icon(Icons.list),
onPressed: () async {
var draft = await Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => ListPage(),
),
);
if (draft != null) {
setState(() => _textController.text = draft);
}
},
),
],
),
body: Center(
child: ListView(
padding: EdgeInsets.symmetric(horizontal: 24.0),
children: <Widget>[
SizedBox(height: 80.0),
TextFormField(
controller: _textController,
validator: (value) {
if (value.isEmpty) {
return 'Please enter text';
} else {
return null;
}
},
decoration: InputDecoration(
filled: true,
labelText: 'input',
),
),
SizedBox(height: 20.0),
RaisedButton(
child: Text('送信'),
onPressed: () {
InputTextRepository.create(_textController.text);
_textController.clear();
},
),
],
),
),
);
}
}
class ListPage extends StatefulWidget {
@override
_ListPageState createState() => _ListPageState();
}
class _ListPageState extends State<ListPage> {
@override
Widget build(BuildContext context) {
var futureBuilder = FutureBuilder(
future: InputTextRepository.getAll(),
builder: (BuildContext context, AsyncSnapshot snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
return Text('loading...');
default:
if (snapshot.hasError)
return Text('Error: ${snapshot.error}');
else
return createListView(context, snapshot);
}
},
);
return Scaffold(
appBar: AppBar(title: Text("Text List")),
body: futureBuilder,
);
}
Widget createListView(BuildContext context, AsyncSnapshot snapshot) {
List<InputText> inputTextList = snapshot.data;
return ListView.builder(
itemCount: inputTextList != null ? inputTextList.length : 0,
itemBuilder: (BuildContext context, int index) {
InputText inputText = inputTextList[index];
return Column(
children: <Widget>[
ListTile(
title: Text(inputText.getBody),
subtitle: Text(inputText.getUpdatedAt.toString()),
onTap: () {
final draftBody = inputText.getBody;
InputTextRepository.delete(inputText.getId);
Navigator.of(context).pop(draftBody);
},
onLongPress: () => showDialog(
context: context,
builder: (context) {
return SimpleDialog(
backgroundColor: Colors.grey,
children: <Widget>[
SimpleDialogOption(
onPressed: () {
final draftBody = inputText.getBody;
InputTextRepository.delete(inputText.getId);
Navigator.of(context).pop(draftBody);
Navigator.of(context).pop(draftBody);
},
child: Text(
"編集する",
style: TextStyle(
color: Colors.white,
fontSize: 18.0,
),
),
),
SimpleDialogOption(
onPressed: () {
setState(() {
InputTextRepository.delete(inputText.id);
print('deleted');
Navigator.of(context).pop();
});
},
child: Text(
"削除する",
style: TextStyle(
color: Colors.white,
fontSize: 18.0,
),
),
),
],
);
},
),
),
Divider(height: 1.0),
],
);
},
);
}
}
Navigator
やStatefulWidget
はまだまだ雰囲気で使ってるので引き続き勉強していきたいです。
感想
データソースにアクセスするインフラ層を一から設計し、実装する機会が今までになかったのでそこが結構楽しかったです。GitHubを見ると様々な実装方法があるようでいろいろ試したいなと思いました。そしてFlutterは引き続き状態管理についても勉強して行きたいと思います。