Flutterで「MVVM・Provider・SQLite」を使った「検索バーアプリ」を作成する

# 作成するアプリ

検索バーに入力されたキーワードで、メモのタイトル検索を行うアプリです。

検索の他に、メモの登録・編集・削除を行うことが出来ます。

**メモ一覧画面**
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/365643/ba4f0b3a-174f-aea5-aa92-e027a9c61374.png" width=300 />

**検索バーを使ったタイトル検索**
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/365643/3ee1fcad-a5cd-6d20-5165-cebb77c4d378.png" width=300 />

**メモ登録・編集・削除画面**
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/365643/6e4e43ae-2318-121e-a2a9-bb01d1ab5f7b.png" width=300 />

# 実装

- Flutter: 2.0.6
- Dart: 2.12.3

**パッケージ構成**

```
├── lib
│   ├── main.dart
│   ├── model
│   │   ├── db
│   │   │   └── app_database.dart
│   │   ├── entity
│   │   │   └── memo.dart
│   │   └── repository
│   │       └── memo_repository.dart
│   └── ui
│       ├── memo_detail (メモ登録・編集・削除画面)
│       │   ├── memo_detail.dart
│       │   └── memo_detail_view_model.dart
│       └── memo_list (メモ一覧画面)
│           ├── memo_list.dart
│           └── memo_list_view_model.dart
├── pubspec.yaml
```

まず、`pubspec.yaml`にライブラリを追加します。

```pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  # 追加: memo.dartの「DateFormat」で使用する
  flutter_localizations:
    sdk: flutter

  # 以下を追加
  sqflite: ^2.0.0+3
  # app_database.dartの「join」で使用する
  path: 1.8.0
  provider: ^5.0.0
  # Memoのid生成で使用する
  uuid: ^3.0.4
```

## model

modelパッケージの`app_database.dart`、`memo.dart`、`memo_repository.dart`を作成します。

```app_database.dart
import 'package:path/path.dart';
import 'package:search_bar_sample_app/model/entity/memo.dart';
import 'package:sqflite/sqflite.dart';

class AppDatabase {
  final String _tableName = 'Memo';
  final String _columnId = 'id';
  final String _columnTitle = 'title';
  final String _columnContent = 'content';
  final String _columnCreatedAt = 'created_at';

  Database _database;

  Future<Database> get database async {
    if (_database != null) return _database;
    _database = await _initDB();
    return _database;
  }

  Future<Database> _initDB() async {
    String path = join(await getDatabasesPath(), 'memo.db');

    return await openDatabase(
      path,
      version: 1,
      onCreate: _createTable,
    );
  }

  Future<void> _createTable(Database db, int version) async {
    String sql = '''
      CREATE TABLE $_tableName(
        $_columnId TEXT PRIMARY KEY,
        $_columnTitle TEXT,
        $_columnContent TEXT,
        $_columnCreatedAt TEXT
      )
    ''';

    return await db.execute(sql);
  }

  Future<List<Memo>> loadAllMemo() async {
    final db = await database;
    var maps = await db.query(
      _tableName,
      orderBy: '$_columnCreatedAt DESC',
    );

    if (maps.isEmpty) return [];

    return maps.map((map) => fromMap(map)).toList();
  }

  Future<List<Memo>> search(String keyword) async {
    final db = await database;
    var maps = await db.query(
      _tableName,
      orderBy: '$_columnCreatedAt DESC',
      where: '$_columnTitle LIKE ?',
      whereArgs: ['%$keyword%'],
    );

    if (maps.isEmpty) return [];

    return maps.map((map) => fromMap(map)).toList();
  }

  Future insert(Memo memo) async {
    final db = await database;
    return await db.insert(_tableName, toMap(memo));
  }

  Future update(Memo memo) async {
    final db = await database;
    return await db.update(
      _tableName,
      toMap(memo),
      where: '$_columnId = ?',
      whereArgs: [memo.id],
    );
  }

  Future delete(Memo memo) async {
    final db = await database;
    return await db.delete(
      _tableName,
      where: '$_columnId = ?',
      whereArgs: [memo.id],
    );
  }

  Map<String, dynamic> toMap(Memo memo) {
    return {
      _columnId: memo.id,
      _columnTitle: memo.title,
      _columnContent: memo.content,
      _columnCreatedAt: memo.createdAt.toUtc().toIso8601String()
    };
  }

  Memo fromMap(Map<String, dynamic> json) {
    return Memo(
      id: json[_columnId],
      title: json[_columnTitle],
      content: json[_columnContent],
      createdAt: DateTime.parse(json[_columnCreatedAt]).toLocal(),
    );
  }
}
```

```memo.dart
import 'package:intl/intl.dart';

class Memo {
  String id;
  String title;
  String content;
  DateTime createdAt;

  String getContent() {
    String cont = content.replaceAll('\n', ' ');
    if (cont.length <= 10) return cont;
    return '${cont.substring(0, 10)}...';
  }

  String getCreatedAt() {
    try {
      // 曜日を表示したいときは「'yyyy/MM/dd（E） HH:mm:ss'」
      var fomatter = DateFormat('yyyy/MM/dd HH:mm:ss', 'ja_JP');
      return fomatter.format(createdAt);
    } catch (e) {
      print(e);
      return '';
    }
  }

  Memo({
    this.id,
    this.title,
    this.content,
    this.createdAt,
  });
}
```

```memo_repository.dart
import 'package:search_bar_sample_app/model/db/app_database.dart';
import 'package:search_bar_sample_app/model/entity/memo.dart';

class MemoRepository {
  final AppDatabase _appDatabase;

  MemoRepository(this._appDatabase);

  Future<List<Memo>> loadAllMemo() => _appDatabase.loadAllMemo();

  Future<List<Memo>> search(String keyword) => _appDatabase.search(keyword);

  Future insert(Memo memo) => _appDatabase.insert(memo);

  Future update(Memo memo) => _appDatabase.update(memo);

  Future delete(Memo memo) => _appDatabase.delete(memo);
}
```

## ui

次は「メモ一覧画面」「メモ登録・編集・削除画面」を作成します。

```main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:search_bar_sample_app/ui/memo_list/memo_list.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Search Bar App',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      routes: <String, WidgetBuilder>{
        '/': (_) => MemoList(),
      },
      localizationsDelegates: [
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      supportedLocales: [
        const Locale('ja'),
      ],
    );
  }
}
```

### メモ一覧画面

```memo_list.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:search_bar_sample_app/ui/memo_detail/memo_detail.dart';
import 'package:search_bar_sample_app/ui/memo_list/memo_list_view_model.dart';
import 'package:search_bar_sample_app/model/db/app_database.dart';
import 'package:search_bar_sample_app/model/entity/memo.dart';
import 'package:search_bar_sample_app/model/repository/memo_repository.dart';

class MemoList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final vm = MemoListViewModel(MemoRepository(AppDatabase()));
    final page = _MemoListPage();
    return ChangeNotifierProvider(
      create: (_) => vm,
      child: Scaffold(
        // AppBarにTextFieldを配置することで、検索バーになる
        appBar: AppBar(
          title: TextField(
            style: const TextStyle(color: Colors.white),
            decoration: InputDecoration(
              prefixIcon: Icon(Icons.search, color: Colors.white),
              hintText: 'タイトルを検索',
              hintStyle: const TextStyle(color: Colors.white),
            ),
            onChanged: (value) => vm.search(value),
          ),
        ),
        backgroundColor: Color(0xffF2F2F2),
        body: page,
        floatingActionButton: FloatingActionButton(
          child: Icon(Icons.add),
	  // メモ登録画面に遷移する
          onPressed: () => page.goToMemoDetailScreen(context, null),
        ),
      ),
    );
  }
}

class _MemoListPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final vm = Provider.of<MemoListViewModel>(context);

    if (vm.isLoading) {
      return const Center(child: CircularProgressIndicator());
    }

    if (vm.memos.isEmpty) {
      return const Center(child: const Text('メモが登録されていません'));
    }

    return ListView.builder(
      itemCount: vm.memos.length,
      itemBuilder: (BuildContext context, int index) {
        var memo = vm.memos[index];
        return _buildMemoListTile(context, memo);
      },
    );
  }

  Widget _buildMemoListTile(BuildContext context, Memo memo) {
    return Card(
      child: ListTile(
        title: Text(
          memo.title,
          style: TextStyle(fontWeight: FontWeight.bold),
        ),
        subtitle: Text(memo.getContent()),
        trailing: Text(memo.getCreatedAt()),
	// メモ編集・削除画面に遷移する
        onTap: () => goToMemoDetailScreen(context, memo),
      ),
    );
  }

  void goToMemoDetailScreen(BuildContext context, Memo memo) {
    var route = MaterialPageRoute(
      settings: RouteSettings(name: '/ui.memo_detail'),
      builder: (BuildContext context) => MemoDetail(memo),
    );
    Navigator.push(context, route);
  }
}
```

```memo_list_view_model.dart
import 'package:flutter/material.dart';
import 'package:search_bar_sample_app/model/entity/memo.dart';
import 'package:search_bar_sample_app/model/repository/memo_repository.dart';

class MemoListViewModel extends ChangeNotifier {
  final MemoRepository _repository;

  MemoListViewModel(this._repository) {
    loadAllMemo();
  }

  List<Memo> _memos = [];

  List<Memo> get memos => _memos;

  bool _isLoading = false;

  bool get isLoading => _isLoading;

  void loadAllMemo() async {
    _startLoading();
    _memos = await _repository.loadAllMemo();
    _finishLoading();
  }

  void search(String keyword) async {
    _startLoading();
    _memos = await _repository.search(keyword);
    _finishLoading();
  }

  void _startLoading() {
    _isLoading = true;
    notifyListeners();
  }

  void _finishLoading() {
    _isLoading = false;
    notifyListeners();
  }
}
```

### メモ登録・編集・削除画面

```memo_detail.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:search_bar_sample_app/model/db/app_database.dart';
import 'package:search_bar_sample_app/model/entity/memo.dart';
import 'package:search_bar_sample_app/model/repository/memo_repository.dart';
import 'package:search_bar_sample_app/ui/memo_detail/memo_detail_view_model.dart';

class MemoDetail extends StatelessWidget {
  final Memo _memo;

  MemoDetail(this._memo);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => MemoDetailViewModel(_memo, MemoRepository(AppDatabase())),
      child: _MemoDetailPage(),
    );
  }
}

class _MemoDetailPage extends StatelessWidget {
  final GlobalKey<FormState> _globalKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    final vm = Provider.of<MemoDetailViewModel>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text(vm.isNew ? '登録' : '編集'),
        actions: [
          IconButton(
            icon: Icon(Icons.save),
            onPressed: () => _showSaveOrUpdateDialog(context),
          ),
          IconButton(
            icon: Icon(Icons.delete),
            onPressed: vm.isNew ? null : () => _showDeleteDialog(context),
          ),
        ],
      ),
      body: SafeArea(
        child: Form(
          key: _globalKey,
          child: ListView(
            padding: EdgeInsets.all(15),
            children: [
              TextFormField(
                decoration: const InputDecoration(labelText: 'タイトル'),
                initialValue: vm.isNew ? '' : vm.memo.title,
                validator: (value) => (value.isEmpty) ? 'タイトルを入力して下さい' : null,
                onChanged: (value) => vm.setTitle(value),
              ),
              Padding(
                padding: EdgeInsets.only(top: 20),
                child: TextFormField(
                  decoration: InputDecoration(labelText: 'メモ'),
                  keyboardType: TextInputType.multiline,
                  maxLines: null,
                  initialValue: vm.isNew ? '' : vm.memo.content,
                  onChanged: (value) => vm.setContent(value),
                ),
              ),
              Padding(
                padding: EdgeInsets.only(top: 20),
                child: Align(
                  alignment: Alignment.centerRight,
                  child: Text('${vm.contentCounts} 文字'),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  void _showSaveOrUpdateDialog(BuildContext context) {
    if (!_globalKey.currentState.validate()) return;

    var vm = Provider.of<MemoDetailViewModel>(context, listen: false);

    bool isNew = vm.isNew;

    String saveOrUpdateText = (isNew ? '保存' : '更新');

    showDialog(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          content: Text('メモを$saveOrUpdateTextしますか？'),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: const Text('キャンセル'),
            ),
            TextButton(
              onPressed: () =>
                  isNew ? _save(context, vm) : _update(context, vm),
              child: Text(saveOrUpdateText),
            ),
          ],
        );
      },
    );
  }

  void _showDeleteDialog(BuildContext context) {
    var vm = Provider.of<MemoDetailViewModel>(context, listen: false);

    showDialog(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          content: const Text('メモを削除しますか？'),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: const Text('キャンセル'),
            ),
            TextButton(
              onPressed: () => _delete(context, vm),
              child: const Text('削除'),
            ),
          ],
        );
      },
    );
  }

  void _save(BuildContext context, MemoDetailViewModel vm) async {
    _showIndicator(context);
    await vm.save();
    _goToMemoListScreen(context);
  }

  void _update(BuildContext context, MemoDetailViewModel vm) async {
    _showIndicator(context);
    await vm.update();
    _goToMemoListScreen(context);
  }

  void _delete(BuildContext context, MemoDetailViewModel vm) async {
    _showIndicator(context);
    await vm.delete();
    _goToMemoListScreen(context);
  }

  void _showIndicator(BuildContext context) {
    showGeneralDialog(
      context: context,
      barrierDismissible: false,
      transitionDuration: Duration(milliseconds: 300),
      barrierColor: Colors.black.withOpacity(0.5),
      pageBuilder: (
        BuildContext context,
        Animation animation,
        Animation secondaryAnimation,
      ) {
        return Center(child: CircularProgressIndicator());
      },
    );
  }

  void _goToMemoListScreen(BuildContext context) {
    Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false);
  }
}
```

```memo_detail_view_model.dart
import 'package:flutter/material.dart';
import 'package:search_bar_sample_app/model/entity/memo.dart';
import 'package:search_bar_sample_app/model/repository/memo_repository.dart';
import 'package:uuid/uuid.dart';

class MemoDetailViewModel extends ChangeNotifier {
  final MemoRepository _repository;

  MemoDetailViewModel(memo, this._repository) {
    _memo = memo ?? initMemo();
    _isNew = (memo == null);
    _contentCounts = _memo.content.length;
    notifyListeners();
  }

  Memo _memo;

  Memo get memo => _memo;

  bool _isNew;

  bool get isNew => _isNew;

  int _contentCounts = 0;

  int get contentCounts => _contentCounts;

  Memo initMemo() {
    return Memo(
      id: Uuid().v4(),
      title: '',
      content: '',
      createdAt: null
    );
  }

  void setTitle(String title) {
    _memo.title = title;
    notifyListeners();
  }

  void setContent(String content) {
    _memo.content = content;
    _contentCounts = content.length;
    notifyListeners();
  }

  Future save() async {
    _memo.createdAt = DateTime.now();
    return await _repository.insert(_memo);
  }

  Future update() async {
    _memo.createdAt = DateTime.now();
    return await _repository.update(_memo);
  }

  Future delete() async => _repository.delete(_memo);
}
```