/// 오프라인 큐 캐치업(roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11) /// 순수 로직. `DateTime.now()`/타이머에 의존하지 않고 값을 인자로 받는 함수들이라 /// 테스트에서 결정적으로 검증할 수 있다. /// /// 배경: 서버(core-backend)는 모든 메시지를 DB에 그대로 durable하게 저장한다 — /// 클라이언트가 끊겨 있는 동안 온 메시지도 서버에는 이미 있다. 그래서 여기서 /// "큐"를 새로 만드는 게 아니라, 소켓이 재연결되거나 앱이 포그라운드로 돌아올 때 /// 마지막으로 본 메시지 id 이후를 `GET .../messages?since_id=` 로 다시 받아 /// 그 gap을 메우는 전략을 쓴다. 이 파일은 그 재연결 backoff 계산과, REST로 /// 받아온 새 메시지를 이미 있는 목록에 중복 없이 합치는 부분만 담는다. library; import '../models/models.dart'; /// 재연결 시도 backoff의 초기 지연. const Duration kReconnectInitialDelay = Duration(seconds: 1); /// 재연결 시도 backoff의 상한 — 이보다 더 늘어나지 않는다. const Duration kReconnectMaxDelay = Duration(seconds: 30); /// 몇 번째 재연결 시도([attempt], 0-based: 첫 시도가 0)인지에 따른 다음 지연을 /// 계산한다. 1s → 2s → 4s → 8s → 16s → 30s(상한)로 두 배씩 늘고, 상한을 /// 넘지 않는다. 성공적으로 재연결되면 호출부에서 attempt를 0으로 되돌린다. /// [attempt]가 아무리 커져도(재연결이 오래 실패해도) 오버플로 없이 상한에서 /// 멈추도록 시프트 전에 attempt 자체를 안전한 범위로 클램프한다. Duration nextReconnectDelay(int attempt) { if (attempt <= 0) return kReconnectInitialDelay; const maxUsefulAttempt = 10; // 1s * 2^10 = 1024s, 상한(30s)을 한참 넘어 충분. final clamped = attempt > maxUsefulAttempt ? maxUsefulAttempt : attempt; final ms = kReconnectInitialDelay.inMilliseconds * (1 << clamped); final capped = ms > kReconnectMaxDelay.inMilliseconds ? kReconnectMaxDelay.inMilliseconds : ms; return Duration(milliseconds: capped); } /// 이미 로드되어 있는 메시지 목록의 최댓값 id (없으면 null) — 캐치업 요청에 /// `since_id`로 넘길 값을 계산할 때 쓴다. int? highestMessageId(List messages) { if (messages.isEmpty) return null; return messages.map((m) => m.id).reduce((a, b) => a > b ? a : b); } /// [existing] 뒤에 [fresh]에서 아직 없는(id 기준) 메시지만 id 순서로 이어붙인다. /// 소켓으로 실시간 수신한 메시지와 REST 캐치업이 같은 메시지를 중복으로 /// 가져오는 경우(재연결 race)를 여기서 걸러낸다. [existing]의 기존 순서는 /// 그대로 보존한다. List mergeNewMessages( List existing, List fresh, ) { final knownIds = existing.map((m) => m.id).toSet(); final toAdd = fresh.where((m) => !knownIds.contains(m.id)).toList() ..sort((a, b) => a.id.compareTo(b.id)); if (toAdd.isEmpty) return List.of(existing); return [...existing, ...toAdd]; } /// "이 답장 나답아요?" 피드백(vision.md 지표, deploy-checklist.md N4-12) 순수 /// 로직: [messages] 중 이미 평가된(naturalness_rating != null) 메시지 id들만 /// 뽑아낸다. chat_screen.dart는 히스토리를 새로 불러올 때마다(또는 캐치업 /// 때마다) 이 결과를 세션 상태의 "이미 평가됨" 집합에 합쳐 — 서버가 이미 /// 알고 있는 평가는 화면이 다시 그려져도 뱅지/버튼을 다시 보여주지 않는다. Set ratedMessageIdsFrom(List messages) { return messages.where((m) => m.naturalnessRating != null).map((m) => m.id).toSet(); }