前にFlutterの環境作ってから、その後何にも進んでいないんだ。本当は何度かチャレンジしたんだよ。でも何度やってもうまくいかねー。内緒だけどね。
気を取り直して。いかにもちょろく出来たふりして。
改めて、やるぜ!Flutter!
今回参考にさせていただいたサイトは下記の通りです。多謝!
ちゃんと動くサンプルがなかなか見つけられなかったよ
【Flutter】簡単なToDoアプリの作成手順
1.今回作るアプリの概要
普通に一覧から詳細に遷移する画面を作るよ。照会も編集も同じ画面でやっちゃうよ。
こんなイメージ。
なんか枠がつくとすでにできてる感満載になるよね。
ちなみにアイコンの登録はまた今度。今回は画像は置いておいて、データを表示するところを中心に考えるよ。
2.テーブル設計
今回のテーブルは猫テーブル一択。こんな項目を考えるよ。
名前:猫の名前
性別:男の子、女の子、ないしょを選択できるようにする。
誕生日:年月だったり年月日だったりするので、とりあえずTEXT形式で保存しておく。
メモ:なんでも適当に入力できるエリアにする。
作成日:データ作成日。画面には表示しない。yyyymmdd hhmmssくらいは取っておきたい。
あと、いずれアイコンね。
3.DBはSQLiteを使うよ
とりあえず単体で使えれば良しとして、データはSQLiteに入れるようにするよ。
SQLiteの入れ方は、いろんなサイトで入れ方書いてあるからあんまりいらないかもしれないけど、入れたとこだけ書いとくね。
「dependences:」に「sqflite:」を追加すればOKだよ。
インデント見ちゃうから、「flutter:」と同じ高さで書いておいてね。
dependencies:
flutter:
sdk: flutter
sqflite:
sqfliteを追加してpubspec.yamlを保存すると、勝手にsqliteがインストールされる。はず。もしインストールされなかったら、コマンドで個別にインストールしてね。
4.システム構成
フォルダ構成はこんな感じにするよ。
lib ┬ main.dart // メイン
├ model ┬ cats.dart // catsテーブルのmodel
│ └ db_helper.dart // DBヘルパー。DB処理を集めた
└ view ┬ cat_detail_edit.dart // 更新画面
├ cat_detail.dart // 詳細画面
└ cat_list.dart // 一覧画面
5.こんな考え方で作ったよ
(1)どこでDBオープンするのさ
昔ながらのシステム屋さんだと、「DBやファイルって初期処理でオープンするよね。それってどこでやってるの?」って気持ちになるのさ。ググっても、どうもいまいち理解できないよ。
おいらはこんな風に理解したよ。
・「Future get database async {~}」をdb_helper.dartに書いておくことで、db_helperが呼ばれたとき必ずここで書いた処理が実行される。ここで「まだデータベースのインスタンスができていなかったら、DBをオープンする」って処理を書いておく。
↓
おいらはdb_helper.dartの中に、こんな感じで処理を書いたよ。パクリだけどね。
// databaseをオープンしてインスタンス化する
Future<Database> get database async {
return _database ??= await _initDB(); // 初回だったら_initDB()=DBオープンする
}
// データベースをオープンする
Future<Database> _initDB() async {
String path = join(await getDatabasesPath(), 'cats.db'); // cats.dbのパスを取得する
return await openDatabase(
path,
version: 1,
onCreate: _onCreate, // cats.dbがなかった時の処理を指定する(DBは勝手に作られる)
);
}
// データベースがなかった時の処理
Future _onCreate(Database database, int version) async {
//catsテーブルをcreateする
await database.execute('''
CREATE TABLE cats(
_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
gender TEXT,
birthday TEXT,
memo TEXT,
createdAt TEXT
)
''');
}
DBのオープンのところの処理はいろんなところに書いてあったから、より良いやり方は自力で調査だ。がんばれ。
(2)読んできたデータはどうやって一覧表示するの?
ググるとDBアクセスの仕方は書いてあるけど、それをどうやって使えばいいのさ?って思わない?僕ぁすげー思ったよ。そのまま写経すれば動くソースはあるけど、俺のやりたいこととはちぃーと違うんだよ、って感じ。
そこで僕ぁこんな風にしてみたね。
・「ListView」を使って一覧表示してみたよ。
ListViewでitemBuilderを設定すると、渡したデータで一覧を作ってくれるようだ。
// catsテーブルに登録されている全データを取ってくる
Future getCatsList() async {
~
//catsテーブルを全件読み込む
catList = await DbHelper.instance.selectAllCats();
~
}
ListView.builder( // 取得したcatsテーブル全件をリスト表示する
itemCount: catList.length, // 取得したデータの件数を取得
itemBuilder: (BuildContext context, int index) {
final cat = catList[index]; // 1件分の処理
~
}
全件のデータを入れたエリアをもらって、1件ずつ処理をしていく。その辺はほかの色んな言語とおんなじ感じだね。
(3)更新画面にはどうやって遷移させるの?
「Navigator.of(context).push」で指定された画面に飛ぶよ。
onTapはInkWellの中で定義しているよ。
onTap: () async { // cardをtapしたときの処理を設定
await Navigator.of(context).push( // ページ遷移をNavigatorで設定
MaterialPageRoute(
builder: (context) => CatDetail(id: cat.id!), // cardのデータの詳細を表示するcat_detail.dartへ遷移
),
);
getCatsList(); // データが更新されているかもしれないので、catsテーブル全件読み直し
},
ちなみに更新画面で更新したら、「Navigator.of(context).pop()」で前の画面に戻っているよ。
データを更新したら一覧の内容も変わるから、戻ってきたらデータ全件読み直しているよ。
(4)いたるところでnull saftyが効いてうざい
Dartはver2からnull saftyになったので、nullになるような変数設定がしてあるといちいちエラーになる。うざい。いいじゃん、nullで。
まあ、ぬるぽにひどい目にあった人がたくさんいて、nullなんかやめちまえってことになったんだろうから、ここはおとなしく従うよ。
僕ぁどこで初期化したらいいかわかんないような奴は、initStateで初期化してみたよ。
例えば更新画面で入力項目を初期設定するとか。
@override
void initState() {
super.initState();
id = widget.cats?.id ?? 0;
name = widget.cats?.name ?? '';
birthday = widget.cats?.birthday ?? '';
gender = widget.cats?.gender ?? '';
_selected = widget.cats?.gender ?? '不明';
memo = widget.cats?.memo ?? '';
createdAt = widget.cats?.createdAt ?? DateTime.now();
}
6.結局こんな風になったよ
最終的にはこんな感じになりました。
まだいろいろできていないこと多いけどね。
・画像を変更できない。今は固定で同じ絵を表示しているよ。
・BLOCとかproviderは敢えて使わないようにした。純粋にDB更新するところの処理を知りたかったから。
・削除や更新の確認メッセージは出してないよ。
・validationはほぼしてない。
ソース全体を乗っけとくね。ちなみに使ったflutter SDKのバージョンは2.8.1、dartは2.15.1だ。
・main処理。一覧画面を呼んでいるだけだよ。
import 'package:flutter/material.dart';
import 'package:flutter_crud/view/cat_list.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp( //初期画面の設定
title: '猫一覧',
theme: ThemeData(
primarySwatch: Colors.blue,
),
routes: <String, WidgetBuilder>{
'/': (_) => const CatList(), //cat_list.dartを呼び出し
},
);
}
}
・catsテーブルのmodel
import 'package:flutter_crud/model/db_helper.dart';
import 'package:intl/intl.dart';
// catsテーブルの定義
class Cats {
int? id;
String name;
String gender;
String birthday;
String memo;
DateTime createdAt;
Cats({
this.id,
required this.name,
required this.gender,
required this.birthday,
required this.memo,
required this.createdAt,
});
// 更新時のデータを入力項目からコピーする処理
Cats copy({
int? id,
String? name,
String? birthday,
String? gender,
String? memo,
DateTime? createdAt,
}) =>
Cats(
id: id ?? this.id,
name: name ?? this.name,
birthday: birthday ?? this.birthday,
gender: gender ?? this.gender,
memo: memo ?? this.memo,
createdAt: createdAt ?? this.createdAt,
);
static Cats fromJson(Map<String, Object?> json) => Cats(
id: json[columnId] as int,
name: json[columnName] as String,
gender: json[columnGender] as String,
birthday: json[columnBirthday] as String,
memo: json[columnMemo] as String,
createdAt: DateTime.parse(json[columnCreatedAt] as String),
);
Map<String, Object> toJson() => {
columnName: name,
columnGender: gender,
columnBirthday: birthday,
columnMemo: memo,
columnCreatedAt: DateFormat('yyyy-MM-dd HH:mm:ss').format(createdAt),
};
}
・データベースに関する処理をまとめた。
import 'package:flutter_crud/model/cats.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
// catsテーブルのカラム名を設定
const String columnId = '_id';
const String columnName = 'name';
const String columnGender = 'gender';
const String columnBirthday = 'birthday';
const String columnMemo = 'memo';
const String columnCreatedAt = 'createdAt';
// catsテーブルのカラム名をListに設定
const List<String> columns = [
columnId,
columnName,
columnGender,
columnBirthday,
columnMemo,
columnCreatedAt,
];
// catsテーブルへのアクセスをまとめたクラス
class DbHelper {
// DbHelperをinstance化する
static final DbHelper instance = DbHelper._createInstance();
static Database? _database;
DbHelper._createInstance();
// databaseをオープンしてインスタンス化する
Future<Database> get database async {
return _database ??= await _initDB(); // 初回だったら_initDB()=DBオープンする
}
// データベースをオープンする
Future<Database> _initDB() async {
String path = join(await getDatabasesPath(), 'cats.db'); // cats.dbのパスを取得する
return await openDatabase(
path,
version: 1,
onCreate: _onCreate, // cats.dbがなかった時の処理を指定する(DBは勝手に作られる)
);
}
// データベースがなかった時の処理
Future _onCreate(Database database, int version) async {
//catsテーブルをcreateする
await database.execute('''
CREATE TABLE cats(
_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
gender TEXT,
birthday TEXT,
memo TEXT,
createdAt TEXT
)
''');
}
// catsテーブルのデータを全件取得する
Future<List<Cats>> selectAllCats() async {
final db = await instance.database;
final catsData = await db.query('cats'); // 条件指定しないでcatsテーブルを読み込む
return catsData.map((json) => Cats.fromJson(json)).toList(); // 読み込んだテーブルデータをListにパースしてreturn
}
// _idをキーにして1件のデータを読み込む
Future<Cats> catData(int id) async {
final db = await instance.database;
var cat = [];
cat = await db.query(
'cats',
columns: columns,
where: '_id = ?', // 渡されたidをキーにしてcatsテーブルを読み込む
whereArgs: [id],
);
return Cats.fromJson(cat.first); // 1件だけなので.toListは不要
}
// データをinsertする
Future insert(Cats cats) async {
final db = await database;
return await db.insert(
'cats',
cats.toJson() // cats.dartで定義しているtoJson()で渡されたcatsをパースして書き込む
);
}
// データをupdateする
Future update(Cats cats) async {
final db = await database;
return await db.update(
'cats',
cats.toJson(),
where: '_id = ?', // idで指定されたデータを更新する
whereArgs: [cats.id],
);
}
// データを削除する
Future delete(int id) async {
final db = await instance.database;
return await db.delete(
'cats',
where: '_id = ?', // idで指定されたデータを削除する
whereArgs: [id],
);
}
}
・一覧画面
import 'package:flutter/material.dart';
import 'package:flutter_crud/model/cats.dart';
import 'package:flutter_crud/model/db_helper.dart';
import 'package:flutter_crud/view/cat_detail.dart';
import 'package:flutter_crud/view/cat_detail_edit.dart';
// catテーブルの内容全件を一覧表示するクラス
class CatList extends StatefulWidget {
const CatList({Key? key}) : super(key: key);
@override
_CatListPageState createState() => _CatListPageState();
}
class _CatListPageState extends State<CatList> {
List<Cats> catList = []; //catsテーブルの全件を保有する
bool isLoading = false; //テーブル読み込み中の状態を保有する
// Stateのサブクラスを作成し、initStateをオーバーライドすると、wedgit作成時に処理を動かすことができる。
// ここでは、初期処理としてCatsの全データを取得する。
@override
void initState() {
super.initState();
getCatsList();
}
// initStateで動かす処理。
// catsテーブルに登録されている全データを取ってくる
Future getCatsList() async {
setState(() => isLoading = true); //テーブル読み込み前に「読み込み中」の状態にする
catList = await DbHelper.instance.selectAllCats(); //catsテーブルを全件読み込む
setState(() => isLoading = false); //「読み込み済」の状態にする
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('猫一覧')),
body: isLoading //「読み込み中」だったら「グルグル」が表示される
? const Center(
child: CircularProgressIndicator(), // これが「グルグル」の処理
)
: SizedBox(
child: ListView.builder( // 取得したcatsテーブル全件をリスト表示する
itemCount: catList.length, // 取得したデータの件数を取得
itemBuilder: (BuildContext context, int index) {
final cat = catList[index]; // 1件分のデータをcatに取り出す
return Card( // ここで1件分のデータを表示
child: InkWell( // cardをtapしたときにそのcardの詳細画面に遷移させる
child: Padding( // cardのpadding設定
padding: const EdgeInsets.all(15.0),
child: Row( // cardの中身をRowで設定
children: <Widget>[ // Rowの中身を設定
Container( // アイコンを表示
width: 80,height: 80,
decoration: const BoxDecoration(
shape: BoxShape.circle, // 丸にする
image: DecorationImage(
fit: BoxFit.fill,
image: AssetImage('assets/icon/dora.png')
)
)
),
Text(cat.name,style: const TextStyle(fontSize: 30),), // catのnameを表示
]
),
),
onTap: () async { // cardをtapしたときの処理を設定
await Navigator.of(context).push( // ページ遷移をNavigatorで設定
MaterialPageRoute(
builder: (context) => CatDetail(id: cat.id!), // cardのデータの詳細を表示するcat_detail.dartへ遷移
),
);
getCatsList(); // データが更新されているかもしれないので、catsテーブル全件読み直し
},
),
);
},
),
),
floatingActionButton: FloatingActionButton( // +ボタンを下に表示する
child: const Icon(Icons.add), // ボタンの形を指定
onPressed: () async { // +ボタンを押したときの処理を設定
await Navigator.of(context).push( // ページ遷移をNavigatorで設定
MaterialPageRoute(
builder: (context) => const CatDetailEdit() // 詳細更新画面(元ネタがないから新規登録)を表示するcat_detail_edit.dartへ遷移
),
);
getCatsList(); // 新規登録されているので、catテーブル全件読み直し
},
),
);
}
}
・詳細画面
import 'package:flutter/material.dart';
import 'package:flutter_crud/model/cats.dart';
import 'package:flutter_crud/model/db_helper.dart';
import 'package:flutter_crud/view/cat_detail_edit.dart';
// catsテーブルの中の1件のデータに対する操作を行うクラス
class CatDetail extends StatefulWidget {
final int id;
const CatDetail({Key? key, required this.id}) : super(key: key);
@override
_CatDetailState createState() => _CatDetailState();
}
class _CatDetailState extends State<CatDetail> {
late Cats cats;
bool isLoading = false;
static const int textExpandedFlex = 1; // 見出しのexpaded flexの比率
static const int dataExpandedFlex = 4; // 項目のexpanede flexの比率
// Stateのサブクラスを作成し、initStateをオーバーライドすると、wedgit作成時に処理を動かすことができる。
// ここでは、渡されたidをキーとしてcatsテーブルからデータを取得する
@override
void initState() {
super.initState();
catData();
}
// initStateで動かす処理
// catsテーブルから指定されたidのデータを1件取得する
Future catData() async {
setState(() => isLoading = true);
cats = await DbHelper.instance.catData(widget.id);
setState(() => isLoading = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('猫詳細'),
actions: [
IconButton(
onPressed: () async { // 鉛筆のアイコンが押されたときの処理を設定
await Navigator.of(context).push( // ページ遷移をNavigatorで設定
MaterialPageRoute(
builder: (context) => CatDetailEdit( // 詳細更新画面を表示する
cats: cats,
),
),
);
catData(); // 更新後のデータを読み込む
},
icon: const Icon(Icons.edit), // 鉛筆マークのアイコンを表示
),
IconButton(
onPressed: () async { // ゴミ箱のアイコンが押されたときの処理を設定
await DbHelper.instance.delete(widget.id); // 指定されたidのデータを削除する
Navigator.of(context).pop(); // 削除後に前の画面に戻る
},
icon: const Icon(Icons.delete), // ゴミ箱マークのアイコンを表示
)
],
),
body: isLoading //「読み込み中」だったら「グルグル」が表示される
? const Center(
child: CircularProgressIndicator(), // これが「グルグル」の処理
)
: Column(
children :[
Container( // アイコンを表示
width: 80,height: 80,
decoration: const BoxDecoration(
shape: BoxShape.circle, // 丸にする
image: DecorationImage(
fit: BoxFit.fill,
image: AssetImage('assets/icon/dora.png')
)
)
),
Column( // 縦並びで項目を表示
crossAxisAlignment: CrossAxisAlignment.stretch, // 子要素の高さを合わせる
children: [
Row(children: [
const Expanded( // 見出しの設定
flex: textExpandedFlex,
child: Text('名前',
textAlign: TextAlign.center,
),
),
Expanded(
flex: dataExpandedFlex,
child: Container( // catsテーブルのnameの表示を設定
padding: const EdgeInsets.all(5.0),
child: Text(cats.name),
),
),
],),
Row(children: [
const Expanded( // 見出しの設定(性別)
flex: textExpandedFlex,
child: Text('性別',
textAlign: TextAlign.center,
),
),
Expanded(
flex: dataExpandedFlex,
child: Container( // catsテーブルのgenderの表示を設定
padding: const EdgeInsets.all(5.0),
child: Text(cats.gender),
),
),
],),
Row(children: [
const Expanded( // 「誕生日」の見出し行の設定
flex: textExpandedFlex,
child: Text('誕生日',
textAlign: TextAlign.center,
),
),
Expanded(
flex: dataExpandedFlex,
child: Container( // catsテーブルのbirthdayの表示を設定
padding: const EdgeInsets.all(5.0),
child: Text(cats.birthday),
),
)
],),
Row(children: [
const Expanded( // 「メモ」の見出し行の設定
flex: textExpandedFlex,
child: Text('メモ',
textAlign: TextAlign.center,
)
),
Expanded(
flex: dataExpandedFlex,
child: Container( // catsテーブルのmemoの表示を設定
padding: const EdgeInsets.all(5.0),
child: Text(cats.memo),
),
),
],),
],
),
],
)
);
}
}
・更新画面
import 'package:flutter/material.dart';
import 'package:flutter_crud/model/cats.dart';
import 'package:flutter_crud/model/db_helper.dart';
class CatDetailEdit extends StatefulWidget {
final Cats? cats;
const CatDetailEdit({Key? key, this.cats}) : super(key: key);
@override
_CatDetailEditState createState() => _CatDetailEditState();
}
class _CatDetailEditState extends State<CatDetailEdit> {
late int id;
late String name;
late String birthday;
late String gender;
late String memo;
late DateTime createdAt;
final List<String> _list = <String>['男の子', '女の子', '不明']; // 性別のDropdownの項目を設定
late String _selected; // Dropdownの選択値を格納するエリア
String value = '不明'; // Dropdownの初期値
static const int textExpandedFlex = 1; // 見出しのexpaded flexの比率
static const int dataExpandedFlex = 4; // 項目のexpanede flexの比率
// Stateのサブクラスを作成し、initStateをオーバーライドすると、wedgit作成時に処理を動かすことができる。
// ここでは、各項目の初期値を設定する
@override
void initState() {
super.initState();
id = widget.cats?.id ?? 0;
name = widget.cats?.name ?? '';
birthday = widget.cats?.birthday ?? '';
gender = widget.cats?.gender ?? '';
_selected = widget.cats?.gender ?? '不明';
memo = widget.cats?.memo ?? '';
createdAt = widget.cats?.createdAt ?? DateTime.now();
}
// Dropdownの値の変更を行う
void _onChanged(String? value) {
setState(() {
_selected = value!;
gender = _selected;
});
}
// 詳細編集画面を表示する
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('猫編集'),
actions: [
buildSaveButton(), // 保存ボタンを表示する
],
),
body: SingleChildScrollView(
child: Column(children: <Widget>[
Row(children: [
// 名前の行の設定
const Expanded( // 見出し(名前)
flex: textExpandedFlex,
child: Text('名前',
textAlign: TextAlign.center,
),
),
Expanded( // 名前入力エリアの設定
flex: dataExpandedFlex,
child: TextFormField(
maxLines: 1,
initialValue: name,
decoration: const InputDecoration(
hintText: '名前を入力してください',
),
validator: (name) => name != null && name.isEmpty
? '名前は必ず入れてね'
: null, // validateを設定
onChanged: (name) => setState(() => this.name = name),
),
),
]),
// 性別の行の設定
Row(children: [
const Expanded( // 見出し(性別)
flex: textExpandedFlex,
child: Text('性別',
textAlign: TextAlign.center,
),
),
Expanded( // 性別をドロップダウンで設定
flex: dataExpandedFlex,
child: DropdownButton(
items: _list.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem(
value: value,
child: Text(value),
);
}).toList(),
value: _selected,
onChanged: _onChanged,
),
),
]),
Row(children: [
const Expanded( // 見出し(誕生日)
flex: textExpandedFlex,
child: Text('誕生日',
textAlign: TextAlign.center,
),
),
Expanded( // 誕生日入力エリアの設定
flex: dataExpandedFlex,
child: TextFormField(
maxLines: 1,
initialValue: birthday,
decoration: const InputDecoration(
hintText: '誕生日を入力してください',
),
onChanged: (birthday) =>
setState(() => this.birthday = birthday),
),
),
]),
Row(children: [
const Expanded( // 見出し(メモ)
flex: textExpandedFlex,
child: Text('メモ',
textAlign: TextAlign.center,
)
),
Expanded( // メモ入力エリアの設定
flex: dataExpandedFlex,
child: TextFormField(
maxLines: 1,
initialValue: memo,
decoration: const InputDecoration(
hintText: 'メモを入力してください',
),
onChanged: (memo) => setState(() => this.memo = memo),
),
),
]),
]),
),
);
}
// 保存ボタンの設定
Widget buildSaveButton() {
final isFormValid = name.isNotEmpty;
return Padding(
padding: const EdgeInsets.all(10.0),
child: ElevatedButton(
child: const Text('保存'),
style: ElevatedButton.styleFrom(
onPrimary: Colors.white,
primary: isFormValid ? Colors.redAccent : Colors.grey.shade700,
),
onPressed: createOrUpdateCat, // 保存ボタンを押したら実行する処理を指定する
),
);
}
// 保存ボタンを押したとき実行する処理
void createOrUpdateCat() async {
final isUpdate = (widget.cats != null); // 画面が空でなかったら
if (isUpdate) {
await updateCat(); // updateの処理
} else {
await createCat(); // insertの処理
}
Navigator.of(context).pop(); // 前の画面に戻る
}
// 更新処理の呼び出し
Future updateCat() async {
final cat = widget.cats!.copy( // 画面の内容をcatにセット
name: name,
birthday: birthday,
gender: gender,
memo: memo,
);
await DbHelper.instance.update(cat); // catの内容で更新する
}
// 追加処理の呼び出し
Future createCat() async {
final cat = Cats( // 入力された内容をcatにセット
name: name,
birthday: birthday,
gender: gender,
memo: memo,
createdAt: createdAt,
);
await DbHelper.instance.insert(cat); // catの内容で追加する
}
}
冒頭にも書いたけど、環境作ってからここまでたどり着くのに、すごく時間がかかったよ。
写経すればそれなりに動くんだけど、自分がやりたいようにするにはどうしたらいいか、なかなか理解が追い付かなかった。しょせんおいらはサーバサイドの技術者さ。
これでなんとなくCRUD関係は克服できた気がする。
今後地味にブラッシュアップしてく記事を書くよ。多分、きっと…
そのときまで、あうふびたーぜん!ふろいんと!!