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),
            
          ],
        ),
      ),
    );
  }
}

基本構造に大きな変更はありませんが、ユーザインターフェースは少し工夫をしました。

その際調べたことについては、後で別記事で紹介したいと思っています。