かってに通知メモ v1.1.2 開発記録:ジャスト時刻通知の実装

「かってに通知メモ」v1.1.2 をリリースした際の開発記録です。今回は「ユーザーから寄せられたフィードバック」が機能追加の起点となり、実装の過程で iOS の制約に直面しながら、最終的に動線をシンプル化することで解決に至った、という流れでした。

実装中に検討したことや、判断の根拠を後から振り返れるように記録しておきます。

目次

v1.1.2 で追加した機能

  • ジャスト時刻にも通知(事前通知+予定時刻の2回鳴る)
  • 「そのうち」「いつか」のデフォルト日数を設定画面から変更可能
  • 通知バナー長押しのアクションを廃止し、通知タップで大画面に統一

きっかけ:ユーザーからのフィードバック

きっかけは、友人がアプリを使っていて気付いた違和感でした。30分後に予定を入れて事前通知(15分前)を受け取ったあと、「てっきり予定時刻にもう一度通知が来ると思っていた」とのこと。Google カレンダーは事前通知+予定時刻の2回鳴るので、その挙動を期待していたそうです。

確かに「30分後に予定を入れたのに15分後の通知だけで終わる」のは不親切に感じます。Google カレンダーと同じ振る舞いを期待するのは自然な流れなので、ジャスト時刻にも通知する機能を追加することにしました。

設計:通知ID の管理方針

flutter_local_notifications では通知に整数IDを付けて管理します。1つのメモから2件の通知(事前通知+ジャスト通知)を出すため、IDの管理方針を決める必要がありました。

既存の ID 管理ルールはこんな感じでした:

種類ID
通常の通知memoId % 100000(baseId と呼ぶ)
しつこい通知baseId + i * 10000(i=1, 2, 3…)

しつこい通知が + 10000 〜 + 90000 の範囲を使うので、ジャスト通知用には衝突しない値が必要。安全策として + 500000 を採用しました。

種類ID
事前通知baseId
ジャスト通知baseId + 500000
しつこい通知baseId + i * 10000

これで衝突を避けつつ、メモごとに2件をペアで管理できます。

実装:ヘルパーメソッドで複雑さを隠す

通知の予約処理は、メモ追加・編集・時刻設定など複数箇所から呼ばれます。各箇所で「事前通知+ジャスト通知の2件を予約する」ロジックを書くと重複してしまうので、ヘルパーメソッドにまとめました。

Future<void> _scheduleMemoNotifications(Memo memo) async {
  if (memo.notifyAt == null) return;

  final baseId = int.parse(memo.id) % 100000;
  final justTime = memo.notifyAt!;
  final advanceTime = justTime.subtract(
    Duration(minutes: _notifyBeforeMinutes),
  );
  final now = DateTime.now();
  // 事前通知を出すかどうか
  final hasAdvance = _notifyBeforeMinutes > 0 && advanceTime.isAfter(now);

  if (hasAdvance) {
    await NotificationService.schedule(
      id: baseId,
      title: '(まもなく) ${memo.text}',
      scheduledAt: advanceTime,
      ...
    );
  }
  await NotificationService.schedule(
    id: baseId + NotificationService.kJustOffset,
    title: hasAdvance ? '(時間です) ${memo.text}' : memo.text,
    scheduledAt: justTime,
    ...
  );
}

このメソッドが解決してくれること:

  • 事前通知時刻が過去になる場合(例:3分後の予定で15分前通知)は、自動的にジャスト通知のみ予約
  • notifyBeforeMinutes = 0 の場合もジャスト通知のみ
  • 2件出す場合のみタイトルに「(まもなく)」「(時間です)」を付ける

呼び出し側は await _scheduleMemoNotifications(memo); の1行で済むようになり、コード全体が読みやすくなりました。

苦戦したこと:通知バナーアクションの不安定さ

実装後の動作確認で、想定外の挙動に遭遇しました。

通知バナーを長押しすると「✅完了 / ⏰10分後 / 🕐1時間後」のアクションメニューが出るのですが、ここから「10分後」を選んでも:

  • スヌーズ通知が予約されない
  • ジャスト通知のキャンセルも効かない

調べてみると、iOS の通知アクションはバックグラウンドで実行されるため、flutter_local_notifications 経由の cancel や schedule が安定して動かないことがあるようでした。

一方、通知タップで大画面(NotificationScreen)を開いてからスヌーズした場合は、ちゃんと動作する。フォアグラウンドで実行されるので問題なし、ということですね。

判断:動線を1つに統一する

技術的に頑張れば iOS のバックグラウンド制約を回避する方法もあるかもしれませんが、調査と実装に時間がかかる割にユーザーには伝わらない地味な改善です。

そこで思い切って、通知バナー長押しのアクション機能自体を廃止することにしました。

  • 通知バナーを長押ししても、アクションメニューは出ない
  • タップすると大画面が開く
  • スヌーズや完了は大画面で操作

メリット:

  • バグの根本原因が消える
  • 操作の選択肢が1つに統一されてユーザーが迷わない
  • スヌーズ処理のコードが3箇所→2箇所に減る
  • iOS のバックグラウンド実行制限を回避できる

このアプリのコンセプトは「シンプル」「ズボラ向け」「年配の方も使える」なので、選択肢を減らすほうが価値があると判断しました。

実装は意外とシンプルで、notification_service.dart の _onNotificationResponse を簡素化し、初期化時の notificationCategories を削除するだけ:

// 修正後
static void _onNotificationResponse(NotificationResponse response) async {
  final payload = response.payload;
  if (payload != null) {
    NotificationService.onNotificationTapped?.call(payload);
  }
}

さらに直したUI:スヌーズ後の表示

実機テストしていて、もう1つ気付いた違和感がありました。

スヌーズしたメモを見ると、リスト表示が「14:40 に通知」となっている。でも実際は 14:45 にスヌーズした時間の通知だけが来る。表示と実際の動作がズレていました。

原因は UI 側の表示ロジック。事前通知時刻(ジャスト時刻 – notifyBeforeMinutes 分)を表示していたのですが、スヌーズ後はそもそも事前通知を予約していないので、表示が嘘になっていました。

ここで「スヌーズしたメモは事前通知という機能そのものを無視する」と仕様を整理し、Memo クラスに isSnoozed フラグを追加。

class Memo {
  // ...既存のフィールド
  bool isSnoozed;  // スヌーズされたかどうか

  Memo({
    // ...
    this.isSnoozed = false,
  });
}

スヌーズ時に memo.isSnoozed = true にして、UI 側で分岐:

if (memo.isSnoozed) {
  return '${TimeParser.format(memo.notifyAt!)} に通知';
}
// それ以外は既存ロジック(事前通知時刻 or ジャスト時刻)

ついでに、メモ編集や時刻再設定の際は isSnoozed = false にリセット。これで仕様も UI も内部動作も整合性が取れました。

データの永続化(SharedPreferences)は ?? false で古いデータとの後方互換性を確保:

isSnoozed: json['isSnoozed'] ?? false,

振り返り

今回の開発で得られた学びをいくつか。

1. ユーザーフィードバックの解像度

「ジャスト時刻にも通知が欲しい」というフィードバックの背景には、「Google カレンダーと同じ挙動を期待している」という前提がありました。一見シンプルなリクエストでも、その背後にある期待値を理解すると実装の方針が定まりやすくなります。

2. プラットフォーム制約への向き合い方

iOS の通知バナーアクションが不安定という問題は、フレームワーク側の挙動なので、アプリ側で完全に解決するのは難しいです。こういう場合は「制約を回避する設計」を選ぶほうが現実的でした。

3. シンプルさが信頼を生む

「アクションが3つあるけど、たまに動かない」より「アクションは大画面に統一されていて確実に動く」ほうが、ユーザーから見ると信頼できます。機能を増やすことよりも、確実に動く動線を1つ作ることのほうが価値があると改めて感じました。

4. 表示と実装の整合性

スヌーズ後の表示問題は、機能としては正しく動いていたものの、UI が古いロジックのままで「嘘の情報」を表示していました。isSnoozed のような「状態を表すフラグ」を1つ追加するだけで、機能・UI・データの3つが揃うのは気持ちのいい設計です。

おわりに

「かってに通知メモ」は引き続き、ズボラな人や年配の方が「ちょっと忘れがち」を防ぐためのツールとして開発を続けていきます。バグ報告やご要望があればお気軽にどうぞ。

目次