LoginSignup
5
4

More than 3 years have passed since last update.

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

Posted at

作成するアプリ

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

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

メモ一覧画面

検索バーを使ったタイトル検索

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

実装

  • 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.dartmemo.dartmemo_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);
}
5
4
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
5
4