Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions lib/core/utils/local_notifications.dart
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,10 @@ const _photoCaptureDueNotificationId = 3;
/// 撮影間隔が来たタイミングで出す通知(issue #107フォローアップ)。
///
/// バナーは画面内表示のため、他のタブ(地図/写真)を見ている・アプリを
/// バックグラウンドにしている等で気づかれないことがある。役割を問わず出す
/// (鬼は撮影ボタン自体が出ない=押しても何も起きないため、通知だけ届いても
/// 実害は無い。むしろ鬼だけ通知が来ないと「気づいていないだけでは」と
/// 混乱させる)。チャンネルを`kakureru_release`/`kakureru_area`と分けるのは、
/// バックグラウンドにしている等で気づかれないことがある。鬼には出さない
/// (鬼は撮影しないため。代わりに逃走者が撮ったことを
/// [showPhotoTakenNotification]で知らせる。issue #120)。出し分けは呼び出し側
/// (`shouldNotifyPhotoCaptureDue`)で行う。チャンネルを`kakureru_release`/`kakureru_area`と分けるのは、
/// こちらは1ゲーム中に何度も繰り返し出るため、性格が違う通知と一緒に
/// 端末側の設定をいじられたくないため。失敗してもログに残すだけ。
Future<void> showPhotoCaptureDueNotification() {
Expand All @@ -185,3 +185,31 @@ Future<void> showPhotoCaptureDueNotification() {
what: '撮影タイミングの通知',
);
}

/// 逃走者が写真を撮ったことを鬼に知らせる通知のID。他の通知とは別に取る。
const _photoTakenNotificationId = 4;

/// 逃走者が足元の写真を撮ったとき、鬼の端末に出す通知(issue #120)。
///
/// 鬼は写真を撮らないが全員の写真を見られる(`photoTileVisibilityOf`)。
/// 新しい写真が来たことに気づけるよう、画面OFFやバックグラウンドでも
/// 届く端末通知で知らせる。同じIDで出すので、続けて撮られたら最新の
/// 内容に置き換わる(通知が積み上がらない)。失敗してもログに残すだけ。
Future<void> showPhotoTakenNotification(String message) {
const androidDetails = AndroidNotificationDetails(
'kakureru_photo_taken',
'写真の撮影の通知',
channelDescription: '鬼のとき、逃走者が足元の写真を撮ったら通知します',
importance: Importance.high,
priority: Priority.high,
);
return _runOrLogFailure(
() => _plugin.show(
id: _photoTakenNotificationId,
title: 'かくれんぼ',
body: message,
notificationDetails: const NotificationDetails(android: androidDetails),
),
what: '写真の撮影の通知',
);
}
112 changes: 112 additions & 0 deletions lib/features/room/photo_taken_notifications.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:kakureru/core/utils/local_notifications.dart';
import 'package:kakureru/features/room/model/room_photo.dart';
import 'package:kakureru/features/room/model/room_user.dart';
import 'package:kakureru/features/room/view/game/game_view_helpers.dart';
import 'package:kakureru/features/room/view_model/room_view_model.dart';

/// 撮影タイミングの通知(「足元の写真を撮ってください」)を出すか。
///
/// 鬼は撮影しないので出さない(issue #120)。役割がまだ分からない間は、
/// 逃走者が気づけないほうが困るので出す。
bool shouldNotifyPhotoCaptureDue(UserRole? role) => role != UserRole.demon;

/// 新しく撮られた写真のうち、鬼である自分に知らせるべきものを返す
/// (issue #120)。
///
/// - [previousIds]がnull(最初に一覧を受け取ったとき)は何も知らせない。
/// 途中参加やアプリの再起動で、過去の写真をまとめて知らせないため
/// - 自分が鬼でなければ知らせない
/// - 自分が撮った写真は知らせない
List<RoomPhoto> photosToNotify({
required Set<String>? previousIds,
required List<RoomPhoto> photos,
required String? myUid,
required UserRole? myRole,
}) {
if (previousIds == null) return const [];
if (myRole != UserRole.demon) return const [];
return [
for (final photo in photos)
if (!previousIds.contains(photo.id) && photo.uid != myUid) photo,
];
}

/// 通知の本文。新しい写真[photos]を撮った人の名前を「さん」付けで並べる。
///
/// 同じ人が続けて撮っても名前は1回だけ出す。同一人物かどうかは名前では
/// なくuidで判断する(同じ名前の別人を1人にまとめてしまわないため)。
/// 参加者一覧[users]に見つからない人は「誰か」と出す。
String photoTakenMessage(List<RoomPhoto> photos, List<RoomUser> users) {
final uids = <String>{for (final photo in photos) photo.uid};
final names = [for (final uid in uids) '${_displayNameOf(users, uid)}さん'];
return '${names.join('、')}が足元の写真を撮りました';
}

/// 逃走者が写真を撮ったら、鬼の端末に通知とSnackBarで知らせるフック
/// (issue #120)。
///
/// 直前に見た写真のIDをuseRefで持ち、増えた分だけを知らせる。この
/// ウィジェットが破棄されたら追跡もやめてよい一時状態なのでhooksで持つ
/// (AGENTS.mdの状態管理規約、`useLeftUserNotifications`と同じ形)。
void usePhotoTakenNotifications(
WidgetRef ref,
BuildContext context, {
required String roomId,
required String? myUid,
}) {
final previousIds = useRef<Set<String>?>(null);
// 役割と名前を引くためのルーム。他に購読している人がいないと値が
// 読めないので、自分でもwatchして購読を保つ。値そのものは、写真が届いた
// 時点の最新をコールバックの中で読む(buildの時点の値をとっておくと、
// ルームと写真が同時に届いたとき古い役割で判定してしまうため)。
ref.watch(roomStreamProvider(roomId));

// 写真の一覧がすでに読み込まれている(写真タブで先に購読していた等)と、
// ref.listenはその値では呼ばれず、次の更新が基準になってしまう。そこで
// 増えた写真を見落とさないよう、読み込み済みの一覧を先に基準にする。
// まだ読み込み中ならnullのままで、最初に届いた一覧が基準になる。
previousIds.value ??= ref
.read(photosStreamProvider(roomId))
.value
?.map((photo) => photo.id)
.toSet();

ref.listen(photosStreamProvider(roomId), (prev, next) {
final photos = next.value;
if (photos == null) return;
Comment on lines +79 to +81

final users = ref.read(roomStreamProvider(roomId)).value?.users ?? const [];
final newPhotos = photosToNotify(
previousIds: previousIds.value,
photos: photos,
myUid: myUid,
myRole: roleOf(users, myUid),
);
previousIds.value = {for (final photo in photos) photo.id};
if (newPhotos.isEmpty) return;

final message = photoTakenMessage(newPhotos, users);
// 画面OFF・バックグラウンドでも気づけるよう、端末通知は必ず出す。
unawaited(showPhotoTakenNotification(message));
// 画面を見ているときはSnackBarでも出す。写真の拡大表示など別の画面を
// 上に開いているときは出さない(useLeftUserNotificationsと同じガード)。
if (context.mounted && (ModalRoute.of(context)?.isCurrent ?? false)) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
});
}

/// 通知文に出す名前。参加者一覧に見つからなければ「誰か」で代替する。
String _displayNameOf(List<RoomUser> users, String uid) {
for (final user in users) {
if (user.id == uid) return user.displayName;
}
return '誰か';
}
8 changes: 8 additions & 0 deletions lib/features/room/view/game_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import 'package:kakureru/features/room/game_session.dart';
import 'package:kakureru/features/room/model/room_user.dart';
import 'package:kakureru/features/room/opponent_roster_status.dart';
import 'package:kakureru/features/room/photo_capture_config.dart';
import 'package:kakureru/features/room/photo_taken_notifications.dart';
import 'package:kakureru/features/room/repository/event_log_repository.dart';
import 'package:kakureru/features/room/restart_recovery.dart';
import 'package:kakureru/features/room/role_theme.dart';
Expand Down Expand Up @@ -178,6 +179,10 @@ class GamePage extends HookConsumerWidget {
lastPhotoAt: myUid == null
? null
: findUser(room?.users ?? const [], myUid)?.lastPhotoAt,
// 鬼は撮影しないので「撮ってください」の通知は出さない(issue #120)。
notifyWhenDue: shouldNotifyPhotoCaptureDue(
roleOf(room?.users ?? const [], myUid),
),
);

// GPSの実測(getPositionStream)は初回の測位に時間がかかる(コールドスタート)。
Expand Down Expand Up @@ -219,6 +224,9 @@ class GamePage extends HookConsumerWidget {
// 誰かが鬼になったらSnackBarで全員に知らせる。
useDemonChangeNotifications(ref, context, roomId: roomId, myUid: myUid);

// 鬼のとき、逃走者が写真を撮ったら通知とSnackBarで知らせる(issue #120)。
usePhotoTakenNotifications(ref, context, roomId: roomId, myUid: myUid);

// プレイエリア外のアラート(issue #61 / UI改修モック2a-07)。
//
// 判定・振動・通知はすべて GameAlerts 側で回っている(issue #71)。画面が
Expand Down
20 changes: 16 additions & 4 deletions lib/features/room/view_model/photo_capture_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ class PhotoCaptureController {
/// を渡す。バックグラウンドでの自動撮影は行わず、間隔が来たら
/// [PhotoCaptureState.isDue]をtrueにしてバナー表示を促すだけ(Phase 1)。
///
/// [notifyWhenDue]がfalseなら、間隔が来ても端末通知は出さない(鬼は撮影
/// しないため。issue #120)。途中で鬼になることがあるので、タイマーが
/// 発火した時点の値を使う。
///
/// このタイマー/アップロード状態はGamePageが消えたら一緒に消えてよい
/// (次に入った時はlastPhotoAtから間隔を復元できる)ため、AGENTS.mdの
/// 判断基準に従いRiverpodではなくhooksで持つ(useGameSessionが束ねる
Expand All @@ -56,8 +60,17 @@ PhotoCaptureController usePhotoCaptureController(
required String? myUid,
required int intervalSec,
required int? lastPhotoAt,
required bool notifyWhenDue,
}) {
final stateHook = useState(const PhotoCaptureState());
// タイマーのコールバックは作った時点の引数を閉じ込めるので、最新の値を
// 参照できるようにrefへ入れておく。
final notifyWhenDueRef = useRef(notifyWhenDue)..value = notifyWhenDue;
void notifyDueIfNeeded() {
if (!notifyWhenDueRef.value) return;
unawaited(showPhotoCaptureDueNotification());
}

final repository = useMemoized(PhotoRepository.new, const []);
final dueTimerRef = useRef<Timer?>(null);

Expand All @@ -70,9 +83,8 @@ PhotoCaptureController usePhotoCaptureController(
if (!context.mounted) return;
stateHook.value = stateHook.value.copyWith(isDue: true);
// バナーは他のタブを見ている・バックグラウンド中だと気づかれない
// ため、通知でも知らせる(役割は問わない。鬼は撮影ボタン自体が
// 出ないだけで、通知が来ても実害は無い)。
unawaited(showPhotoCaptureDueNotification());
// ため、通知でも知らせる(鬼には出さない)。
notifyDueIfNeeded();
},
);
}
Expand All @@ -89,7 +101,7 @@ PhotoCaptureController usePhotoCaptureController(
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
stateHook.value = stateHook.value.copyWith(isDue: true);
unawaited(showPhotoCaptureDueNotification());
notifyDueIfNeeded();
});
} else {
scheduleDueTimer(nextDueAtMillis);
Expand Down
12 changes: 12 additions & 0 deletions test/core/utils/local_notifications_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,16 @@ void main() {
expect(logs, isNotEmpty);
});
});

test('写真の撮影の通知は、撮影タイミングの通知とも別のIDで出す (issue #120)', () async {
await showPhotoCaptureDueNotification();
await showPhotoTakenNotification('たろうさんが足元の写真を撮りました');

expect(methods(), ['show', 'show']);
final dueId = (calls[0].arguments as Map<Object?, Object?>)['id'];
final takenId = (calls[1].arguments as Map<Object?, Object?>)['id'];
// 同じIDだと、鬼になった直後などに互いの通知を上書きし合う。
expect(takenId, isNot(dueId));
expect(takenId, isNot(anyOf(0, 1, 2)));
});
}
Loading
Loading