Last active
August 24, 2022 20:06
-
-
Save 3t14/99951d954ff50bfa217ec6eea9fb5633 to your computer and use it in GitHub Desktop.
Master-Detail型簡易メモ帳アプリ(SQLite対応版)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// メモデータに関するモデル処理 | |
import 'package:flutter/foundation.dart'; | |
import 'package:path/path.dart'; | |
import 'package:sqflite/sqflite.dart'; | |
// 1件分のメモデータ | |
class Item { | |
final int id; | |
final String title; | |
final String memo; | |
// idを時刻データにする | |
const Item({this.id = -1, required this.title, required this.memo}); | |
//Map型への変換 | |
Map<String, Object?> toMap() { | |
return { | |
'id': id, | |
'title': title, | |
'memo': memo, | |
}; | |
} | |
} | |
// 全データ | |
List<Item> items = <Item>[ | |
// const Item( | |
// id: 0, | |
// title: 'Item 1', | |
// memo: 'This is the first item.', | |
// ), | |
// const Item( | |
// id: 1, | |
// title: 'Item 2', | |
// memo: 'This is the second item.', | |
// ), | |
// const Item( | |
// id: 2, | |
// title: 'Item 3', | |
// memo: 'This is the third item.', | |
// ), | |
]; | |
Database? _database; | |
// データベースを開く | |
Future<Database> getDb() async { | |
// 未代入のときは取得する | |
if (_database == null) { | |
var databasesPath = await getDatabasesPath(); | |
var path = join(databasesPath, 'memos.db'); | |
if (kDebugMode) print("path = $path"); | |
_database = await openDatabase( | |
path, // データベースファイルのパス | |
onCreate: (db, version) { | |
// データベースファイルがない場合は作成する | |
// テーブルmp作成 | |
db.execute( | |
'CREATE TABLE memos (' | |
'id INTEGER PRIMARY KEY, ' | |
'title TEXT, ' | |
'memo TEXT' | |
')', | |
); | |
}, | |
// データベースファイルのバージョン | |
version: 1, | |
); | |
} | |
return _database!; | |
} | |
bool get isDBReady => _database != null; | |
// 最初のデータベースの読み込み | |
initItems(initItemsCallback) async { | |
if (isDBReady) return; | |
await getItems(); | |
initItemsCallback(); | |
} | |
// 全データの取得 | |
Future<List<Item>> getItems() async { | |
print('getItems'); | |
final db = await getDb(); | |
final List<Map<String, dynamic>> maps = await db.query('memos'); | |
items = List.generate(maps.length, (i) { | |
return Item( | |
id: maps[i]['id'], | |
title: maps[i]['title'], | |
memo: maps[i]['memo'], | |
); | |
}); | |
return items; | |
} | |
// メモの追加 | |
Future<void> addItem(Item item) async { | |
items.add(item); | |
final db = await getDb(); | |
await db.insert('memos', item.toMap()); | |
} | |
// メモの更新 | |
Future<void> updateItem(Item item) async { | |
var id = item.id; | |
var index = items.indexWhere((item) => item.id == id); | |
// 存在しない場合例外発生 | |
if (index == -1) throw Exception('Item not found: $item'); | |
// 置き換え | |
items[index] = item; | |
// データベース側の更新は非同期で行う | |
final db = await getDb(); | |
await db.update( | |
'memos', | |
item.toMap(), | |
where: "id = ?", | |
whereArgs: [item.id], | |
); | |
} | |
// メモの削除 | |
Future<void> removeItem(Item item) async { | |
// idが一致するindexを調べ削除する | |
var id = item.id; | |
var index = items.indexWhere((item) => item.id == id); | |
// 存在しない場合例外発生 | |
if (index == -1) throw Exception('Item not found: $item'); | |
// 削除 | |
items.removeAt(index); | |
// データベース側の更新は非同期で行う | |
final db = await getDb(); | |
await db.delete( | |
'memos', | |
where: "id = ?", | |
whereArgs: [item.id], | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// メモデータの詳細表示、編集フォーム | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'item.dart'; | |
// ウィジェットのモード | |
enum ItemDetailsMode { | |
view, // 表示モード | |
add, // 追加モード | |
edit, // 編集モード | |
} | |
const emptyItem = Item( | |
title: '', | |
memo: '', | |
); | |
class ItemDetails extends StatefulWidget { | |
final bool isInTabletLayout; | |
final ItemDetailsMode mode; // 表示モード or 編集モード | |
final Item? initItem; // 生成時の初期メモデータ | |
final ValueChanged<Item>? onEdit; // 編集モードのコールバック | |
final ValueChanged<Item>? onCancel; // 編集キャンセル時のコールバック | |
final ValueChanged<Item>? onSave; // 登録・保存時のコールバック | |
final ValueChanged<Item>? onDelete; // 削除時のコールバック | |
const ItemDetails({ | |
Key? key, | |
required this.isInTabletLayout, | |
required this.mode, | |
// メモデータの初期状態 | |
this.initItem = emptyItem, | |
this.onEdit, | |
this.onCancel, | |
this.onSave, | |
this.onDelete, | |
}) : super(key: key); | |
@override | |
_ItemDetailsState createState() => _ItemDetailsState(); | |
} | |
class _ItemDetailsState extends State<ItemDetails> { | |
var _title = ""; | |
var _memo = ""; | |
var _id = -1; | |
@override | |
void initState() { | |
super.initState(); | |
initData(); | |
} | |
Item _item = emptyItem; | |
// データの初期化 | |
void initData() { | |
var initItem = widget.initItem; | |
initItem ??= emptyItem; // initItemがnullの場合はemptyItemを使用する | |
_title = initItem.title; | |
_memo =initItem.memo; | |
_id = initItem.id; | |
regenerateItem(); | |
} | |
// データを再生成する | |
void regenerateItem() { | |
_item = Item( | |
id: _id, | |
title: _title, | |
memo: _memo, | |
); | |
} | |
@override | |
Widget build(BuildContext context) { | |
if (kDebugMode) print("build ${_id}, ${_title}, ${_memo}"); | |
final textTheme = Theme.of(context).textTheme; | |
const textFormFieldDecoration = InputDecoration( | |
filled: true, | |
fillColor: Colors.white, | |
border: OutlineInputBorder(), | |
); | |
var children = items.isNotEmpty || widget.mode == ItemDetailsMode.add | |
? [ | |
Text( | |
"タイトル", | |
style: textTheme.caption, | |
), | |
widget.mode == ItemDetailsMode.view | |
? SelectableText( | |
_title, | |
style: textTheme.headlineSmall, | |
) | |
: TextFormField( | |
initialValue: _title, | |
style: textTheme.bodyMedium, | |
decoration: textFormFieldDecoration, | |
onChanged: (value) { | |
setState(() => _title = value); | |
regenerateItem(); | |
}, | |
), | |
const SizedBox(height: 10), | |
Text( | |
"メモ", | |
style: textTheme.caption, | |
), | |
widget.mode == ItemDetailsMode.view | |
? SelectableText( | |
_memo, | |
style: textTheme.subtitle1, | |
) | |
: TextFormField( | |
initialValue: _memo, | |
style: textTheme.bodyMedium, | |
decoration: textFormFieldDecoration, | |
keyboardType: TextInputType.multiline, | |
maxLines: null, | |
onChanged: (value) { | |
setState(() => _memo = value); | |
regenerateItem(); | |
}, | |
), | |
const SizedBox(height: 48), | |
] | |
: [ | |
Text( | |
"+ボタンをクリックしてメモを追加してください。", | |
style: textTheme.caption, | |
) | |
]; | |
var cancelButton = ElevatedButton( | |
child: const Text('キャンセル'), | |
onPressed: () { | |
if (widget.onCancel != null) widget.onCancel!(_item); | |
}, | |
); | |
if (widget.mode == ItemDetailsMode.view && items.isNotEmpty) { | |
children.add( | |
Row( | |
mainAxisAlignment: MainAxisAlignment.spaceEvenly, | |
children: [ | |
ElevatedButton( | |
child: const Text( | |
'削除', | |
), | |
onPressed: () { | |
if (widget.onDelete != null) widget.onDelete!(_item); | |
}, | |
style: ElevatedButton.styleFrom( | |
primary: Colors.red, | |
), | |
), | |
ElevatedButton( | |
child: const Text('編集'), | |
onPressed: () { | |
if (widget.onEdit != null) widget.onEdit!(_item); | |
}, | |
), | |
], | |
), | |
); | |
} else if (widget.mode == ItemDetailsMode.edit) { | |
children.add(Row( | |
mainAxisAlignment: MainAxisAlignment.spaceEvenly, | |
children: <Widget>[ | |
cancelButton, | |
ElevatedButton( | |
child: const Text('保存'), | |
onPressed: () { | |
print("save memo: ${_item.memo}"); | |
if (widget.onSave != null) widget.onSave!(_item); | |
}, | |
), | |
], | |
)); | |
} else if (widget.mode == ItemDetailsMode.add) { | |
children.add(Row( | |
mainAxisAlignment: MainAxisAlignment.spaceEvenly, | |
children: <Widget>[ | |
cancelButton, | |
ElevatedButton( | |
child: const Text('新規登録'), | |
onPressed: () { | |
if (widget.onSave != null) widget.onSave!(_item); | |
}, | |
), | |
], | |
)); | |
} | |
final body = SafeArea( | |
child: Padding( | |
padding: widget.isInTabletLayout | |
? const EdgeInsets.symmetric(horizontal: 72, vertical: 48) | |
: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: children, | |
), | |
), | |
); | |
return widget.isInTabletLayout | |
? body | |
: Scaffold( | |
appBar: AppBar( | |
title: const Text("メモ詳細"), | |
), | |
backgroundColor: Colors.yellow, | |
body: body, | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// メモデータのリスト表示 | |
import 'dart:math'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:hello_flutter/item_details.dart'; | |
import 'item.dart'; | |
class ItemListing extends StatelessWidget { | |
const ItemListing({ | |
Key? key, | |
required this.itemSelectedCallback, | |
this.selectedItem, | |
}): super(key: key); | |
final ValueChanged<Item> itemSelectedCallback; | |
final Item? selectedItem; | |
@override | |
Widget build(BuildContext context) { | |
if (kDebugMode) print('ItemListing.build() ${selectedItem?.id}, $key'); | |
return ListView.builder( | |
itemCount: items.length, | |
itemBuilder: (BuildContext context, int index) { | |
final item = items.isNotEmpty ? items[index]: emptyItem; | |
return ListTile( | |
leading: const Icon( | |
Icons.edit_note, | |
color: Colors.yellow, | |
size: 24.0, | |
semanticLabel: 'Text to announce in accessibility modes', | |
), | |
title: Text(item.title), | |
subtitle: Text(item.memo.substring(0, min(20, item.memo.length))), | |
selected: item.id == selectedItem?.id, | |
onTap: () { | |
itemSelectedCallback(item); | |
}, | |
tileColor: Colors.lightBlueAccent, | |
selectedTileColor: Colors.amberAccent, | |
); | |
}, | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// エントリーポイント | |
import 'package:flutter/material.dart'; | |
import 'master_detail_container.dart'; | |
void main() { | |
runApp(const MemoApp()); | |
} | |
class MemoApp extends StatelessWidget { | |
const MemoApp({Key? key}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
title: '簡易メモアプリ', | |
theme: ThemeData( | |
primarySwatch: Colors.blue, | |
), | |
home: const MasterDetailContainer(), | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// モバイル、タブレット双方対応のためのMaster-Detailコンテナー | |
import 'package:flutter/foundation.dart'; | |
import 'item.dart'; | |
import 'item_details.dart'; | |
import 'item_listing.dart'; | |
import 'package:flutter/material.dart'; | |
class MasterDetailContainer extends StatefulWidget { | |
const MasterDetailContainer({Key? key}) : super(key: key); | |
@override | |
_ItemMasterDetailContainerState createState() => | |
_ItemMasterDetailContainerState(); | |
} | |
class _ItemMasterDetailContainerState extends State<MasterDetailContainer> { | |
static const int kTabletBreakpoint = 600; | |
Item? _selectedItem = items.isNotEmpty ? items[0] : null; | |
ItemDetailsMode _mode = ItemDetailsMode.view; | |
// モバイル端末か否かを判定する | |
bool get isInMobileLayout => | |
MediaQuery.of(context).size.shortestSide < kTabletBreakpoint; | |
// 詳細画面ウィジェットの生成 | |
Widget get itemDetails => ItemDetails( | |
key: ValueKey(_selectedItem!.id), // idごとにItemDetailsを更新する | |
isInTabletLayout: !isInMobileLayout, | |
mode: _mode, | |
initItem: _selectedItem, | |
onEdit: edit, | |
onCancel: cancel, | |
onSave: save, | |
onDelete: delete, | |
); | |
// 編集フォームの表示 | |
void edit(Item item) { | |
setState(() { | |
_mode = ItemDetailsMode.edit; | |
_selectedItem = item; | |
}); | |
modeChange(_mode); | |
} | |
// 編集をキャンセル | |
void cancel(Item item) { | |
if (_mode == ItemDetailsMode.add && isInMobileLayout) { | |
Navigator.of(context).pop(); | |
} | |
if (_mode == ItemDetailsMode.edit) { | |
modeChange(_mode); | |
} | |
setState(() { | |
_mode = ItemDetailsMode.view; | |
}); | |
} | |
void save(Item item) { | |
if (kDebugMode) print("save ${item.title}"); | |
if (_mode == ItemDetailsMode.add) { | |
// idは重複しないようにタイムスタンプを利用 | |
var newItem = Item( | |
id: DateTime.now().microsecondsSinceEpoch, | |
title: item.title, | |
memo: item.memo); | |
addItem(newItem); | |
item = newItem; | |
} else if (_selectedItem != null) { | |
updateItem(item); | |
} | |
setState(() { | |
_mode = ItemDetailsMode.view; | |
_selectedItem = item; | |
}); | |
modeChange(_mode); | |
} | |
// 削除 | |
void delete(Item item) { | |
try { | |
removeItem(item); | |
} catch (e) { | |
// 対象のレコードが配列に存在しない | |
if (kDebugMode) print(e); | |
} | |
if (items.isNotEmpty) { | |
setState(() { | |
_selectedItem = items[0]; | |
}); | |
} else { | |
setState(() { | |
_selectedItem = emptyItem; | |
}); | |
} | |
// モバイル画面の場合は一覧表示画面に遷移する | |
if (isInMobileLayout) { | |
Navigator.of(context).pop(); | |
} | |
} | |
// モードの変更 | |
modeChange(ItemDetailsMode mode) async { | |
if (kDebugMode) print('updateMode: $_mode to $mode'); | |
setState(() { | |
_mode = mode; | |
}); | |
if (isInMobileLayout) { | |
await Navigator.of(context).pushReplacement( | |
PageRouteBuilder( | |
pageBuilder: (context, animation1, animation2) => itemDetails, | |
transitionDuration: const Duration(seconds: 0), | |
), | |
); | |
} | |
} | |
Widget _buildMobileLayout() { | |
return ItemListing(itemSelectedCallback: (item) async { | |
setState(() { | |
_selectedItem = item; | |
_mode = ItemDetailsMode.view; | |
}); | |
await Navigator.of(context).push( | |
MaterialPageRoute( | |
builder: (BuildContext context) => itemDetails, | |
), | |
); | |
}); | |
} | |
Widget _buildTabletLayout() { | |
return Row( | |
children: <Widget>[ | |
Flexible( | |
flex: 1, | |
child: Material( | |
elevation: 4.0, | |
child: ItemListing( | |
itemSelectedCallback: (item) { | |
print("itemSelectedCallback: ${item.title}"); | |
setState(() { | |
_selectedItem = item; | |
_mode = ItemDetailsMode.view; | |
}); | |
}, | |
selectedItem: _selectedItem, | |
), | |
), | |
), | |
Flexible( | |
flex: 3, | |
child: itemDetails, | |
), | |
], | |
); | |
} | |
// DBから読み込みとその反映 | |
initItemsCallback() { | |
setState((){ | |
_selectedItem = items.isNotEmpty ? items[0] : emptyItem; | |
}); | |
if (kDebugMode) print("_selectedItem: ${_selectedItem!.id}"); | |
} | |
@override | |
Widget build(BuildContext context) { | |
// 初めてのアクセスの場合、DBからデータを非同期で読み込む | |
if (!isDBReady) initItems(initItemsCallback); | |
Widget content; | |
if (isInMobileLayout) { | |
content = _buildMobileLayout(); | |
} else { | |
content = _buildTabletLayout(); | |
} | |
var newItem = const Item( | |
title: '', | |
memo: '', | |
); | |
return Scaffold( | |
resizeToAvoidBottomInset: false, | |
appBar: AppBar( | |
title: const Text('簡易メモ帳'), | |
), | |
body: SingleChildScrollView( | |
child:SizedBox( | |
height: MediaQuery.of(context).size.height, | |
child:content, | |
), | |
scrollDirection: Axis.vertical | |
), | |
backgroundColor: Colors.yellow, | |
floatingActionButton: FloatingActionButton( | |
child: const Icon(Icons.add), | |
onPressed: () { | |
setState(() { | |
_selectedItem = newItem; | |
_mode = ItemDetailsMode.add; | |
}); | |
if (isInMobileLayout) { | |
Navigator.of(context).push( | |
MaterialPageRoute(builder: (BuildContext context) => itemDetails), | |
); | |
} | |
}, | |
), | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment