[Flutter] Sqfliteを使ってtwitterの下書きの機能の挙動を真似してみた


# 概要

 サーバーサイドのエンジニアですがFlutterについて勉強する機会をもらえたので、Twitterクローンを作って遊んでいます。今回は、Twitterクローン作成中に`Navigator`あたりの挙動をいろいろ確かめたかったのでコンパクトに作った検証用プロジェクトをQiitaに投稿します。

この記事ではSqfliteを使って以下のことをしています。

 - レコードの保存、
 - レコードの問い合わせ
 - レコードの削除

挙動はTwitterアプリ（Android版）の「下書き機能」を参考にしています。Twitterの「下書き機能」の仕様上、`update`は使われてなさそうだったので今回の記事では実装していません。

フロントやネイティブの知識がなく、手探りで読み漁って実装しましたがいろいろ勉強になりました。

[github:今回作成したもの](https://github.com/rinonkia/sqflite_demo)

## 仕様

### 投稿ページ

`input`フォームにテキストを入力し、送信ボタンを押すとそのテキストが保存されます。
右上のリストアイコンで保存されたテキストのリストページに遷移します。

![スクリーンショット 2020-08-30 0.38.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222848/d53ace8e-46ac-715e-98ac-305e88a30237.png)

### テキスト一覧ページ

保存されているテキストの一覧が表示されます。

 - リストを長押し：「削除」か「編集」のダイアログが出現します。
 - リストをタップ：選んだテキストがホームの投稿ページのフォームに挿入され、選ばれたテキストはローカルストレージから削除されます。

![スクリーンショット 2020-08-30 0.38.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222848/a71f03d8-7bae-963d-5e1e-885682a5f2cc.png)

### ダイアログ

 - 削除する： レコードが削除されます。
 - 編集する： ホーム画面に遷移し、選択した下書きがテキストフォームにフィルインされます。そしてそのレコード自体は削除されます。

![スクリーンショット 2020-08-30 0.38.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222848/97538c97-5772-5cb4-ad42-703261d14c8a.png)

## 実装

まずは、必要なライブラリのインストールです。
`pubspec.yml`の`dependencies`に追加し、`flutter pub get`コマンドを叩きます。

```yaml:pubspec.yml
dependencies:
  flutter:
    sdk: flutter

  sqflite:
  path_provider:
  path:
```

----

以降の実装で`db_provider`や`repository`と名付けていますが、
自分の中で概念がまだふわふわしているので名付けが相応しくないかも...

#### DBProvider

sqfliteの初期化とデータ操作ができる`DBProvider`クラスを作成します。

```dart:lib/db/db_provider.dart

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 モデル

```dart:lib/model/input_text.dart
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を扱う場合、どのように実装すると良いかなど考えて遊んでいたため、このようになっています...

```dart:lib/db/input_text_repository.dart
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`を利用することで上手くリストを表示することができるのですね。（たどり着くのに時間がかかった）

```dart:lib/main.dart

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は引き続き状態管理についても勉強して行きたいと思います。

### 参考

[Simple SQFlite database example in Flutter](https://medium.com/@suragch/simple-sqflite-database-example-in-flutter-e56a5aaa3f91)