27
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-09-05

概要

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

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

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

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

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

github:今回作成したもの

仕様

投稿ページ

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

スクリーンショット 2020-08-30 0.38.06.png

テキスト一覧ページ

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

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

スクリーンショット 2020-08-30 0.38.18.png

ダイアログ

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

スクリーンショット 2020-08-30 0.38.38.png

実装

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

pubspec.yml
dependencies:
  flutter:
    sdk: flutter

  sqflite:
  path_provider:
  path:

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

DBProvider

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

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

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

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を利用することで上手くリストを表示することができるのですね。(たどり着くのに時間がかかった)

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),
          ],
        );
      },
    );
  }
}

NavigatorStatefulWidgetはまだまだ雰囲気で使ってるので引き続き勉強していきたいです。

感想

データソースにアクセスするインフラ層を一から設計し、実装する機会が今までになかったのでそこが結構楽しかったです。GitHubを見ると様々な実装方法があるようでいろいろ試したいなと思いました。そしてFlutterは引き続き状態管理についても勉強して行きたいと思います。

参考

Simple SQFlite database example in Flutter

27
27
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?