FlutterでデータをローカルDBに永続化する

# はじめに

アプリ開発をするにあたって、ローカルへのデータ保存は必須と言えます。
公式サイトにあった SQLite を利用した永続化を試してみます。

https://docs.flutter.dev/cookbook/persistence/sqlite

:::note
手順には従いつつ、データモデルの中身など、一部アレンジしています。
:::

:::note
sqflite を利用しています。
iOS/Android/macOS でしか動きません。

macOS で動かす場合は `cocoapods` のインストールが必要です。
```bash
brew install cocoapods
```
:::

# 手順

## dependencies の追加

以下のコマンドを実行して追加します。

```bash
flutter pub add sqflite path
```

以下のimportを追加してみて、エラーにならないことを確認します。

```bash
import 'dart:async';

import 'package:flutter/widgets.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294382/dce92c64-2c3d-3d4f-3ad9-516b3f0d5764.png)

利用してないので、警告が出ますが問題なし。

## データモデルの定義

DVD や Blu-ray 、デジタル など、持っているソフトの管理をイメージして、Mediaモデルを作ってみます。
（簡易的なモデルですが、練習用なのでご了承ください）

```dart
// ./lib/models/media.dart

enum MediaType {
  dvd,
  bluray,
  digital,
}

class Media {
  final int id;
  final MediaType type;
  final String title;
  final DateTime releasedAt;
  final DateTime addedAt;

  const Media({
    required this.id,
    required this.type,
    required this.title,
    required this.releasedAt,
    required this.addedAt,
  });
}
```

## データベースを開く

main を async にして、データベースを開く処理を追加する。

```diff_dart
- void main() {
+ void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ // データベースを開く
+ final database = openDatabase(
+   join(await getDatabasesPath(), 'media_manager_database.db'),
+ );

  runApp(MyApp());
}
```

一度データベースの中身を見てみます。
macOS で起動した場合、以下のようなパスにファイルが保存されていました。
(`getDatabasesPath().then((val) => print(val));` でパスを確認しました)

```bash
~/Library/Containers/com.example.mediaManagerApp/Data/Documents/media_manager_database.db
```

![Screenshot 2024-12-18 at 11.55.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294382/4e252df1-30b2-2a90-70c2-91e5f97c85c7.png)

`media_manager_database.db` をDBブラウザで開くと、無事にテーブルが追加されていることが分かります。

使用したソフトはこちら

https://sqlitebrowser.org/dl/


## データの投入

モデルを修正して、DB投入用のMap生成を追加。

```diff_dart
// ./lib/models/media.dart

enum MediaType {
  dvd("DVD"),
  bluray("Blu-ray"),
  digital("Digital"),
  ;

+ @override
+ String toString() => displayName;
+
+ final String displayName;
+
+ const MediaType(this.displayName);
}

class Media {
  final int id;
  final MediaType type;
  final String title;
  final DateTime releasedAt;
  final DateTime addedAt;

  const Media({
    required this.id,
    required this.type,
    required this.title,
    required this.releasedAt,
    required this.addedAt,
  });

+ // DB投入用にtoMapを追加
+ Map<String, Object?> toMap() {
+   return {
+     'id': id,
+     'type': type.toString(),
+     'title': title,
+     'released_at': releasedAt.toUtc().toIso8601String(),
+     'added_at': addedAt.toUtc().toIso8601String(),
+   };
+ }
+
+ // 情報確認用にtoStringも実装
+ @override
+ String toString() {
+   return 'Media{id: $id, type: $type, title: $title, releasedAt: $releasedAt, addedAt: $addedAt}';
+ }
}

```

DBへの投入関数を追加

```dart
// ./lib/models/main.dart

// media テーブルに insert する
Future<void> insertMedia(Media media) async {
  // Get a reference to the database.
  final db = await database;

  // Conflict が発生した場合、置き換える
  await db.insert(
    'media',
    media.toMap(),
    conflictAlgorithm: ConflictAlgorithm.replace,
  );
}
```

insertMedia の呼び出しを追加

```dart
// ./lib/models/main.dart

// media テーブルへ insert するデータ
var media = Media(
id: 0,
type: MediaType.dvd,
title: 'Flutterの冒険',
releasedAt: DateTime(1999, 3, 6),
addedAt: DateTime.now(),
);

insertMedia(media);
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294382/1693bdc0-b405-78e5-fb4a-9ec5665ab227.png)

ちゃんと insert 出来てますね

## データの取得

全件取得を追加します。
まず、MediaType にDBから取得したデータ(type)を MediaType に変換するための処理を追加しておきます。

```diff_dart
// ./lib/models/main.dart
enum MediaType {
  dvd("DVD"),
  bluray("Blu-ray"),
  digital("Digital"),
  ;

  @override
  String toString() => displayName;

  final String displayName;

  const MediaType(this.displayName);

+ static MediaType from(String displayName) {
+   return MediaType.values.firstWhere(
+     (type) => type.displayName == displayName,
+     orElse: () => throw ArgumentError('Invalid MediaType: $displayName'),
+   );
+ }
}

// ...
```

取得処理を追加

```dart
// ./lib/models/main.dart

// media テーブルから全データを取得する
Future<List<Media>> listMedia() async {
  // Get a reference to the database.
  final db = await database;

  // media から全件取得するクエリ.
  final List<Map<String, Object?>> maps = await db.query('media');

  // 取得したデータをMediaに変換する
  return maps
      .map((v) => Media(
            id: v['id'] as int,
            type: MediaType.from(v['type'] as String),
            title: v['title'] as String,
            releasedAt: DateTime.parse(v['released_at'] as String),
            addedAt: DateTime.parse(v['added_at'] as String),
          ))
      .toList();
}
```

listMedia の呼び出しを追加

```dart
// ./lib/models/main.dart

listMedia().then((v) => {v.forEach(print)});
```

あらかじめデータを2件に増やして実行してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294382/3df8cf11-946c-78c4-f8da-67c42f0132bf.png)

出力結果

```bash
flutter: Media{id: 0, type: DVD, title: Flutterの冒険, releasedAt: 1999-03-05 15:00:00.000Z, addedAt: 2024-12-18 11:08:21.595752Z}
flutter: Media{id: 1, type: DVD, title: Flutterの冒険2, releasedAt: 2000-05-08 15:00:00.000Z, addedAt: 2024-12-18 12:59:00.913387Z}
```

正しく取得できました。


## データの更新

更新処理を追加

```dart
// ./lib/models/main.dart

Future<void> updateMedia(Media media) async {
  // Get a reference to the database.
  final db = await database;

  // 引数のmediaで更新する
  await db.update(
    'media',
    media.toMap(),
    // IDで更新対象を特定する
    where: 'id = ?',
    whereArgs: [media.id],
  );
}
```

updateMedia の呼び出しを追加

```dart
var media = Media(
  id: 1,
  type: MediaType.bluray,
  title: 'Flutterの冒険3',
  releasedAt: DateTime(2012, 2, 6),
  addedAt: DateTime.now(),
);

updateMedia(media);
```


更新前後でデータ取得を呼び出して diff を取ってみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294382/51d9d8a5-14ca-7242-aa1d-39c2f9e63a03.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294382/29aaa40a-98ce-b400-492c-63edbc2ea741.png)

正しく更新されています。

## データの削除

やっとラスト。削除です。

削除処理を追加。

```dart
// ./lib/models/main.dart

Future<void> deleteMedia(int id) async {
  // Get a reference to the database.
  final db = await database;

  // 引数idのmediaを削除する
  await db.delete(
    'media',
    // IDで削除対象を特定する
    where: 'id = ?',
    whereArgs: [id],
  );
}
```

deleteMedia の呼び出しを追加
(idを指定して呼び出すだけ)

```dart
// ./lib/models/main.dart

deleteMedia(1);
```

削除前後でデータ取得を呼び出して diff を取ってみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294382/30cbcd83-5a56-e11a-9ce0-e149e1c418b1.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294382/1f83a3b1-b890-019c-5049-0ce7ae51611a.png)

削除されました。


# 少しアレンジ

CUI だと味気ないので、GUI で確認できるようにしてみました。

![test4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3294382/dc6b94b8-6185-a6f7-0a2e-6b07c44bef89.gif)

コードを載せておきます。

## ./lib/models/media.dart


```dart
enum MediaType {
  dvd("DVD"),
  bluray("Blu-ray"),
  digital("Digital"),
  ;

  @override
  String toString() => displayName;

  final String displayName;

  const MediaType(this.displayName);

  static MediaType from(String displayName) {
    return MediaType.values.firstWhere(
      (type) => type.displayName == displayName,
      orElse: () => throw ArgumentError('Invalid MediaType: $displayName'),
    );
  }
}

class Media {
  final int id;
  final MediaType type;
  final String title;
  final DateTime releasedAt;
  final DateTime addedAt;

  const Media({
    required this.id,
    required this.type,
    required this.title,
    required this.releasedAt,
    required this.addedAt,
  });

  // DB投入用にtoMapを追加
  Map<String, Object?> toMap() {
    return {
      'id': id,
      'type': type.toString(),
      'title': title,
      'released_at': releasedAt.toUtc().toIso8601String(),
      'added_at': addedAt.toUtc().toIso8601String(),
    };
  }

  // 情報確認用にtoStringも実装
  @override
  String toString() {
    return 'Media{id: $id, type: $type, title: $title, releasedAt: $releasedAt, addedAt: $addedAt}';
  }
}
```

## ./main.dart

```dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:media_manager_app/models/media.dart';

import 'dart:async';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';

late final Future<Database> database;

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // データベースを開く
  database = openDatabase(
    // Set the path to the database. Note: Using the `join` function from the
    // `path` package is best practice to ensure the path is correctly
    // constructed for each platform.
    join(await getDatabasesPath(), 'media_manager_database.db'),
    // When the database is first created, create a table to store dogs.
    onCreate: (db, version) {
      // Run the CREATE TABLE statement on the database.
      return db.execute(
        'CREATE TABLE media(id INTEGER PRIMARY KEY, type TEXT, title TEXT, released_at DATE, added_at DATE)',
      );
    },
    // Set the version. This executes the onCreate function and provides a
    // path to perform database upgrades and downgrades.
    version: 1,
  );

  runApp(MyApp());
}

// media テーブルに insert する
Future<void> insertMedia(Media media) async {
  // Get a reference to the database.
  final db = await database;

  // Conflict が発生した場合、置き換える
  await db.insert(
    'media',
    media.toMap(),
    conflictAlgorithm: ConflictAlgorithm.replace,
  );
}

// media テーブルから全データを取得する
Future<List<Media>> listMedia() async {
  // Get a reference to the database.
  final db = await database;

  // media からrefetchMyMediazぜんけ取得するクエリ.
  final List<Map<String, Object?>> maps = await db.query('media');

  // 取得したデータをMediaに変換する
  return maps
      .map((v) => Media(
            id: v['id'] as int,
            type: MediaType.from(v['type'] as String),
            title: v['title'] as String,
            releasedAt: DateTime.parse(v['released_at'] as String),
            addedAt: DateTime.parse(v['added_at'] as String),
          ))
      .toList();
}

Future<void> updateMedia(Media media) async {
  // Get a reference to the database.
  final db = await database;

  // 引数のmediaで更新する
  await db.update(
    'media',
    media.toMap(),
    // IDで更新対象を特定する
    where: 'id = ?',
    whereArgs: [media.id],
  );
}

Future<void> deleteMedia(int id) async {
  // Get a reference to the database.
  final db = await database;

  // 引数idのmediaを削除する
  await db.delete(
    'media',
    // IDで削除対象を特定する
    where: 'id = ?',
    whereArgs: [id],
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => MyAppState(),
      child: MaterialApp(
        title: 'Media Manager App',
        theme: ThemeData(
          useMaterial3: true,
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
        ),
        home: MyHomePage(),
      ),
    );
  }
}

class MyAppState extends ChangeNotifier {
  var mediaList = <Media>[];

  void refetchMyMedia() async {
    listMedia().then((v) => mediaList = v);
    notifyListeners();
  }

  void clearMediaList() {
    mediaList.clear();
    notifyListeners();
  }
}

class MyHomePage extends StatefulWidget {
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  var selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    Widget page;
    switch (selectedIndex) {
      case 0:
        page = GeneratorPage();
        break;
      case 1:
        page = MyMediaPage();
        break;
      default:
        throw UnimplementedError('no widget for $selectedIndex');
    }

    return LayoutBuilder(builder: (context, constraints) {
      return Scaffold(
        body: Row(
          children: [
            SafeArea(
              child: NavigationRail(
                extended: constraints.maxWidth >= 600,
                destinations: [
                  NavigationRailDestination(
                    icon: Icon(Icons.home),
                    label: Text('Home'),
                  ),
                  NavigationRailDestination(
                    icon: Icon(Icons.favorite),
                    label: Text('My Media'),
                  ),
                ],
                selectedIndex: selectedIndex,
                onDestinationSelected: (value) {
                  setState(() {
                    selectedIndex = value;
                  });
                },
              ),
            ),
            Expanded(
              child: Container(
                color: Theme.of(context).colorScheme.primaryContainer,
                child: page,
              ),
            ),
          ],
        ),
      );
    });
  }
}

class GeneratorPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();

    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              ElevatedButton(
                onPressed: () {
                  // media テーブルへ insert するデータ
                  var media = Media(
                    id: 0,
                    type: MediaType.dvd,
                    title: 'Flutterの冒険',
                    releasedAt: DateTime(1999, 3, 6),
                    addedAt: DateTime.now(),
                  );

                  insertMedia(media);
                  appState.refetchMyMedia();
                },
                child: Text('Insert Flutterの冒険'),
              ),
              IconButton(
                onPressed: () {
                  // 0 のデータを削除
                  deleteMedia(0);
                  appState.refetchMyMedia();
                },
                icon: Icon(Icons.delete),
              ),
            ],
          ),
          SizedBox(height: 10),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              ElevatedButton(
                onPressed: () {
                  var media = Media(
                    id: 1,
                    type: MediaType.dvd,
                    title: 'Flutterの冒険2',
                    releasedAt: DateTime(2000, 5, 9),
                    addedAt: DateTime.now(),
                  );

                  insertMedia(media);
                  appState.refetchMyMedia();
                },
                child: Text('Insert Flutterの冒険2'),
              ),
              IconButton(
                onPressed: () {
                  // 1 のデータを削除
                  deleteMedia(1);
                  appState.refetchMyMedia();
                },
                icon: Icon(Icons.delete),
              ),
            ],
          ),
          SizedBox(height: 10),
          ElevatedButton(
            onPressed: () {
              // updateするデータ
              var media = Media(
                id: 1,
                type: MediaType.bluray,
                title: 'Flutterの冒険3',
                releasedAt: DateTime(2012, 2, 6),
                addedAt: DateTime.now(),
              );

              updateMedia(media);
              appState.refetchMyMedia();
            },
            child: Text('Update Flutterの冒険2 title'),
          ),
        ],
      ),
    );
  }
}

class MyMediaPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();

    if (appState.mediaList.isEmpty) {
      return Center(
        child: Text('No MyMedia yet.'),
      );
    }

    return ListView(
      children: [
        Padding(
          padding: const EdgeInsets.all(20),
          child: Text('You have '
              '${appState.mediaList.length} mediaList:'),
        ),
        ...appState.mediaList.map((v) => ListTile(
              leading: Icon(Icons.favorite),
              title: Text(v.title),
              subtitle: Text('''
ID: ${v.id}
MediaType: ${v.type.displayName}
ReleaseDate: ${v.releasedAt.toLocal().toIso8601String()}
AddedDate: ${v.addedAt.toLocal().toIso8601String()}
'''),
            ))
      ],
    );
  }
}
```

コードが冗長だったり、例外処理をあまり入れてなかったりしますが、あくまで確認用コードということで、ご容赦ください。

# 最後に

今回はデータの永続化(CRUD) をやってみました。
これができると、アプリが作れそうな気になってきますね(笑)
まだまだ、書き方がいけてなかったりすると思うので勉強続けていきたいと思います。

ご拝読ありがとうございました。