Open30

アプリ開発初心者がFlutterでアラームアプリを作っていく

HajimeHajime

Webの海に漂流しているけど、たまにはアプリ作り挑戦してみようと思う。

とりあえずFlutterはFVMつかってインストールした。
ブランクプロジェクトを作って開発中のアプリの実機起動を確認。

HajimeHajime

さて、今回はアラーム的なアプリを作りたい。
とりあえず何時何分を指定してアラームを設定する機能を作ろう。

アプリの管理するディレクトリにデータを保存する必要がある。
Sqliteとかいくつかライブラリがあるけど何を使ったらわからない。

HajimeHajime

ざっくり調べて、使いやすそうな ObjectBoxをチョイス。

pubspec.yamlに追記して...

$ fvm flutter pub get

ずっと前にFlutter触った時は ios ディレクトリで pod installしてたなと思い、下記実行。

$ pod install
Analyzing dependencies
[!] CocoaPods could not find compatible versions for pod "objectbox_flutter_libs":
  In Podfile:
    objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)

Specs satisfying the `objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)` dependency were found, but they required a higher minimum deployment target.

早速つまづいた。
ざっくり調べてみると、iOSのターゲットバージョンが低いと出るらしい。

このへんの管理ってどうなっているんだろう。
依存するパッケージによってはかならずしもiOSターゲットバージョンを上げられないこともあるんじゃないか?プロダクションで運用している開発者はどう管理しているのだろう。

とか考えながら、 podfileのtargetを11へ。XCodeでxcodeプロジェクトのターゲットも11へ。
再実行したらうまくいったぽい。

$ pod install
Analyzing dependencies
Downloading dependencies
Generating Pods project
Integrating client project
Pod installation complete! There are 3 dependencies from the Podfile and 4 total pods installed.
HajimeHajime

ObjectBoxのドキュメントは綺麗で見やすい。手順通りに lib/model.dartにデータベースで扱いたいエンティティの定義を作成。

@Entity()
class Note {
  int id;
  String text;
  String? comment;
  DateTime date;

  ...
}

次はこの定義をもとになにやらコードを自動生成するらしい。

$ fvm dart run build_runner build

[SEVERE] objectbox_generator:resolver on lib/model.dart:

line 1, column 1 of package:posealarm/model.dart: Could not resolve annotation for `class User`.
  ╷
1 │ @Entity()
  │ ^^^^^^^^^
  ╵
[INFO] Running build completed, took 569ms

また躓く。

HajimeHajime

サンプルを丸々コピペしたらなんか上手くいった...のだろうか?

$ fvm dart run build_runner build
[INFO] Generating build script...
[INFO] Generating build script completed, took 452ms

[INFO] Initializing inputs
[INFO] Reading cached asset graph...
[INFO] Reading cached asset graph completed, took 64ms

[INFO] Checking for updates since last build...
[INFO] Checking for updates since last build completed, took 682ms

[INFO] Running build...
[INFO] objectbox_generator:generator on lib/$lib$:Package: posealarm
[INFO] objectbox_generator:generator on lib/$lib$:Found 1 entities in: (lib/model.objectbox.info)
[WARNING] objectbox_generator:generator on lib/$lib$:
Creating model: lib/objectbox-model.json
[INFO] objectbox_generator:generator on lib/$lib$:Found new entity Note
[INFO] objectbox_generator:generator on lib/$lib$:Found new property Note.id
[INFO] objectbox_generator:generator on lib/$lib$:Found new property Note.text
[INFO] objectbox_generator:generator on lib/$lib$:Found new property Note.comment
[INFO] objectbox_generator:generator on lib/$lib$:Found new property Note.date
[WARNING] objectbox_generator:generator on lib/$lib$:
Failed to find package root from output directory, generated imports might be incorrect.
[INFO] objectbox_generator:generator on lib/$lib$:Generating code: lib/objectbox.g.dart
[INFO] Running build completed, took 375ms

[INFO] Caching finalized dependency graph...
[INFO] Caching finalized dependency graph completed, took 32ms

[INFO] Succeeded after 418ms with 1 outputs (1 actions)
HajimeHajime

openStore()を出力してみるとFutureが帰ってきてたので、サンプルを参考にmain関数をasyncにしてawaitさせるようにしたら、なんとかできた!

main.dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final store = await openStore();

  debugPrint("ストアをオープン");
  debugPrint(store.toString());

  // Box(概念的にはテーブルに近い)を取得
  final noteBox = store.box<Note>();
  debugPrint(noteBox.toString());

  
  // 新規登録
  final note = Note('Flutterの基礎知識2');
  debugPrint(noteBox.toString());
  noteBox.put(note);
  
  final notes = noteBox.getAll();
  for (var note in notes) {
    debugPrint("${note.id} ${note.text} ${note.comment} ${note.dateFormat}");
  }

  store.close();
  debugPrint("ストアをクローズ");

  runApp(const MyApp());
}
output
flutter: ストアをオープン
flutter: Instance of 'Store'
flutter: Instance of 'Box<Note>'
flutter: 1 Flutterの基礎知識 null 26.04.2022 11:56:42
flutter: 2 Flutterの基礎知識1 null 26.04.2022 11:56:53
flutter: 3 Flutterの基礎知識2 null 26.04.2022 11:58:33
flutter: ストアをクローズ
HajimeHajime

モヤモヤしたまま寝ることにならずよかった。
今日はここまで。

HajimeHajime

今日作業再開しようとして開発環境を起動したら

Could not build the precompiled application for the device.
Error (Xcode): No signing certificate "iOS Development" found: No "iOS Development" signing certificate matching team ID "XXXXXXXXX" with a private key was found.

なにこれ..

XCodeで.xcodeprojを起動してから、再度実行したらなおった。
意味わからない。

HajimeHajime

次は、アプリを起動したら、データベースかたデータを取り出し、リストに表示するということをやりたい。

HajimeHajime

それに伴いModelを作り直す。

model.dart
()
class AlarmEntry {
  int id;

  List<int> repeat = []; // 曜日毎の繰り返し設定 曜日番号の配列を格納
  String label = ""; // アラームのラベル付け
  String sound = "default"; // サウンドの識別子
  bool snooze = false; // スヌーズ設定
}
HajimeHajime

もともと書いてあった、

Note(this.text, {this.id = 0, this.comment, DateTime? date})
      : date = date ?? DateTime.now();

の部分、Dartで定義するclassのコンストラクタだったと思うのでおさらいをする。

基本のコンストラクタの書き方

同じ名前で関数みたいな書き方をすればいいらしい。

class Dog {
  var name;
  Dog(name) {
    this.name = name;
  }
}

Automatic field initialization

this.フィールド名 と書いておくと、受け取った引数をそのまま this.フィールド名=引数で渡された値 と書いたのと同じ効果になる。

class Dog {
  var name;
  Dog(this.name);
}
main() {
    var dog = new Dog('チワワ')
    print(dog.name) // チワワ
}

Named Constructors

Dartは名前付きコンストラクタを定義できる。
初期化に自由度を持たせられますね。

名前のついていないコンストラクタをオーバーライドするような動きをする。
例えば下記では、引数に渡された値が name に設定される。

class Dog {
  var name;
  Dog() {
    this.name = 'Anonymous';
  }
  Dog.name(this.name);
}
main() {
  var dog = new Dog.name('Pochi');
  print(dog.name); // Pochi
}

Redirecting Constructors

別のコンストラクタへ処理をリダイレクトできる。
コンストラクタ定義の後ろに「:」コロンを置いて、その後にリダイレクト先コンストラクタをthis.コンストラクタ名()でコールするような形をとる。リダイレクトコンストラクタ自体に処理をもたせることもでき、その場合はリダイレクト先コンストラクタが先に処理されたあと、自身のコンストラクタの処理をする。

class Dog {
  var name;
  var age;
  Dog() : this.anonymous() {
    this.age = 0;
  }
  Dog.anonymous() {
    this.name = 'Anonymous';
  }
  Dog.name(this.name);
}
main() {
  var dog = new Dog();
  print(dog.name); // Anonymous
  print(dog.age); // 0
}

Initializer Lists

コンストラクタの定義と同時にインスタンス変数の初期化内容を定義できる。

class Dog {
    var name;
    var age;
    Dog() : this.anonymous();
    Dog.anonymous() : this.name = "Anonymous", this.age = 0;
    Dog.name(this.name)
}
main() {
    var dog = new Dog();
    print(dog.name); // Anonymous
}

ん?Redirecting Constructorsと併用とかできるのかな?

ファクトリ

factoryキーワードをコンストラクタにつけるとそれはfactoryになる。(なんだfactoryって...factoryパターンとかきいたことあるけど)

シングルトンなどはファクトリコンストラクタを定義してあげることで実現できるようだ。
ファクトリコンストラクタはそれ自身はインスタンスを生成しないらしく、自分でインスタンスを生成して返してあげる必要があるらしい。

インスタンスを持つ必要のないクラスを定義するときに使う?
ユースケースがイメージできていない。

class Dog {
  var name;
  Dog() : this.anonymous();
  Dog.anonymous() : this.name = 'Anonymous';
  Dog.name(this.name);
}

class Pochi extends Dog {
  static var _instance;
  factory Pochi() {
    if (_instance == null) {
      _instance = new Pochi._internal();
    }
    return _instance;
  }
  Pochi._internal() : super.name('Pochi');
}

Constant Constructors(定数コンストラクタ)

コンパイル時にオブジェクトをインスタンス化したい場合に利用するらしい。
Flutterの場合はウィジェットにconstをつけるけど、そうすると使いまわされて効率がいいとか聞いたことある。

コンパイル時にインスタンス化して、アプリ実行時は既にインスタンス化されているものを使い回す、そんな感じなのかな?

定数オブジェクトのフィールドには final 、定数コンストラクタには const キーワードをつける。

class Dog {
    final name;
    const Dog() : this.anonymous();
    const Dog.anonymous() : this.name = 'Anonymous';
    const Dog.name(this.name);
}
final dog = const Dog();
final pochi = const Dog.name('Pochi');

main() {
    print(dog.name); // Anonymous
    print(pochi.name); // Pochi
}

きょうはここまで。

HajimeHajime
Note(this.text, {this.id = 0, this.comment, DateTime? date}) : date = date ?? DateTime.now();

これの {this.id = 0, this.comment, DateTime? date} この部分てどういう意味だろう。

HajimeHajime

ListViewでの描画をした。また、ボタンをタップしたらDBにレコードを書き込むようにもした。

ListViewでの描画

描画でてこずったのは、snapshot.dataをmapできないという問題。

FutureBulderでデータの取得関数をコールし、AsyncSnapshotとして戻り値を取得できるが、mapしようとすると 「snapshot.dataはnullの可能性があるからできないよ」とのこと。

AsyncSnapshotのドキュメントをみると、requiredDataというプロパティを発見。
https://api.flutter.dev/flutter/widgets/AsyncSnapshot/requireData.html

これをつかってみたところなんとかいけた。

  Future<List<Widget>> _getAlarmEntries() async {
    final store = await openStore();
    final alarmEntryBox = store.box<AlarmEntry>();
    final alarmEntries = alarmEntryBox.getAll();
    // alarmEntryBox.removeAll();
    store.close();

    return alarmEntries.map((alarmEntry) {
      return Text("${alarmEntry.id} ${alarmEntry.label}");
    }).toList();
  }

  Widget _alarmEntryList(BuildContext context) {
    return FutureBuilder(
        future: _getAlarmEntries(),
        builder: (BuildContext context, AsyncSnapshot<List<Widget>> snapshot) {
          if (snapshot.hasError) {
            return Text("Error: ${snapshot.error}");
          }
          if (!snapshot.hasData) {
            return const Text("データが見つかりません");
          }
          return ListView(
            children: snapshot.requireData.map((widget) {
              return widget;
            }).toList()
          );
        });
  }

ボタンをタップしたらDBにレコードを書き込む

デフォルトテンプレートの _incrementCounter() を書き換える。
簡単。

  void _incrementCounter() async {
    final store = await openStore();
    final alarmEntryBox = store.box<AlarmEntry>();
    final alarmEntry = AlarmEntry('アラーム1');
    alarmEntryBox.put(alarmEntry);
    store.close();
  }
HajimeHajime

登録と一覧表示の動作は確認できたので、登録画面を作っていきたい。

画面の遷移して、登録フォームを表示する必要がありそう。

HajimeHajime

ブランクな画面をつくって画面遷移を確認していく。
今回はFlutter組み込みのNavigator.pushを使用する。

ボタンをタップしたら遷移したいので、_incrementCounter ではなく新たに作成した関数を呼び出すように書き換える。

main.dart
  void _pushNewAlarmEntryPage() {
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => const NewAlarmEntryPage())
    );
  }

ページはScaffoldでザックリつくる。

NewAlarmEntryPage.dart
import 'package:flutter/material.dart';

class NewAlarmEntryPage extends StatefulWidget {
  final String title = 'アラーム登録';

  const NewAlarmEntryPage({Key? key}) : super(key: key);

  
  _NewAlarmEntryPageState createState() => _NewAlarmEntryPageState();
}

class _NewAlarmEntryPageState extends State<NewAlarmEntryPage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(child: Text('アラーム登録ページ'))
    );
  }
}
Head Head

いいかんじ♪

HajimeHajime

案外少ない記述で画面遷移を実現できるのには感動した。
Router系のpubライブラリとの違いとかは追々確認していきたい。

HajimeHajime

アラームの登録画面を作成していく。
前回作成した新しい画面に実装していき、サブミットボタンをタップしたら前のページに戻って、一覧に追加されているイメージ。

iOS標準のアラームは下から迫り上がって前面に浮き出てくる画面で実装されているけど、これってFlutterでもできるのかな?追々確認したい。

HajimeHajime

ウィジェットを組み合わせて、値はStateに持たせて、フォームの値が変更されたらStateを更新して...といった流れでガツガツと大雑把に画面を構築。ざっくり1時間くらいの作業。

HajimeHajime

ウィジェットの構築では特に大きなつまづきは無かった。
既存ウィジェットをそのまま使ったから。

ちょっと気になったのは、DropdownButtonのラベル部分にpaddingがはいっておらず、スヌーズの文字とすれたのでとりあえずPaddingをいれた。けれど、端末によってはうまく揃わないことがありそうなので後々確認していきたい。

ドラムロールな Time PickerはCupertinoDatePickerをCupertinoDatePickerMode.time で使用することで実現できた、ただし縦幅が無限に伸びてしまうので、SizedBoxで高さを固定しないといけなかった。

端末毎の縦長さによってオーバーフロするかもしれない。スクロールで表示するなどの対応は追々やっていきたい。

new_alarm_page.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class NewAlarmEntryPage extends StatefulWidget {
  final String title = 'アラーム登録';

  const NewAlarmEntryPage({Key? key}) : super(key: key);

  
  _NewAlarmEntryPageState createState() => _NewAlarmEntryPageState();
}

class _NewAlarmEntryPageState extends State<NewAlarmEntryPage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: _form()
    );
  }

  final _formKey = GlobalKey<FormState>();
  bool sunChk = false;
  bool monChk = false;
  bool tueChk = false;
  bool wedChk = false;
  bool thuChk = false;
  bool friChk = false;
  bool satChk = false;
  bool snoozeChk = false;
  String sound = 'default';
  DateTime dateTime = DateTime.now();

  Widget _form() {
    return Form(
      key: _formKey,
      child: Padding(
          padding: const EdgeInsets.all(18),
          child: Column(
            children: <Widget>[
              SizedBox(
                height: 200,
                child: CupertinoDatePicker(
                  initialDateTime: dateTime,
                  mode: CupertinoDatePickerMode.time,
                  use24hFormat: true,
                  onDateTimeChanged: (DateTime newTime) {
                    setState(() => dateTime = newTime);
                  },
                ),
              ),
              TextFormField(
                decoration: const InputDecoration(
                  labelText: 'ラベル(必須)',
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'ラベルを入力してください';
                  }
                  return null;
                },
              ),
              Table(children: <TableRow>[
                const TableRow(children: <Widget>[
                  Center(child: Text("日")),
                  Center(child: Text("月")),
                  Center(child: Text("火")),
                  Center(child: Text("水")),
                  Center(child: Text("木")),
                  Center(child: Text("金")),
                  Center(child: Text("土")),
                ]),
                TableRow(children: <Widget>[
                  Center(child: Checkbox(
                      value: sunChk,
                      onChanged: (bool? value) {
                        setState(() => sunChk = value!);
                        debugPrint('sunChk = $value');
                      }
                  )),
                  Center(child: Checkbox(
                      value: monChk,
                      onChanged: (bool? value) {
                        setState(() => monChk = value!);
                        debugPrint('monChk = $value');
                      }
                  )),
                  Center(child: Checkbox(
                      value: tueChk,
                      onChanged: (bool? value) {
                        setState(() => tueChk = value!);
                        debugPrint('tueChk = $value');
                      }
                  )),
                  Center(child: Checkbox(
                      value:wedChk,
                      onChanged: (bool? value) {
                        setState(() => wedChk = value!);
                        debugPrint('wedChk = $value');
                      }
                  )),
                  Center(child: Checkbox(
                      value: thuChk,
                      onChanged: (bool? value) {
                        setState(() => thuChk = value!);
                        debugPrint('thuChk = $value');
                      }
                  )),
                  Center(child: Checkbox(
                      value: friChk,
                      onChanged: (bool? value) {
                        setState(() => friChk = value!);
                        debugPrint('friChk = $value');
                      }
                  )),
                  Center(child: Checkbox(
                      value: satChk,
                      onChanged: (bool? value) {
                        setState(() => satChk = value!);
                        debugPrint('satChk = $value');
                      }
                  )),
                ])
              ]),
              DropdownButton(
                  items: const [DropdownMenuItem(child: Padding(child: Text("デフォルト"), padding: EdgeInsets.symmetric(vertical: 0, horizontal: 16)), value: 'default')],
                  value: sound,
                  isExpanded: true,
                  onChanged: (String? value) {
                    setState(() => sound = value.toString());
                    debugPrint('sound = $value');
                  },
              ),
              CheckboxListTile(
                title: const Text('スヌーズ'),
                value: snoozeChk,
                onChanged: (bool? value) {
                  setState(() => snoozeChk = value!);
                  debugPrint('snooze = $value');
                }
              ),
              ElevatedButton(
                  onPressed: () {
                    if (_formKey.currentState!.validate()) {
                      ScaffoldMessenger.of(context).showSnackBar(
                        const SnackBar(content: Text('登録しました')),
                      );
                    }
                  },
                  child: const Text("保存")),
            ],
          )),
    );
  }
}
HajimeHajime

一覧を作った。

ListViewを使うといい感じに一覧画面を組み立てられる。
ListViewの子にListTileを使うと見た目が整う。

ListTileには、リストの先頭や末尾(trailing)にWidgetを埋め込めるので、iOS標準のアラームに合わせて CupertinoSwitch を埋め込んでみた。

トグルを押したら、DBを更新して画面を再描画すれば良さそう。
ここで問題が...どうやって画面を最新化すればよいか?

最終的には、データをStateに持たせ、それを強制的に更新させればUIが更新される感じになる。
これでトグルの操作がDB-UIで同期して操作できるようになった。

初回読み込みはFutureBuilderFuture

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:posealarm/model.dart';
import '../objectbox.g.dart';

class AlarmEntryListPage extends StatefulWidget {
  const AlarmEntryListPage({Key? key}) : super(key: key);

  
  State<AlarmEntryListPage> createState() => _AlarmEntryListPageState();
}

class _AlarmEntryListPageState extends State<AlarmEntryListPage> {

  List<AlarmEntry> alarmEntries = [];

  
  void initState() {
    super.initState();
    debugPrint("initState");
    _updateAlarmEntries();
  }

  Future<List<AlarmEntry>> _queryAlarmEntries() async {
    debugPrint("_queryAlarmEntries()");
    final store = await openStore();
    final alarmEntryBox = store.box<AlarmEntry>();
    final _alarmEntries = alarmEntryBox.getAll();
    store.close();
    return _alarmEntries;
  }

  Future<void> _updateAlarmEntries() async {
    debugPrint("_updateAlarmEntries()");
    List<AlarmEntry> _alarmEntries = await _queryAlarmEntries();
    setState(() {
      alarmEntries = _alarmEntries;
    });
  }

  Future _toggleActive(alarmEntry) async {
    debugPrint("_toggleActive()");
    final store = await openStore();
    final alarmEntryBox = store.box<AlarmEntry>();
    alarmEntry.active = !alarmEntry.active;
    alarmEntryBox.put(alarmEntry);
    store.close();
    _updateAlarmEntries();
  }

  
  Widget build(BuildContext context) {
    debugPrint("build");
    // return _alarmEntryList(context);
    return FutureBuilder(
        future: _queryAlarmEntries(),
        builder:
            (BuildContext context, AsyncSnapshot<List<AlarmEntry>> snapshot) {
          if (snapshot.hasError) return Text("Error: ${snapshot.error}");
          if (!snapshot.hasData) {
            return const Center(
                child: SizedBox(
                    width: 100,
                    height: 100,
                    child: CircularProgressIndicator(strokeWidth: 5)));
          }
          return _alarmEntryList(context);
        });
  }

  Widget _alarmEntryList(BuildContext context) {
    return ListView.builder(
      itemBuilder: (BuildContext context, int index) {
        var alarmEntry = alarmEntries[index];
        return ListTile(
            title: Text(alarmEntry.dateTime.toLocal().toString()),
            trailing: CupertinoSwitch(
              value: alarmEntry.active,
              onChanged: (bool value) {
                debugPrint("onChanged $value");
                _toggleActive(alarmEntry);
              },
            ));
      },
      itemCount: alarmEntries.length,
    );
  }
}
HajimeHajime

IDをキーとして Dismissible ウィジェットに割り当てていた。

Dismissible(
    key: Key(alarmEntry.id.toString()),
    ...
)

特定の条件下で下記のエラーが画面に出力される問題が発生。

ObjectBoxは、保存されているID+1の値を新しいオブジェクトに割り当てる。もし、ID2を一度削除した後、再度新しいオブジェクトを登録するとID2を使い回す。このせいで Dismissible ウィジェットで指定するキーが重複してしまい、エラーが発生していた。

ObjectBoxにおけるIDの払い出しルールは公式サイトに記載があった。
https://docs.objectbox.io/advanced/object-ids#object-id-assignment-default

対策としてUniqueKeyを使うようにした。
多分、問題ないよね...?

Dismissible(
    key: key: UniqueKey(),
    ...
)
HajimeHajime

アラームを鳴らすところで罠があった...

  • iOSではバックグラウンドで1分おきに定期実行するようなしくみを実装できない(できても最低15分間隔)

この記事に色々書いてあった。MethodChannelとは...ここを深掘りすれば、iOSネイティブでバックグラウンドアラーム機能を実装できるのかも。
https://qiita.com/Sashiiii111/items/763ac2b3ce6c95860f81

HajimeHajime

Writing custom platform-specific code

プラットフォーム固有のコードをFlutterアプリから実行するための仕組み。

MethodChannel
メッセージと応答は非同期
ブール値、数値、文字列、バイトバッファ、これらのリストとマップなどの単純なJSONのような値の効率的なバイナリシリアル化をサポートする標準のメッセージコーデックを使用します。

Pigonややこしい。

Swiftの調査
やはりバックグラウンドに移行した後のアラームは落とし穴があるようだ。

BackgroundTaskという仕組みではアラームのような定刻起動は厳しそう...
https://qiita.com/chocoyama/items/61dc07bf3312f62fc565

HajimeHajime

これやっぱ無理なのかな...

やりたいことは
・夜アラームを設定して、朝アラームを起動
・アラームの起動と同時に音を鳴らす

⇨朝までアプリがキルされないようにする必要がある

朝までアプリがキルされないようにするには
・録音し続けるアプリにする←審査が厳しい
・無音を再生しつづける←できるのか不明
・他...案なし