From fb7058d4f9829940d1068687b5a5556859b85294 Mon Sep 17 00:00:00 2001 From: rinyaaa <168408089+rinyaaa@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:59:50 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E9=80=83=E8=B5=B0=E8=80=85?= =?UTF-8?q?=E3=81=8C=E5=86=99=E7=9C=9F=E3=82=92=E6=92=AE=E3=81=A3=E3=81=9F?= =?UTF-8?q?=E3=82=89=E9=AC=BC=E3=81=AB=E9=80=9A=E7=9F=A5=E3=81=99=E3=82=8B?= =?UTF-8?q?=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 鬼のとき、新しく撮られた他人の写真があれば端末通知とSnackBarで 知らせる(usePhotoTakenNotifications)。最初に受け取った一覧は基準に するだけで通知しない - 鬼は撮影しないので、撮影タイミングの通知は鬼には出さない。途中で 鬼になった場合も、タイマー発火時点の役割に従う Co-Authored-By: Claude Opus 5.5 --- lib/core/utils/local_notifications.dart | 36 +++- .../room/photo_taken_notifications.dart | 102 +++++++++ lib/features/room/view/game_page.dart | 8 + .../view_model/photo_capture_controller.dart | 20 +- test/core/utils/local_notifications_test.dart | 12 ++ .../room/photo_taken_notifications_test.dart | 201 ++++++++++++++++++ .../photo_capture_controller_test.dart | 47 +++- 7 files changed, 416 insertions(+), 10 deletions(-) create mode 100644 lib/features/room/photo_taken_notifications.dart create mode 100644 test/features/room/photo_taken_notifications_test.dart diff --git a/lib/core/utils/local_notifications.dart b/lib/core/utils/local_notifications.dart index a1945da..79888a1 100644 --- a/lib/core/utils/local_notifications.dart +++ b/lib/core/utils/local_notifications.dart @@ -161,10 +161,10 @@ const _photoCaptureDueNotificationId = 3; /// 撮影間隔が来たタイミングで出す通知(issue #107フォローアップ)。 /// /// バナーは画面内表示のため、他のタブ(地図/写真)を見ている・アプリを -/// バックグラウンドにしている等で気づかれないことがある。役割を問わず出す -/// (鬼は撮影ボタン自体が出ない=押しても何も起きないため、通知だけ届いても -/// 実害は無い。むしろ鬼だけ通知が来ないと「気づいていないだけでは」と -/// 混乱させる)。チャンネルを`kakureru_release`/`kakureru_area`と分けるのは、 +/// バックグラウンドにしている等で気づかれないことがある。鬼には出さない +/// (鬼は撮影しないため。代わりに逃走者が撮ったことを +/// [showPhotoTakenNotification]で知らせる。issue #120)。出し分けは呼び出し側 +/// (`shouldNotifyPhotoCaptureDue`)で行う。チャンネルを`kakureru_release`/`kakureru_area`と分けるのは、 /// こちらは1ゲーム中に何度も繰り返し出るため、性格が違う通知と一緒に /// 端末側の設定をいじられたくないため。失敗してもログに残すだけ。 Future showPhotoCaptureDueNotification() { @@ -185,3 +185,31 @@ Future showPhotoCaptureDueNotification() { what: '撮影タイミングの通知', ); } + +/// 逃走者が写真を撮ったことを鬼に知らせる通知のID。他の通知とは別に取る。 +const _photoTakenNotificationId = 4; + +/// 逃走者が足元の写真を撮ったとき、鬼の端末に出す通知(issue #120)。 +/// +/// 鬼は写真を撮らないが全員の写真を見られる(`photoTileVisibilityOf`)。 +/// 新しい写真が来たことに気づけるよう、画面OFFやバックグラウンドでも +/// 届く端末通知で知らせる。同じIDで出すので、続けて撮られたら最新の +/// 内容に置き換わる(通知が積み上がらない)。失敗してもログに残すだけ。 +Future 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: '写真の撮影の通知', + ); +} diff --git a/lib/features/room/photo_taken_notifications.dart b/lib/features/room/photo_taken_notifications.dart new file mode 100644 index 0000000..93c84eb --- /dev/null +++ b/lib/features/room/photo_taken_notifications.dart @@ -0,0 +1,102 @@ +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 photosToNotify({ + required Set? previousIds, + required List 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, + ]; +} + +/// 通知の本文。撮った人の名前を「さん」付けで並べる。 +/// +/// 同じ人が続けて撮っても名前は1回だけ出す。 +String photoTakenMessage(List displayNames) { + final names = {for (final name in displayNames) '$nameさん'}; + 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?>(null); + // 役割と名前を引くための参加者一覧。listenのコールバックで`ref.read` + // すると、他に購読している人がいないとき値がまだ無い。自分でwatchして + // 最新の一覧をrefに入れておく。 + final latestUsers = useRef>(const []); + latestUsers.value = + ref.watch(roomStreamProvider(roomId)).value?.users ?? latestUsers.value; + + ref.listen(photosStreamProvider(roomId), (prev, next) { + final photos = next.value; + if (photos == null) return; + + final users = latestUsers.value; + 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([ + for (final photo in newPhotos) _displayNameOf(users, photo.uid), + ]); + // 画面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 users, String uid) { + for (final user in users) { + if (user.id == uid) return user.displayName; + } + return '誰か'; +} diff --git a/lib/features/room/view/game_page.dart b/lib/features/room/view/game_page.dart index e6ba7ab..c854134 100644 --- a/lib/features/room/view/game_page.dart +++ b/lib/features/room/view/game_page.dart @@ -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'; @@ -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)は初回の測位に時間がかかる(コールドスタート)。 @@ -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)。画面が diff --git a/lib/features/room/view_model/photo_capture_controller.dart b/lib/features/room/view_model/photo_capture_controller.dart index a6434b0..84e9ab0 100644 --- a/lib/features/room/view_model/photo_capture_controller.dart +++ b/lib/features/room/view_model/photo_capture_controller.dart @@ -45,6 +45,10 @@ class PhotoCaptureController { /// を渡す。バックグラウンドでの自動撮影は行わず、間隔が来たら /// [PhotoCaptureState.isDue]をtrueにしてバナー表示を促すだけ(Phase 1)。 /// +/// [notifyWhenDue]がfalseなら、間隔が来ても端末通知は出さない(鬼は撮影 +/// しないため。issue #120)。途中で鬼になることがあるので、タイマーが +/// 発火した時点の値を使う。 +/// /// このタイマー/アップロード状態はGamePageが消えたら一緒に消えてよい /// (次に入った時はlastPhotoAtから間隔を復元できる)ため、AGENTS.mdの /// 判断基準に従いRiverpodではなくhooksで持つ(useGameSessionが束ねる @@ -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(null); @@ -70,9 +83,8 @@ PhotoCaptureController usePhotoCaptureController( if (!context.mounted) return; stateHook.value = stateHook.value.copyWith(isDue: true); // バナーは他のタブを見ている・バックグラウンド中だと気づかれない - // ため、通知でも知らせる(役割は問わない。鬼は撮影ボタン自体が - // 出ないだけで、通知が来ても実害は無い)。 - unawaited(showPhotoCaptureDueNotification()); + // ため、通知でも知らせる(鬼には出さない)。 + notifyDueIfNeeded(); }, ); } @@ -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); diff --git a/test/core/utils/local_notifications_test.dart b/test/core/utils/local_notifications_test.dart index eb84d3f..39bb4cb 100644 --- a/test/core/utils/local_notifications_test.dart +++ b/test/core/utils/local_notifications_test.dart @@ -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)['id']; + final takenId = (calls[1].arguments as Map)['id']; + // 同じIDだと、鬼になった直後などに互いの通知を上書きし合う。 + expect(takenId, isNot(dueId)); + expect(takenId, isNot(anyOf(0, 1, 2))); + }); } diff --git a/test/features/room/photo_taken_notifications_test.dart b/test/features/room/photo_taken_notifications_test.dart new file mode 100644 index 0000000..567b236 --- /dev/null +++ b/test/features/room/photo_taken_notifications_test.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:kakureru/features/room/model/room.dart'; +import 'package:kakureru/features/room/model/room_photo.dart'; +import 'package:kakureru/features/room/model/room_setting.dart'; +import 'package:kakureru/features/room/model/room_user.dart'; +import 'package:kakureru/features/room/photo_taken_notifications.dart'; +import 'package:kakureru/features/room/view_model/room_view_model.dart'; + +const _me = 'me'; + +RoomPhoto _photo(String id, String uid) => + RoomPhoto(id: id, uid: uid, takenAt: 0); + +const _roomId = 'room1'; + +class _Harness extends HookConsumerWidget { + const _Harness(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + usePhotoTakenNotifications(ref, context, roomId: _roomId, myUid: _me); + return const Scaffold(body: SizedBox()); + } +} + +/// 自分の役割が[myRole]のルームで、写真の一覧を[photos]から流す地図なしの +/// 画面を立ち上げる。 +Future _pumpHarness( + WidgetTester tester, { + required UserRole myRole, + required StreamController> photos, +}) async { + final room = Room( + id: _roomId, + roomCode: '1234', + hostUserId: _me, + status: RoomStatus.playing, + createdAt: 0, + setting: const RoomSetting(), + users: [ + RoomUser(id: _me, displayName: 'わたし', role: myRole), + const RoomUser(id: 'a', displayName: 'たろう'), + ], + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + roomStreamProvider(_roomId).overrideWith((ref) => Stream.value(room)), + photosStreamProvider(_roomId).overrideWith((ref) => photos.stream), + ], + child: const MaterialApp(home: _Harness()), + ), + ); + await tester.pump(); +} + +void main() { + group('photosToNotify (issue #120)', () { + test('最初に受け取った一覧は基準にするだけで、何も知らせない', () { + // 途中参加やアプリの再起動で、過去の写真をまとめて知らせないため。 + final result = photosToNotify( + previousIds: null, + photos: [_photo('p1', 'a'), _photo('p2', 'b')], + myUid: _me, + myRole: UserRole.demon, + ); + expect(result, isEmpty); + }); + + test('鬼なら、新しく増えた他人の写真を知らせる', () { + final result = photosToNotify( + previousIds: {'p1'}, + photos: [_photo('p1', 'a'), _photo('p2', 'b')], + myUid: _me, + myRole: UserRole.demon, + ); + expect(result.map((p) => p.id), ['p2']); + }); + + test('同時に複数枚増えたら、全部を知らせる', () { + final result = photosToNotify( + previousIds: {'p1'}, + photos: [_photo('p1', 'a'), _photo('p2', 'b'), _photo('p3', 'c')], + myUid: _me, + myRole: UserRole.demon, + ); + expect(result.map((p) => p.id), ['p2', 'p3']); + }); + + test('自分が撮った写真は知らせない', () { + final result = photosToNotify( + previousIds: const {}, + photos: [_photo('p1', _me)], + myUid: _me, + myRole: UserRole.demon, + ); + expect(result, isEmpty); + }); + + test('逃走者には知らせない', () { + final result = photosToNotify( + previousIds: const {}, + photos: [_photo('p1', 'a')], + myUid: _me, + myRole: UserRole.fugitive, + ); + expect(result, isEmpty); + }); + + test('役割が分からない間は知らせない', () { + final result = photosToNotify( + previousIds: const {}, + photos: [_photo('p1', 'a')], + myUid: _me, + myRole: null, + ); + expect(result, isEmpty); + }); + }); + + group('photoTakenMessage', () { + test('1人なら名前にさんを付ける', () { + expect(photoTakenMessage(['たろう']), 'たろうさんが足元の写真を撮りました'); + }); + + test('複数人なら読点でつなぎ、同じ人は1回だけ出す', () { + expect( + photoTakenMessage(['たろう', 'はなこ', 'たろう']), + 'たろうさん、はなこさんが足元の写真を撮りました', + ); + }); + }); + + group('shouldNotifyPhotoCaptureDue', () { + test('鬼には撮影タイミングの通知を出さない', () { + expect(shouldNotifyPhotoCaptureDue(UserRole.demon), isFalse); + }); + + test('逃走者と、役割がまだ分からない人には出す', () { + expect(shouldNotifyPhotoCaptureDue(UserRole.fugitive), isTrue); + expect(shouldNotifyPhotoCaptureDue(null), isTrue); + }); + }); + + group('usePhotoTakenNotifications', () { + const channel = MethodChannel('dexterous.com/flutter/local_notifications'); + late List calls; + + setUp(() { + calls = []; + AndroidFlutterLocalNotificationsPlugin.registerWith(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + testWidgets('鬼なら、逃走者が撮ったときに通知とSnackBarを出す', (tester) async { + final photos = StreamController>.broadcast(); + addTearDown(photos.close); + await _pumpHarness(tester, myRole: UserRole.demon, photos: photos); + + photos.add([_photo('p1', 'a')]); + await tester.pump(); + // 最初の一覧では知らせない。 + expect(calls, isEmpty); + + photos.add([_photo('p1', 'a'), _photo('p2', 'a')]); + await tester.pump(); + + expect(calls.map((c) => c.method), ['show']); + expect(find.text('たろうさんが足元の写真を撮りました'), findsOneWidget); + }); + + testWidgets('逃走者なら、他の人が撮っても何も出さない', (tester) async { + final photos = StreamController>.broadcast(); + addTearDown(photos.close); + await _pumpHarness(tester, myRole: UserRole.fugitive, photos: photos); + + photos + ..add([_photo('p1', 'a')]) + ..add([_photo('p1', 'a'), _photo('p2', 'a')]); + await tester.pump(); + + expect(calls, isEmpty); + expect(find.byType(SnackBar), findsNothing); + }); + }); +} diff --git a/test/features/room/view_model/photo_capture_controller_test.dart b/test/features/room/view_model/photo_capture_controller_test.dart index c118602..7101f3a 100644 --- a/test/features/room/view_model/photo_capture_controller_test.dart +++ b/test/features/room/view_model/photo_capture_controller_test.dart @@ -11,10 +11,15 @@ import 'package:kakureru/features/room/view_model/photo_capture_controller.dart' /// (PhotoRepositoryと違い注入口が無い)、実機かエミュレータ無しでは検証できない。 /// ここでは注入無しに検証できる「間隔が来たらisDueを立てる」タイマー挙動だけを見る。 class _Harness extends HookWidget { - const _Harness({required this.intervalSec, required this.lastPhotoAt}); + const _Harness({ + required this.intervalSec, + required this.lastPhotoAt, + this.notifyWhenDue = true, + }); final int intervalSec; final int? lastPhotoAt; + final bool notifyWhenDue; @override Widget build(BuildContext context) { @@ -24,6 +29,7 @@ class _Harness extends HookWidget { myUid: 'uid1', intervalSec: intervalSec, lastPhotoAt: lastPhotoAt, + notifyWhenDue: notifyWhenDue, ); return Text(controller.state.isDue ? 'due' : 'not-due'); } @@ -33,11 +39,16 @@ Future _pump( WidgetTester tester, { required int intervalSec, required int? lastPhotoAt, + bool notifyWhenDue = true, }) { return tester.pumpWidget( MaterialApp( home: Scaffold( - body: _Harness(intervalSec: intervalSec, lastPhotoAt: lastPhotoAt), + body: _Harness( + intervalSec: intervalSec, + lastPhotoAt: lastPhotoAt, + notifyWhenDue: notifyWhenDue, + ), ), ), ); @@ -128,6 +139,38 @@ void main() { expect(calls.map((c) => c.method), contains('show')); }); + // 鬼は撮影しないので「撮ってください」の通知は出さない(issue #120)。 + testWidgets('通知しない指定なら、間隔が来てもisDueだけ立てて通知は出さない', (tester) async { + await _pump( + tester, + intervalSec: 5, + lastPhotoAt: null, + notifyWhenDue: false, + ); + + await tester.pump(const Duration(seconds: 6)); + + expect(find.text('due'), findsOneWidget); + expect(calls.map((c) => c.method), isNot(contains('show'))); + }); + + testWidgets('タイマーの途中で通知しない指定に変わったら、発火時点の指定に従う', (tester) async { + // 逃走者として始まり、間隔が来る前に鬼になった状況。 + await _pump(tester, intervalSec: 5, lastPhotoAt: null); + await tester.pump(const Duration(seconds: 2)); + await _pump( + tester, + intervalSec: 5, + lastPhotoAt: null, + notifyWhenDue: false, + ); + + await tester.pump(const Duration(seconds: 4)); + + expect(find.text('due'), findsOneWidget); + expect(calls.map((c) => c.method), isNot(contains('show'))); + }); + testWidgets('画面が破棄されるとタイマーは片付き、残タイマーで失敗しない', (tester) async { await _pump(tester, intervalSec: 300, lastPhotoAt: null); From 5cafa7c3c27f5e6bfc017f620cf3879b77e5d21e Mon Sep 17 00:00:00 2001 From: rinyaaa <168408089+rinyaaa@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:46:21 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E5=86=99=E7=9C=9F=E3=81=AE=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E3=81=A7=E5=90=8C=E5=90=8D=E3=81=AE=E5=88=A5=E4=BA=BA?= =?UTF-8?q?=E3=82=92=E3=81=BE=E3=81=A8=E3=82=81=E3=81=AA=E3=81=84=E3=83=BB?= =?UTF-8?q?=E8=AA=AD=E3=81=BF=E8=BE=BC=E3=81=BF=E6=B8=88=E3=81=BF=E3=81=AE?= =?UTF-8?q?=E4=B8=80=E8=A6=A7=E3=82=92=E5=9F=BA=E6=BA=96=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilotのレビュー指摘への対応。 - 通知の本文で重複を除く単位を名前からuidに変えた。同じ名前の別人が 1人にまとまっていた - 写真の一覧がすでに読み込まれた状態で画面が開くと、ref.listenはその値 では呼ばれず、次に増えた写真が基準扱いになって通知が漏れていた。 読み込み済みの一覧を先に基準にする - 役割はbuild時点の値ではなく、写真が届いた時点の最新のルームから読む Co-Authored-By: Claude Opus 5.5 --- .../room/photo_taken_notifications.dart | 38 ++++--- .../room/photo_taken_notifications_test.dart | 100 +++++++++++++++++- 2 files changed, 121 insertions(+), 17 deletions(-) diff --git a/lib/features/room/photo_taken_notifications.dart b/lib/features/room/photo_taken_notifications.dart index 93c84eb..764e1ab 100644 --- a/lib/features/room/photo_taken_notifications.dart +++ b/lib/features/room/photo_taken_notifications.dart @@ -36,11 +36,14 @@ List photosToNotify({ ]; } -/// 通知の本文。撮った人の名前を「さん」付けで並べる。 +/// 通知の本文。新しい写真[photos]を撮った人の名前を「さん」付けで並べる。 /// -/// 同じ人が続けて撮っても名前は1回だけ出す。 -String photoTakenMessage(List displayNames) { - final names = {for (final name in displayNames) '$nameさん'}; +/// 同じ人が続けて撮っても名前は1回だけ出す。同一人物かどうかは名前では +/// なくuidで判断する(同じ名前の別人を1人にまとめてしまわないため)。 +/// 参加者一覧[users]に見つからない人は「誰か」と出す。 +String photoTakenMessage(List photos, List users) { + final uids = {for (final photo in photos) photo.uid}; + final names = [for (final uid in uids) '${_displayNameOf(users, uid)}さん']; return '${names.join('、')}が足元の写真を撮りました'; } @@ -57,18 +60,27 @@ void usePhotoTakenNotifications( required String? myUid, }) { final previousIds = useRef?>(null); - // 役割と名前を引くための参加者一覧。listenのコールバックで`ref.read` - // すると、他に購読している人がいないとき値がまだ無い。自分でwatchして - // 最新の一覧をrefに入れておく。 - final latestUsers = useRef>(const []); - latestUsers.value = - ref.watch(roomStreamProvider(roomId)).value?.users ?? latestUsers.value; + // 役割と名前を引くためのルーム。他に購読している人がいないと値が + // 読めないので、自分でも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; - final users = latestUsers.value; + final users = ref.read(roomStreamProvider(roomId)).value?.users ?? const []; final newPhotos = photosToNotify( previousIds: previousIds.value, photos: photos, @@ -78,9 +90,7 @@ void usePhotoTakenNotifications( previousIds.value = {for (final photo in photos) photo.id}; if (newPhotos.isEmpty) return; - final message = photoTakenMessage([ - for (final photo in newPhotos) _displayNameOf(users, photo.uid), - ]); + final message = photoTakenMessage(newPhotos, users); // 画面OFF・バックグラウンドでも気づけるよう、端末通知は必ず出す。 unawaited(showPhotoTakenNotification(message)); // 画面を見ているときはSnackBarでも出す。写真の拡大表示など別の画面を diff --git a/test/features/room/photo_taken_notifications_test.dart b/test/features/room/photo_taken_notifications_test.dart index 567b236..361a39f 100644 --- a/test/features/room/photo_taken_notifications_test.dart +++ b/test/features/room/photo_taken_notifications_test.dart @@ -125,16 +125,45 @@ void main() { }); group('photoTakenMessage', () { + const users = [ + RoomUser(id: 'a', displayName: 'たろう'), + RoomUser(id: 'b', displayName: 'はなこ'), + RoomUser(id: 'c', displayName: 'たろう'), + ]; + test('1人なら名前にさんを付ける', () { - expect(photoTakenMessage(['たろう']), 'たろうさんが足元の写真を撮りました'); + expect( + photoTakenMessage([_photo('p1', 'a')], users), + 'たろうさんが足元の写真を撮りました', + ); }); - test('複数人なら読点でつなぎ、同じ人は1回だけ出す', () { + test('複数人なら読点でつなぎ、同じ人が続けて撮っても1回だけ出す', () { expect( - photoTakenMessage(['たろう', 'はなこ', 'たろう']), + photoTakenMessage([ + _photo('p1', 'a'), + _photo('p2', 'b'), + _photo('p3', 'a'), + ], users), 'たろうさん、はなこさんが足元の写真を撮りました', ); }); + + // Copilotのレビュー指摘(PR #128)。名前で重複を除くと、同じ名前の + // 別人が1人にまとまってしまう。 + test('同じ名前の別人は、別々に出す', () { + expect( + photoTakenMessage([_photo('p1', 'a'), _photo('p2', 'c')], users), + 'たろうさん、たろうさんが足元の写真を撮りました', + ); + }); + + test('参加者一覧に見つからない人は「誰か」と出す', () { + expect( + photoTakenMessage([_photo('p1', 'unknown')], users), + '誰かさんが足元の写真を撮りました', + ); + }); }); group('shouldNotifyPhotoCaptureDue', () { @@ -184,6 +213,71 @@ void main() { expect(find.text('たろうさんが足元の写真を撮りました'), findsOneWidget); }); + // Copilotのレビュー指摘(PR #128)。写真の一覧がすでに読み込まれた + // 状態で画面が開くと、ref.listenはその値では呼ばれない。基準を + // 取り損ねると、次に増えた写真が基準扱いになり通知が漏れていた。 + testWidgets('写真の一覧が先に読み込まれていても、次に増えた写真を知らせる', (tester) async { + final photos = StreamController>.broadcast(); + addTearDown(photos.close); + final showHook = ValueNotifier(false); + addTearDown(showHook.dispose); + final room = Room( + id: _roomId, + roomCode: '1234', + hostUserId: _me, + status: RoomStatus.playing, + createdAt: 0, + setting: const RoomSetting(), + users: const [ + RoomUser(id: _me, displayName: 'わたし', role: UserRole.demon), + RoomUser(id: 'a', displayName: 'たろう'), + ], + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + roomStreamProvider( + _roomId, + ).overrideWith((ref) => Stream.value(room)), + photosStreamProvider(_roomId).overrideWith((ref) => photos.stream), + ], + child: MaterialApp( + home: Scaffold( + body: Column( + children: [ + // 写真タブのように、先に写真の一覧を購読している画面。 + Consumer( + builder: (context, ref, _) { + ref.watch(photosStreamProvider(_roomId)); + return const SizedBox(); + }, + ), + ValueListenableBuilder( + valueListenable: showHook, + builder: (context, show, _) => show + ? const Expanded(child: _Harness()) + : const SizedBox(), + ), + ], + ), + ), + ), + ), + ); + photos.add([_photo('p1', 'a')]); + await tester.pump(); + + // 一覧が読み込まれた後で、通知のフックを持つ画面が開く。 + showHook.value = true; + await tester.pump(); + expect(calls, isEmpty); + + photos.add([_photo('p1', 'a'), _photo('p2', 'a')]); + await tester.pump(); + + expect(calls.map((c) => c.method), ['show']); + }); + testWidgets('逃走者なら、他の人が撮っても何も出さない', (tester) async { final photos = StreamController>.broadcast(); addTearDown(photos.close); From 36350a79b94bd34c67c179146100f8f562537f94 Mon Sep 17 00:00:00 2001 From: rinyaaa <168408089+rinyaaa@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:46:46 +0900 Subject: [PATCH 3/3] =?UTF-8?q?style:=20=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=81=AE=E9=9D=99=E7=9A=84=E8=A7=A3=E6=9E=90=E3=81=AE=E6=8C=87?= =?UTF-8?q?=E6=91=98=E3=82=92=E7=9B=B4=E3=81=99=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- test/features/room/photo_taken_notifications_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/features/room/photo_taken_notifications_test.dart b/test/features/room/photo_taken_notifications_test.dart index 361a39f..48381b8 100644 --- a/test/features/room/photo_taken_notifications_test.dart +++ b/test/features/room/photo_taken_notifications_test.dart @@ -221,14 +221,14 @@ void main() { addTearDown(photos.close); final showHook = ValueNotifier(false); addTearDown(showHook.dispose); - final room = Room( + const room = Room( id: _roomId, roomCode: '1234', hostUserId: _me, status: RoomStatus.playing, createdAt: 0, - setting: const RoomSetting(), - users: const [ + setting: RoomSetting(), + users: [ RoomUser(id: _me, displayName: 'わたし', role: UserRole.demon), RoomUser(id: 'a', displayName: 'たろう'), ],