Flutter iOS 音声メモアプリ開発(1)永続的データの保存
Flutterで開発したアプリケーションの公開に挑戦してみたいと思います。
Flutter はAndroidとiOSのどちらも開発可能ではありますが、個人的にiPhoneユーザでありますので、今回はiOS専用アプリとして開発します。
音声メモアプリ
題材とするアプリは、音声認識結果をメモとして登録するアプリです。
備忘録として使ったり、日記のように使うことを想定しています。行き当たりばったりとなりますが、細かい機能は作りながら考えていきます。
まずは、キーボードから入力するメモアプリを作り、後で音声認識機能を加えていきます。
永続的データの保存:localstore パッケージ
メモアプリの最初の課題は、永続的データの保存をどうやって実現するかです。
データの保存については、Firebase上のDBに置く方法が最初に思いつきますが、日記のようなプライベート情報を保存することを考えて、今回は端末内に保存します。
端末内にデータを保存する方式として、大きく3つあります。一つはSQLiteのようなデータベースを持つこと、key-value形式のSharedPreferencesを利用すること、最後にJSONファイルとして保存するlocalstoreです。
key-value形式は、アプリの設定などを保存するのには向いていますが、今回の目的には合いません。また、データ構造が非常にシンプルなので、SQLiteではなく、localstoreを選択しました。
Example code
最初に、localstoreの使い方を理解するために、exampleにあるコードを動かしてみます。
flutter pub add localstore
localstoreパッケージをインストールし、main.dartをexampleのコードに変更します。
なにやら、エラーが出てしまいました。change logを見ると、バージョンが古かった様です。 (v.1.2.3)
最新のバージョンは、1.3.4だったので、pubspec.yamlのlocalstoreのバージョンを書き換えてflutter pub upgradeを実行します。
localstore: ^1.3.4
更に、私の環境の場合、dartのSDKが古いためにエラーが出てupgradeできませんでしたので、先にflutterを最新バージョンにしました。
flutter upgrade
さて、デバッグモードで実行すると、以下の様な画面が出ます。
+ボタンをクリックすると、一つづつリストにカードが追加され,ボタンを押した時間が刻印されています。カードのゴミ箱をクリックすると、削除されます。アプリバーのゴミ箱をクリックすると、全削除となります。
localstoreの基本的な使い方
localstoreの基本的な使い方は、以下の様になっています。
// 必要なパッケージのインポート
import 'package:flutter/material.dart';
import 'package:localstore/localstore.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(MyApp());
}
final db = Localstore.instance;
// 新しいIDを取得
final id = db.collection('todos').doc().id;
// 取得したIDでデータを登録
db.collection('todos').doc(id).set({
'title': 'Todo title',
'done': false
});
//IDでデータを取得
final data = await db.collection('todos').doc(id).get();
// IDでデータを削除
db.collection('todos').doc(id).delete();
final stream = db.collection('todos').stream;
最初のメモアプリ キーボード入力版
Exampleを修正してで出来上がったメモアプリがこちら。
変更箇所
まず、Text Fieldを追加し、addボタンを押すことで、入力されたメモを追加することにしました。蛇足ですが、入力された文字列のSentiment分析をして、結果を顔文字で表現してみました。
また、各itemをクリックすると、編集用画面に遷移します。(編集機能は未実装で遷移のみ用意しておきました。)
細かいところですが、ゴミ箱を押したときに、いきなり削除するのではなく、ダイアロブボックスを表示させて「削除しますか?」と聞いてくる様にしています。
コードはこちらになります。
import 'dart:async';
import 'card.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:localstore/localstore.dart';
import 'package:http/http.dart' as http;
import 'package:google_fonts/google_fonts.dart';
import 'dart:convert';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Voice Memo',
theme: ThemeData(
textTheme: GoogleFonts.kosugiMaruTextTheme(Theme.of(context).textTheme),
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Voice Memo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, this.title}) : super(key: key);
final String? title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final _db = Localstore.instance;
late var _items = <String, Todo>{};
StreamSubscription<Map<String, dynamic>>? _subscription;
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
//お好みの絵文字を設定してください
final String positive = '😄';
final String neutral = '🙂';
final String negative = '😢';
final String other = '😐';
@override
void initState() {
_subscription = _db.collection('todos').stream.listen((event) {
setState(() {
final item = Todo.fromMap(event);
_items.putIfAbsent(item.id, () => item);
});
});
if (kIsWeb) _db.collection('todos').stream.asBroadcastStream();
super.initState();
}
Future<void> _addDataText(String inputText) async {
if (inputText != '') {
final sentiment = await _Sentiment(inputText);
final id = Localstore.instance.collection('todos').doc().id;
final now = DateTime.now();
final item = Todo(
id: id,
title: inputText,
time: now,
sentiment: sentiment,
done: false,
);
item.save();
_items.putIfAbsent(item.id, () => item);
_scrollLast();
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => primaryFocus?.unfocus(),
child: Scaffold(
appBar: AppBar(
title: Text(widget.title!),
actions: [
IconButton(
onPressed: () {
setState(() {
_db.collection('todos').delete();
_items.clear();
});
},
icon: const Icon(Icons.delete_outlined),
)
],
),
body: Container(
padding: const EdgeInsets.all(10),
child: Column(children: [
Expanded(
child: ListView.builder(
//reverse: true,
controller: _scrollController,
itemCount: _items.keys.length,
itemBuilder: (context, index) {
final key = _items.keys.elementAt(index);
final item = _items[key]!;
return GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CardPage(
card_item: item,
))),
child: Card(
child: Column(children: <Widget>[
ListTile(
title: Text(item.title),
subtitle: Text(item.time.toString()),
trailing: Text(item.sentiment,
style:
Theme.of(context).textTheme.headlineSmall),
leading: IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
var ans = await _showDeleteDialog();
// ignore: unrelated_type_equality_checks
if (ans == 'OK') {
setState(() {
item.delete();
_items.remove(item.id);
});
}
})),
]),
),
);
},
),
),
const SizedBox(
height: 5,
),
Column(
children: [
TextField(
style: const TextStyle(fontSize: 12),
maxLength: 200,
keyboardType: TextInputType.multiline,
maxLines: 3,
minLines: 3,
autofocus: true,
controller: _textController,
//textInputAction: TextInputAction.next,
decoration: const InputDecoration(
hintText: 'memoを入力してください',
labelText: 'Your memo',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(10),
),
),
),
),
Ink(
//edit
decoration: const ShapeDecoration(
shape: CircleBorder(), color: Colors.lightBlue),
child: IconButton(
tooltip: 'メモを追加する',
iconSize: 18,
color: Colors.white,
icon: const Icon(Icons.add),
onPressed: () {
if (_textController.text != '') {
_addDataText(_textController.text);
}
}),
),
],
),
]),
),
),
);
}
void _scrollLast() async {
await Future.delayed(const Duration(milliseconds: 100));
_textController.text = '';
_scrollController.jumpTo(
_scrollController.position.maxScrollExtent,
);
}
@override
void dispose() {
if (_subscription != null) _subscription?.cancel();
super.dispose();
}
Future<String> _showDeleteDialog() async {
var result = await showDialog(
context: context,
builder: (_) {
return AlertDialog(
title: const Text('DELETE'),
content: const Text('削除しますよろしいですか?'),
actions: [
TextButton(
child: const Text('OK'),
onPressed: () => Navigator.pop(context, 'OK'),
),
TextButton(
child: const Text('Cancel'),
onPressed: () => Navigator.pop(context, 'Cancel'),
),
],
);
},
);
return result;
}
Future<String> _Sentiment(query) async {
String url = <Your Url>;
String body = json.encode({
"kind": "SentimentAnalysis",
"parameters": {"modelVersion": "latest"},
"analysisInput": {
"documents": [
{"id": "1", "language": "ja", "text": query}
]
}
});
Map<String, String> headers = {
'Ocp-Apim-Subscription-Key': '<Your key>',
'Content-Type': 'application/json'
};
http.Response resp =
await http.post(Uri.parse(url), headers: headers, body: body);
var decode = json.decode(resp.body);
try {
final String sentiment = decode['results']['documents'][0]['sentiment'];
if (sentiment == 'positive') {
return positive;
} else if (sentiment == 'neutral') {
return neutral;
} else if (sentiment == 'negative') {
return negative;
} else {
return other;
}
} catch (e) {
return other;
}
}
}
/// Data Model
class Todo {
final String id;
String title;
DateTime time;
String sentiment;
bool done;
Todo({
required this.id,
required this.title,
required this.time,
required this.sentiment,
required this.done,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'title': title,
'time': time.millisecondsSinceEpoch,
'sentiment': sentiment,
'done': done,
};
}
factory Todo.fromMap(Map<String, dynamic> map) {
return Todo(
id: map['id'],
title: map['title'],
time: DateTime.fromMillisecondsSinceEpoch(map['time']),
sentiment: map['sentiment'],
done: map['done'],
);
}
}
extension ExtTodo on Todo {
Future save() async {
final _db = Localstore.instance;
return _db.collection('todos').doc(id).set(toMap());
}
Future delete() async {
final _db = Localstore.instance;
return _db.collection('todos').doc(id).delete();
}
}
import 'package:flutter/material.dart';
import 'main.dart';
class CardPage extends StatefulWidget {
const CardPage({Key? key, required this.card_item}) : super(key: key);
final Todo card_item;
@override
CardPageState createState() => CardPageState();
}
class CardPageState extends State<CardPage> {
late Todo item;
@override
void initState() {
super.initState();
item = widget.card_item;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(item.time.toIso8601String())),
body: Container(
padding: const EdgeInsets.all(10),
child: Column(
children: [
Card(
child: ListTile(
title: Text(item.title),
trailing: Text(item.sentiment,
style: Theme.of(context).textTheme.headlineSmall),
),
),
const SizedBox(height: 10),
],
),
),
);
}
}
基本構造に大きな変更はありませんが、ユーザインターフェースは少し工夫をしました。
その際調べたことについては、後で別記事で紹介したいと思っています。