generated from ttipo/project-base
fix(flutter): 앱을 보는 중 받은 알림 뒤의 봤음을 1.5초 늦춰 N 이 한 통 많지 않게 한다
서버는 N 을 seen_at 의 초부터 같은 초를 넣어 센다(받은 시각이 초 단위). 앱을 보는 중 새 메일 알림을 받자마자 봤음을 보내면 seen_at 이 그 메일과 같은 초가 되어, 목록으로 보고 열지 않은 그 메일이 다음 알림에 다시 세어졌다 (2026-11-08 실기기 — 한 통이 더 왔는데 "새 메일 2통"). 그 봤음만 받은 때 + 1.5초와 30초 간격 가운데 늦은 때에 보낸다. 앱으로 돌아올 때 · 기다리는 동안 뒤로 갈 때는 그대로다. 고친 뒤 실기기에서 봤음이 2초 뒤에 갔고 다음 알림은 "새 메일 1통"이었다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
/// "봤음"(`DELETE …/unseen`)을 보내는 가장 짧은 간격 — 그 안에 또 오면 건너뛰고 간격 끝에 한 번 보낸다
|
||||
const Duration kPushSeenInterval = Duration(seconds: 30);
|
||||
|
||||
/// 앞에서 받은 새 메일 알림 뒤의 "봤음"은 받은 때부터 이만큼 지나야 보낸다.
|
||||
/// 서버는 N 을 seen_at 의 초부터 같은 초도 넣어 세고 메일의 받은 시각은 초 단위라, 그 알림을 일으킨 메일과 같은 초에 봤음을 보내면
|
||||
/// 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기 — 한 통이 더 왔는데 "새 메일 2통"). 1초 넘게 늦추면 늘 다음 초가 된다
|
||||
const Duration kPushSeenAfterMessageDelay = Duration(milliseconds: 1500);
|
||||
|
||||
/// 직원이 켤 때 FCM 등록 소식(FID)을 기다리는 가장 긴 시간 — 넘으면 켜지 못한 것으로 알린다
|
||||
const Duration kPushRegisterWait = Duration(seconds: 15);
|
||||
|
||||
@@ -43,7 +48,8 @@ final NotifierProvider<PushController, PushState> pushControllerProvider =
|
||||
/// 다음 세션의 등록은 풀기 뒤에 줄을 선다 — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 다시 등록한다.
|
||||
/// - **앞으로 옴**: 알림창의 "새 메일 N통"을 지우고, 권한 · 채널을 다시 읽어 맞추고, "봤음"을 보낸다(30초 간격 — 건너뛰면 끝에 한 번).
|
||||
/// 앞에 있는 동안 온 새 메일 알림도 "봤음"을 보내고 메일 모듈에 알린다([pushEventsProvider] — 새 메일 확인을 바로).
|
||||
/// 봤음은 앱이 앞에 있을 때만 보낸다 — 간격 끝을 기다리는 동안 뒤로 가면 그 자리에서 보낸다(그때가 마지막으로 본 때다. 끝까지 기다리면 그사이 온 새 메일까지 본 것으로 센다).
|
||||
/// 그 봤음만은 받은 때 + [kPushSeenAfterMessageDelay] 보다 일찍 보내지 않는다(보내는 때 = 마지막 봤음 + 30초 · 받은 때 + 1.5초 가운데 늦은 쪽).
|
||||
/// 봤음은 앱이 앞에 있을 때만 보낸다 — 기다리는 동안(간격 끝 · 1.5초) 뒤로 가면 그 자리에서 보낸다(그때가 마지막으로 본 때다. 끝까지 기다리면 그사이 온 새 메일까지 본 것으로 센다).
|
||||
/// - **누른 알림**: [pushEventsProvider] 에 값으로 올린다 — 라우터가 가져간다(`core/router/push_navigation.dart`).
|
||||
/// - Firebase 를 띄우지 못한 휴대폰([PushMessaging.isAvailable] 이 거짓)에서는 아무것도 하지 않는다.
|
||||
/// - FID · 주소 · 토큰은 로그에 남기지 않는다.
|
||||
@@ -94,6 +100,9 @@ class PushController extends Notifier<PushState> implements PushRegistrar {
|
||||
// ── 봤음 ──
|
||||
bool _seenPending = false;
|
||||
DateTime? _lastSeenAt;
|
||||
|
||||
/// 빚진 봤음을 일으킨, 앞에서 받은 마지막 새 메일 알림의 때 — 그 뒤 [kPushSeenAfterMessageDelay] 가 지나야 보낸다(보내면 지운다)
|
||||
DateTime? _newMailAt;
|
||||
Timer? _seenTimer;
|
||||
|
||||
PushRegistrationRepository get _repository =>
|
||||
@@ -582,6 +591,8 @@ class PushController extends Notifier<PushState> implements PushRegistrar {
|
||||
ref.read(pushEventsProvider.notifier).add(message, opened: false);
|
||||
if (message.isNewMail && _sessionReady) {
|
||||
_seenPending = true;
|
||||
// 그 메일과 같은 초에 봤음을 보내지 않는다 — 받은 때부터 1.5초 뒤에(kPushSeenAfterMessageDelay)
|
||||
_newMailAt = _clock();
|
||||
_flushSeen();
|
||||
}
|
||||
}
|
||||
@@ -591,7 +602,7 @@ class PushController extends Notifier<PushState> implements PushRegistrar {
|
||||
ref.read(pushEventsProvider.notifier).add(message, opened: true);
|
||||
}
|
||||
|
||||
/// 앱이 뒤로 갔다 — 간격 끝을 기다리던 봤음은 그 자리에서 보내고 타이머는 버린다(마지막으로 본 때가 지금이다)
|
||||
/// 앱이 뒤로 갔다 — 기다리던 봤음(간격 끝 · 받은 알림 뒤 1.5초)은 그 자리에서 보내고 타이머는 버린다(마지막으로 본 때가 지금이다)
|
||||
void _wentToBackground() {
|
||||
final Timer? timer = _seenTimer;
|
||||
if (!ref.mounted || timer == null) return;
|
||||
@@ -604,26 +615,50 @@ class PushController extends Notifier<PushState> implements PushRegistrar {
|
||||
bool get _canSendSeen =>
|
||||
_sessionReady && _serverRow == true && _store.hasSession;
|
||||
|
||||
/// 빚진 "봤음"을 보낸다 — 앱이 앞에 있고 이 세션의 서버 줄이 있을 때만. 30초 안이면 간격 끝에 한 번
|
||||
/// 빚진 "봤음"을 보낸다 — 앱이 앞에 있고 이 세션의 서버 줄이 있을 때만. 30초 안이면 간격 끝에 한 번,
|
||||
/// 앞에서 받은 새 메일 알림 뒤면 받은 때 + 1.5초보다 일찍 보내지 않는다([_seenWait]).
|
||||
/// 타이머는 하나 — 기다리는 사이 더 늦은 때가 생기면(또 받은 알림) 타이머가 끝날 때 다시 재어 그만큼 더 기다린다
|
||||
void _flushSeen() {
|
||||
if (!_seenPending || !ref.mounted || _seenTimer != null) return;
|
||||
// 뒤에 있으면 보내지 않는다 — 앞으로 올 때 다시 빚진다
|
||||
if (!_canSendSeen || !ref.read(appForegroundProvider)) return;
|
||||
final DateTime now = _clock();
|
||||
final DateTime? last = _lastSeenAt;
|
||||
final Duration? since = last == null ? null : now.difference(last);
|
||||
if (since == null || since.isNegative || since >= kPushSeenInterval) {
|
||||
final Duration wait = _seenWait(now);
|
||||
if (wait <= Duration.zero) {
|
||||
_sendSeen(now);
|
||||
return;
|
||||
}
|
||||
_seenTimer = Timer(kPushSeenInterval - since, () {
|
||||
_seenTimer = Timer(wait, () {
|
||||
_seenTimer = null;
|
||||
_flushSeen();
|
||||
});
|
||||
}
|
||||
|
||||
/// 지금부터 봤음을 보낼 때까지 — 마지막 봤음 + 30초와 앞에서 받은 새 메일 알림 + 1.5초 가운데 늦은 쪽.
|
||||
/// 시계가 거꾸로 갔으면 그 기다림은 지난 것으로 본다(거꾸로 간 만큼 늦어지지 않게)
|
||||
Duration _seenWait(DateTime now) {
|
||||
Duration wait = Duration.zero;
|
||||
final DateTime? last = _lastSeenAt;
|
||||
if (last != null) {
|
||||
final Duration since = now.difference(last);
|
||||
if (!since.isNegative && since < kPushSeenInterval) {
|
||||
wait = kPushSeenInterval - since;
|
||||
}
|
||||
}
|
||||
final DateTime? received = _newMailAt;
|
||||
if (received != null) {
|
||||
final Duration since = now.difference(received);
|
||||
if (!since.isNegative && since < kPushSeenAfterMessageDelay) {
|
||||
final Duration rest = kPushSeenAfterMessageDelay - since;
|
||||
if (rest > wait) wait = rest;
|
||||
}
|
||||
}
|
||||
return wait;
|
||||
}
|
||||
|
||||
void _sendSeen(DateTime now) {
|
||||
_seenPending = false;
|
||||
_newMailAt = null;
|
||||
_lastSeenAt = now;
|
||||
unawaited(
|
||||
_repository.markSeen().then<void>(
|
||||
@@ -636,6 +671,7 @@ class PushController extends Notifier<PushState> implements PushRegistrar {
|
||||
|
||||
void _cancelSeen() {
|
||||
_seenPending = false;
|
||||
_newMailAt = null;
|
||||
_seenTimer?.cancel();
|
||||
_seenTimer = null;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,38 @@ void main() {
|
||||
..set(true);
|
||||
}
|
||||
|
||||
// ── 시험 시계(testWidgets)에서 ──
|
||||
|
||||
/// 시험 시계를 넘기며 조건이 될 때까지 — 네트워크(가짜 서버)는 시험 시계 안에서 차례가 넘어가야 끝난다
|
||||
Future<void> pumpUntil(
|
||||
WidgetTester tester,
|
||||
bool Function() condition,
|
||||
String reason,
|
||||
) async {
|
||||
for (int turn = 0; turn < 300 && !condition(); turn++) {
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
}
|
||||
expect(condition(), isTrue, reason: reason);
|
||||
}
|
||||
|
||||
/// 손 시계와 시험 시계를 함께 넘긴다(간격은 손 시계로 재고 끝은 타이머로 기다린다)
|
||||
Future<void> elapse(WidgetTester tester, Duration duration) async {
|
||||
h.clock.advance(duration);
|
||||
await tester.pump(duration);
|
||||
}
|
||||
|
||||
/// 앞에 있을 때 알림이 온다 — 손 시계를 넘기기 전에 건넨다(받은 때가 지금이 되게)
|
||||
Future<void> receive(WidgetTester tester, PushMessage message) async {
|
||||
h.messaging.receive(message);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// 앱이 뒤로 간다 · 앞으로 온다(따로)
|
||||
void foreground(bool value) =>
|
||||
(container.read(appForegroundProvider.notifier) as FakeForeground).set(
|
||||
value,
|
||||
);
|
||||
|
||||
setUp(() => h = PushHarness());
|
||||
|
||||
group('등록 (PUT — FID 를 받았을 때 · 바뀌었을 때 · 세션이 열릴 때 한 번)', () {
|
||||
@@ -460,23 +492,27 @@ void main() {
|
||||
await until(() => h.push.seen.length == 2, '앞으로 옴');
|
||||
});
|
||||
|
||||
test('앞에 있을 때 온 새 메일 알림 — 메일 모듈에 알리고 봤음을 보낸다. 세션 끝 알림은 알리기만', () async {
|
||||
await start();
|
||||
await until(() => h.push.seen.isNotEmpty, '처음 봤음');
|
||||
h.clock.advance(const Duration(seconds: 31));
|
||||
testWidgets(
|
||||
'앞에 있을 때 온 새 메일 알림 — 메일 모듈에 알리고 1.5초 뒤 봤음을 보낸다. 세션 끝 알림은 알리기만',
|
||||
(WidgetTester tester) async {
|
||||
await start();
|
||||
await pumpUntil(tester, () => h.push.seen.length == 1, '처음 봤음');
|
||||
await elapse(tester, const Duration(seconds: 31));
|
||||
|
||||
h.messaging.receive(kNewMailPush);
|
||||
await until(() => h.push.seen.length == 2, '받은 알림 봤음');
|
||||
final PushEvent? event = container.read(pushEventsProvider);
|
||||
expect(event?.opened, isFalse);
|
||||
expect(event?.message.isNewMail, isTrue);
|
||||
await receive(tester, kNewMailPush);
|
||||
final PushEvent? event = container.read(pushEventsProvider);
|
||||
expect(event?.opened, isFalse);
|
||||
expect(event?.message.isNewMail, isTrue);
|
||||
await elapse(tester, kPushSeenAfterMessageDelay);
|
||||
await pumpUntil(tester, () => h.push.seen.length == 2, '받은 알림 봤음');
|
||||
|
||||
h.clock.advance(const Duration(seconds: 31));
|
||||
h.messaging.receive(kSessionEndedPush);
|
||||
await idle();
|
||||
expect(h.push.seen, hasLength(2));
|
||||
expect(container.read(pushEventsProvider)?.message.isNewMail, isFalse);
|
||||
});
|
||||
await elapse(tester, const Duration(seconds: 31));
|
||||
await receive(tester, kSessionEndedPush);
|
||||
await elapse(tester, const Duration(seconds: 2));
|
||||
expect(h.push.seen, hasLength(2));
|
||||
expect(container.read(pushEventsProvider)?.message.isNewMail, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('누른 알림은 라우터가 가져갈 때까지 값으로 든다 — 꺼져 있다가 뜬 알림 · 뒤에서 누른 알림', () async {
|
||||
h.messaging.initial = kNewMailPush;
|
||||
@@ -498,31 +534,7 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('봤음 — 30초 간격 · 건너뛰면 끝에 한 번 (시험 시계)', () {
|
||||
/// 시험 시계를 넘기며 조건이 될 때까지 — 네트워크(가짜 서버)는 시험 시계 안에서 차례가 넘어가야 끝난다
|
||||
Future<void> pumpUntil(
|
||||
WidgetTester tester,
|
||||
bool Function() condition,
|
||||
String reason,
|
||||
) async {
|
||||
for (int turn = 0; turn < 300 && !condition(); turn++) {
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
}
|
||||
expect(condition(), isTrue, reason: reason);
|
||||
}
|
||||
|
||||
/// 손 시계와 시험 시계를 함께 넘긴다(간격은 손 시계로 재고 끝은 타이머로 기다린다)
|
||||
Future<void> elapse(WidgetTester tester, Duration duration) async {
|
||||
h.clock.advance(duration);
|
||||
await tester.pump(duration);
|
||||
}
|
||||
|
||||
/// 앱이 뒤로 간다 · 앞으로 온다(따로)
|
||||
void foreground(bool value) =>
|
||||
(container.read(appForegroundProvider.notifier) as FakeForeground).set(
|
||||
value,
|
||||
);
|
||||
|
||||
group('봤음 — 30초 간격 · 건너뛰면 끝에 한 번 · 받은 알림 뒤 1.5초 (시험 시계)', () {
|
||||
testWidgets('앞에 있는 동안 30초 안에 또 오면 건너뛰고 간격 끝에 한 번 보낸다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
@@ -533,7 +545,8 @@ void main() {
|
||||
comeBack();
|
||||
await pumpUntil(tester, () => h.notifications.statusCalls >= 2, '다시 읽기');
|
||||
await elapse(tester, const Duration(seconds: 5));
|
||||
h.messaging.receive(kNewMailPush);
|
||||
// 받은 때 + 1.5초(16.5초)는 간격 끝(30초)보다 이르다 — 간격 끝 그대로
|
||||
await receive(tester, kNewMailPush);
|
||||
await elapse(tester, const Duration(seconds: 14));
|
||||
expect(h.push.seen, hasLength(1), reason: '30초 안이라 건너뛴다');
|
||||
|
||||
@@ -566,6 +579,107 @@ void main() {
|
||||
await pumpUntil(tester, () => h.push.seen.length == 3, '다시 앞으로 옴');
|
||||
});
|
||||
|
||||
testWidgets('앞에서 받은 새 메일 알림 뒤의 봤음은 1.5초 전에는 보내지 않고 1.5초에 한 번 보낸다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await start();
|
||||
await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음');
|
||||
await elapse(tester, const Duration(seconds: 31));
|
||||
|
||||
// 서버는 seen_at 의 초부터 같은 초까지 센다 — 그 알림을 일으킨 메일과 같은 초에 보내지 않는다
|
||||
await receive(tester, kNewMailPush);
|
||||
await elapse(
|
||||
tester,
|
||||
kPushSeenAfterMessageDelay - const Duration(milliseconds: 1),
|
||||
);
|
||||
expect(h.push.seen, hasLength(1), reason: '받은 때 + 1.5초 전');
|
||||
|
||||
await elapse(tester, const Duration(milliseconds: 1));
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() => h.push.seen.length == 2,
|
||||
'받은 때 + 1.5초에 한 번',
|
||||
);
|
||||
await elapse(tester, const Duration(seconds: 40));
|
||||
expect(h.push.seen, hasLength(2));
|
||||
});
|
||||
|
||||
testWidgets('간격 끝이 받은 때 + 1.5초보다 이르면(기다리는 동안 또 받음) 받은 때 + 1.5초에 한 번 보낸다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await start();
|
||||
await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음');
|
||||
await elapse(tester, const Duration(seconds: 10));
|
||||
await receive(tester, kNewMailPush);
|
||||
await elapse(tester, const Duration(seconds: 19));
|
||||
// 간격 끝(30초) 1초 전에 또 받았다 — 받은 때 + 1.5초(30.5초)가 더 늦다
|
||||
await receive(tester, kNewMailPush);
|
||||
|
||||
await elapse(tester, const Duration(seconds: 1));
|
||||
expect(h.push.seen, hasLength(1), reason: '간격 끝에서 받은 때 + 1.5초로 미룬다');
|
||||
await elapse(tester, const Duration(milliseconds: 499));
|
||||
expect(h.push.seen, hasLength(1));
|
||||
|
||||
await elapse(tester, const Duration(milliseconds: 1));
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() => h.push.seen.length == 2,
|
||||
'받은 때 + 1.5초에 한 번',
|
||||
);
|
||||
await elapse(tester, const Duration(seconds: 40));
|
||||
expect(h.push.seen, hasLength(2), reason: '두 번 받아도 한 번');
|
||||
});
|
||||
|
||||
testWidgets('간격 끝이 받은 때 + 1.5초보다 늦으면 간격 끝에 한 번 보낸다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await start();
|
||||
await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음');
|
||||
await elapse(tester, const Duration(seconds: 10));
|
||||
|
||||
await receive(tester, kNewMailPush);
|
||||
await elapse(tester, const Duration(seconds: 2));
|
||||
expect(h.push.seen, hasLength(1), reason: '1.5초가 지나도 30초 간격 안이다');
|
||||
await elapse(tester, const Duration(seconds: 17));
|
||||
expect(h.push.seen, hasLength(1));
|
||||
|
||||
await elapse(tester, const Duration(seconds: 1));
|
||||
await pumpUntil(tester, () => h.push.seen.length == 2, '간격 끝에 한 번');
|
||||
await elapse(tester, const Duration(seconds: 40));
|
||||
expect(h.push.seen, hasLength(2));
|
||||
});
|
||||
|
||||
testWidgets('받은 알림 뒤 1.5초를 기다리는 동안 뒤로 가면 그 자리에서 한 번 보내고, 1.5초에는 보내지 않는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await start();
|
||||
await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음');
|
||||
await elapse(tester, const Duration(seconds: 31));
|
||||
await receive(tester, kNewMailPush);
|
||||
await elapse(tester, const Duration(milliseconds: 500));
|
||||
expect(h.push.seen, hasLength(1));
|
||||
|
||||
// 직원이 마지막으로 본 때는 지금이다 — 뒤로 간 뒤에 보내면 그사이 온 새 메일까지 본 것으로 센다
|
||||
foreground(false);
|
||||
await pumpUntil(tester, () => h.push.seen.length == 2, '뒤로 갈 때 한 번');
|
||||
await elapse(tester, const Duration(seconds: 40));
|
||||
expect(h.push.seen, hasLength(2), reason: '뒤에 있는 동안은 보내지 않는다');
|
||||
});
|
||||
|
||||
testWidgets('앞으로 올 때의 봤음은 1.5초를 기다리지 않고 바로 보낸다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await start();
|
||||
await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음');
|
||||
await elapse(tester, const Duration(seconds: 31));
|
||||
|
||||
// 손 시계를 넘기지 않는다 — 기다림이 있으면 보내지 못한다
|
||||
comeBack();
|
||||
await pumpUntil(tester, () => h.push.seen.length == 2, '앞으로 오면 바로');
|
||||
await elapse(tester, const Duration(seconds: 40));
|
||||
expect(h.push.seen, hasLength(2));
|
||||
});
|
||||
|
||||
testWidgets('로그아웃하면 기다리던 봤음을 보내지 않는다', (WidgetTester tester) async {
|
||||
await start();
|
||||
await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음');
|
||||
|
||||
Reference in New Issue
Block a user