🦋

【Flutter】Riverpod / MVVM / SQLite で「メモ検索アプリ」をつくる

2023/11/10に公開

最近 Riverpod を触り始めたので、以前作成した メモ検索アプリRiverpod に置き換えてみました。

GitHub : yass97/flutter_memo_app

スクリーンショット

メモ一覧 メモ登録・編集 メモ検索

環境

Flutter 3.13.9 • channel stable • https://github.com/flutter/flutter.git
Framework • revision d211f42860 (2 weeks ago) • 2023-10-25 13:42:25 -0700
Engine • revision 0545f8705d
Tools • Dart 3.1.5 • DevTools 2.25.0

pubspec.yaml

dependencies:
  ...
  flutter_riverpod: ^2.4.5
  riverpod_annotation: ^2.3.0
  hooks_riverpod: ^2.4.5
  flutter_hooks: ^0.20.3

dev_dependencies:
  ...
  build_runner: ^2.4.6
  riverpod_generator: ^2.3.5

実装(例: メモ一覧)

app_database.dart

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

part 'app_database.g.dart';


AppDatabase appDatabase(AppDatabaseRef ref) => AppDatabase();

class AppDatabase {
  Database? _db;

  Future<Database> get db async {
    if (_db != null) return _db!;
    _db = await _init();
    return _db!;
  }

  final queries = [
    [
      'CREATE TABLE Memos(id TEXT PRIMARY KEY, title TEXT, description TEXT, created_at TEXT, updated_at TEXT)'
    ]
  ];

  Future<Database> _init() async {
    final path = join(await getDatabasesPath(), 'memo.db');
    return await openDatabase(
      path,
      version: queries.length,
      onCreate: (db, version) async {
        for (final query in queries.first) {
          await db.execute(query);
        }
      },
      onUpgrade: (Database db, int oldVersion, int newVersion) async {
        for (var i = oldVersion; i < newVersion; i++) {
          for (final query in queries[i]) {
            await db.execute(query);
          }
        }
      },
    );
  }
}

memo_dao.dart

import 'package:flutter_memo_app/data/local/app_database.dart';
import 'package:flutter_memo_app/data/entity/memo.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'memo_dao.g.dart';


MemoDao memoDao(MemoDaoRef ref) => MemoDao(ref.read(appDatabaseProvider));

class MemoDao {
  const MemoDao(this._database);

  final AppDatabase _database;

  Future<List<Memo>> loadAll() async {
    final db = await _database.db;
    final data = await db.query('Memos', orderBy: 'updated_at DESC');
    return data.isEmpty ? [] : data.map((e) => Memo.fromJson(e)).toList();
  }

  Future<List<Memo>> search(String keyword) async {
    final db = await _database.db;
    final data = await db.query(
      'Memos',
      where: 'title LIKE ?',
      whereArgs: ['%$keyword%'],
      orderBy: 'updated_at DESC',
    );
    return data.isEmpty ? [] : data.map((e) => Memo.fromJson(e)).toList();
  }

  ...
}

memo_repository.dart

import 'package:flutter_memo_app/data/entity/memo.dart';
import 'package:flutter_memo_app/data/local/memo_dao.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'memo_repository.g.dart';


MemoRepository memoRepository(MemoRepositoryRef ref) {
  return MemoRepository(ref.read(memoDaoProvider));
}

class MemoRepository {
  const MemoRepository(this._dao);

  final MemoDao _dao;

  Future<List<Memo>> loadAll() async {
    return _dao.loadAll();
  }

  Future<List<Memo>> search(String keyword) async {
    return _dao.search(keyword);
  }
  
  ...
}

memo_list_viewmodel.dart

import 'package:flutter_memo_app/data/entity/memo.dart';
import 'package:flutter_memo_app/data/repository/memo_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'memo_list_viewmodel.g.dart';


class MemoListViewModel extends _$MemoListViewModel {
  
  List<Memo> build() => [];

  Future<void> loadAll() async {
    state = await ref.read(memoRepositoryProvider).loadAll();
  }

  Future<void> search(String keyword) async {
    state = await ref.read(memoRepositoryProvider).search(keyword);
  }

  void add(Memo memo) => state = <Memo>[memo, ...state];

  void update(Memo memo) {
    state = state.map((m) => (m.id == memo.id) ? memo : m).toList();
  }

  void delete(String id) {
    state = state.where((m) => m.id != id).toList();
  }
}

memo_list_screen.dart

class MemoListScreen extends HookConsumerWidget {
  const MemoListScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final memos = ref.watch(memoListViewModelProvider);
    final isLoading = useState<bool>(true);

    useEffect(
      () {
        final notifier = ref.read(memoListViewModelProvider.notifier);
        notifier.loadAll().then((_) => isLoading.value = false);
        return null;
      },
      const [],
    );

    return Scaffold(
      appBar: AppBar(
        title: TextField(
          style: const TextStyle(
            color: Colors.white,
          ),
          cursorColor: Colors.white,
          decoration: const InputDecoration(
            prefixIcon: Icon(
              Icons.search,
              color: Colors.white,
            ),
            hintText: 'Title',
            hintStyle: TextStyle(color: Colors.white),
            border: InputBorder.none,
          ),
          onChanged: (keyword) async {
            isLoading.value = true;
            await ref.read(memoListViewModelProvider.notifier).search(keyword);
            isLoading.value = false;
          },
        ),
      ),
      body: isLoading.value
          ? const Center(child: CircularProgressIndicator())
          : memos.isEmpty
              ? const Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Icon(Icons.sticky_note_2_outlined, size: 42),
                      SizedBox(height: 10),
                      Text('No memo...')
                    ],
                  ),
                )
              : ListView.separated(
                  separatorBuilder: (context, index) {
                    return const Divider(height: 1);
                  },
                  itemCount: memos.length,
                  itemBuilder: (context, index) {
                    final memo = memos[index];
                    return InkWell(
                      onTap: () {
                        Navigator.of(context).push(MemoEditScreen.route(memo));
                      },
                      child: Padding(
                        padding: const EdgeInsets.symmetric(horizontal: 12),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            const SizedBox(height: 10),
                            Text(
                              memo.title,
                              style: const TextStyle(
                                fontSize: 16,
                                fontWeight: FontWeight.bold,
                              ),
                              overflow: TextOverflow.ellipsis,
                            ),
                            const SizedBox(height: 8),
                            Text(
                              memo.description,
                              style: const TextStyle(
                                color: Colors.grey,
                                fontSize: 12,
                              ),
                              overflow: TextOverflow.ellipsis,
                              maxLines: 2,
                            ),
                            const SizedBox(height: 12),
                            Align(
                              alignment: Alignment.centerRight,
                              child: Text(
                                memo.updatedAt.format(),
                                style: const TextStyle(fontSize: 12),
                              ),
                            ),
                            const SizedBox(height: 10),
                          ],
                        ),
                      ),
                    );
                  },
                ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () async {
          final route = MemoRegistrationScreen.route();
          Navigator.of(context).push(route);
        },
      ),
    );
  }
}

Discussion