답장 마감 알림(2.7-F) 구현: 온디바이스 스누즈 저장 + 인앱 배지 + 로컬 알림(부분 검증)

roadmap.md §2.7-F / deploy-checklist.md N4-C6a~c 대응. "이따 답장"을 누르면
대화방별 스누즈 시각을 순수 온디바이스 drift 테이블(ConversationSnoozes)에
저장하고(서버 전송 없음, 다른 사람에게 절대 보이지 않음), chat_screen.dart
앱바에서 빠른 선택(1시간 후/저녁에/내일)으로 걸고 해제할 수 있게 했다. 실제
답장을 보내면 자동으로 스누즈가 풀린다.

로컬 알림은 flutter_local_notifications+timezone을 실제로 붙여
zonedSchedule()/cancel()을 호출하지만, 이 샌드박스에는 실기기·에뮬레이터가
없어 알림이 실제로 울리는지는 검증하지 못했다 — 검증한 건 스케줄러 호출
인자(id/시각/페이로드)가 목(mock) 기준으로 맞는지뿐이다. 그 대신 항상 켜져
있는 인앱 대체 경로로 conversation_list_screen.dart 배지와 chat_screen.dart
배너가 마감 지난 스누즈를 보여주고 바로 해제할 수 있게 했다. 웹 빌드는
플러그인이 웹을 지원하지 않아 no-op 스텁으로 대체.

마감 판정(isSnoozePastDue)과 빠른 선택 시각 계산은 순수 함수로 분리해
고정 시각으로 단위 테스트했고, SnoozeController를 통해 스케줄러를 목으로
바꿔 넣어 호출 인자도 검증했다. 백엔드(core-backend/ai-service) 변경 없음.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m
This commit is contained in:
Claude 2026-08-03 05:55:30 +00:00
parent f9fba46889
commit 95422bb012
No known key found for this signature in database
19 changed files with 1262 additions and 107 deletions

View File

@ -62,9 +62,18 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
`persona.go` `resolveRelationshipNote()`(그룹은 항상 빈 문자열 — 전역 기본 메모 개념 자체가 `persona.go` `resolveRelationshipNote()`(그룹은 항상 빈 문자열 — 전역 기본 메모 개념 자체가
없어 티어/자율성과 다름), `ai-service/app/generation.py`가 메모를 `[관계 메모]` 프롬프트 없어 티어/자율성과 다름), `ai-service/app/generation.py`가 메모를 `[관계 메모]` 프롬프트
문단으로 주입. 이 김에 세 resolver가 복붙하던 "1:1 상대 Contact 찾기" 루프를 문단으로 주입. 이 김에 세 resolver가 복붙하던 "1:1 상대 Contact 찾기" 루프를
`findCounterpartContact()` 공용 헬퍼로 추출. C6(답장 마감 알림)은 아직 todo. `findCounterpartContact()` 공용 헬퍼로 추출. C6(답장 마감 알림)도 **완료(부분 검증)**
Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능 (2026-08-03, 아직 GitHub `main`에만 있고 프로덕션 미배포, 모바일 온디바이스 전용이라
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음 `core-backend`/`ai-service` 변경 없음) — 순수 온디바이스 스누즈 저장(`ConversationSnoozes`
drift 테이블, 서버 전송 없음) + `chat_screen.dart`/`conversation_list_screen.dart` UI는
완전히 검증됨(단위 테스트). 로컬 알림은 `flutter_local_notifications`/`timezone`을 실제로
붙였지만(`flutter pub get` 성공, `zonedSchedule`/`cancel` 연동) **이 샌드박스에 실기기·
에뮬레이터가 없어 알림이 실제로 울리는지까지는 검증하지 못함** — 검증한 건 스케줄러 호출
인자가 맞는지(목 기반 단위 테스트)뿐이라 실기기 확인 전까지는 대화 목록/채팅방의 인앱
배지·배너가 실질적인 리마인드 경로. **이로써 2026-08-03 2차 갭 분석 배치(C4/C5/C6)는 모두
구현 완료 — 다만 C6의 "실제 OS 알림 발사" 부분은 Android 실기기 탭(N4-5~10)에서 처음
검증되는 항목으로 남는다.** Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능
- 실 FCM 기기 수신 · Android 실기기 탭(답장 마감 알림 실제 발사 확인 포함) · 사람 PoC 실행은 남음
### NEXT 순서 ### NEXT 순서
@ -221,9 +230,9 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
| **N4-C5a** | `draftRequest``RelationshipNote` 필드 추가 | **done** (2026-08-03) | `core-backend/aiservice.go`, `relationship_tier``omitempty`, 빈 문자열 = 무영향 | | **N4-C5a** | `draftRequest``RelationshipNote` 필드 추가 | **done** (2026-08-03) | `core-backend/aiservice.go`, `relationship_tier``omitempty`, 빈 문자열 = 무영향 |
| **N4-C5b** | draft 핸들러가 연락처 메모 조회해 전달 | **done** (2026-08-03) | `main.go` `POST /conversations/:id/draft` + `core-backend/persona.go` `resolveRelationshipNote()`. 그룹은 항상 빈 문자열(전역 기본 메모 개념 자체가 없음, 티어/자율성과 다른 지점). `resolveRelationshipTier`/`resolveAutonomyLevel`과 공유하는 `findCounterpartContact()` 헬퍼로 3중 복붙 제거 | | **N4-C5b** | draft 핸들러가 연락처 메모 조회해 전달 | **done** (2026-08-03) | `main.go` `POST /conversations/:id/draft` + `core-backend/persona.go` `resolveRelationshipNote()`. 그룹은 항상 빈 문자열(전역 기본 메모 개념 자체가 없음, 티어/자율성과 다른 지점). `resolveRelationshipTier`/`resolveAutonomyLevel`과 공유하는 `findCounterpartContact()` 헬퍼로 3중 복붙 제거 |
| **N4-C5c** | 메모를 톤 프롬프트에 주입 | **done** (2026-08-03) | `ai-service/app/generation.py` `system_prompt_for_tier(relationship_tier, relationship_note)``"[관계 메모] {note} -- ..."` 문단 추가(빈 값/`None`이면 무영향, 관계 티어 지침과 별도 문단이라 안 섞임). 에스컬레이션/정체성 게이팅에는 영향 없음 | | **N4-C5c** | 메모를 톤 프롬프트에 주입 | **done** (2026-08-03) | `ai-service/app/generation.py` `system_prompt_for_tier(relationship_tier, relationship_note)``"[관계 메모] {note} -- ..."` 문단 추가(빈 값/`None`이면 무영향, 관계 티어 지침과 별도 문단이라 안 섞임). 에스컬레이션/정체성 게이팅에는 영향 없음 |
| **N4-C6a** | "이따 답장" 스누즈 저장 | todo | 온디바이스 우선 원칙에 맞는 저장 위치 결정 | | **N4-C6a** | "이따 답장" 스누즈 저장 | **done** (2026-08-03) | 순수 온디바이스 — `mobile/lib/db/tables.dart` `ConversationSnoozes` drift 테이블(서버 전송 없음), `chat_screen.dart` 앱바 "이따 답장" → 빠른 선택(1시간 후/저녁에/내일) + 해제, 실제 답장 전송 시 자동 해제 |
| **N4-C6b** | 리마인드 로컬 알림(본인에게만) | todo | 다른 사람에게 노출되지 않음 | | **N4-C6b** | 리마인드 로컬 알림(본인에게만) | **done (부분 검증)** (2026-08-03) | `flutter_local_notifications`+`timezone` 실제 연동(`mobile/lib/services/snooze_notification_service_native.dart` `zonedSchedule`/`cancel`) — **단, 이 샌드박스에는 실기기/에뮬레이터가 없어 알림이 실제로 울리는지/탭 동작은 검증 불가**. 검증한 건 스케줄러를 목(mock)으로 바꿔 id·시각·페이로드가 올바른지뿐(`test/snooze_controller_test.dart`). 웹 빌드는 플러그인이 웹 미지원이라 `snooze_notification_service_web.dart` no-op 스텁. 실기기 검증 전까지는 대화 목록/채팅방의 인앱 배지·배너(C6c)가 실질적인 리마인드 경로 |
| **N4-C6c** | 스누즈 표시/취소 UI | todo | `chat_screen.dart`/`conversation_list_screen.dart` | | **N4-C6c** | 스누즈 표시/취소 UI | **done** (2026-08-03) | `chat_screen.dart`(앱바 아이콘 상태 + 마감 지난 스누즈 배너 + 해제 버튼), `conversation_list_screen.dart`(서브타이틀 아래 "답장 마감" 배지, 탭하여 바로 해제). 마감 판정은 순수 함수 `isSnoozePastDue()`로 분리해 고정 시각 단위 테스트(`test/snooze_service_test.dart`) |
### FCM — [`fcm-setup.md`](./fcm-setup.md) ### FCM — [`fcm-setup.md`](./fcm-setup.md)
@ -286,9 +295,13 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P
~~Track C2 관계별 페르소나~~ **done** (2026-08-03), ~~Track C3 스팸/도배 감지~~ **done** ~~Track C2 관계별 페르소나~~ **done** (2026-08-03), ~~Track C3 스팸/도배 감지~~ **done**
(2026-08-03) — **Track C 콘텐츠 갭 A/B/C 전체 완료.** 2026-08-03 2차 재분석으로 D/E/F 추가 (2026-08-03) — **Track C 콘텐츠 갭 A/B/C 전체 완료.** 2026-08-03 2차 재분석으로 D/E/F 추가
발견, ~~Track C4 자율성 상대별 예외~~ **done** (2026-08-03), ~~Track C5 관계 메모 반영~~ 발견, ~~Track C4 자율성 상대별 예외~~ **done** (2026-08-03), ~~Track C5 관계 메모 반영~~
**done** (2026-08-03). 남은 것: Track C6(답장 마감 알림), **Master FCM 시크릿(N4-1/3)** **done** (2026-08-03), ~~Track C6 답장 마감 알림~~ **done(부분 검증)** (2026-08-03) —
N4-4 스모크 → Android UI QA (N4-5~10), Track C 프로덕션 재배포(C2/C3/C4/C5는 아직 GitHub **2026-08-03 2차 갭 분석 배치(C4/C5/C6) 전체 구현 완료.** C6은 온디바이스 스누즈
`main`에만 있음) 저장·UI는 완전히 검증(단위 테스트)됐지만, 실제 OS 로컬 알림이 실기기에서 울리는지는
이 샌드박스(실기기/에뮬레이터 없음)에서 검증 불가 — Android UI QA(N4-5~10)에서 처음
확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. 남은 것: **Master FCM
시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10, 답장 마감 알림 실기기 확인
포함), Track C 프로덕션 재배포(C2~C6는 아직 GitHub `main`에만 있음)
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -233,11 +233,31 @@
이 둘에는 전혀 영향 없음(순수 톤/금기어 힌트, 안전 게이트 우회 아님) 이 둘에는 전혀 영향 없음(순수 톤/금기어 힌트, 안전 게이트 우회 아님)
**2.7-F 답장 마감 알림** (`PRD.md` §3.2 P1: "내가 '이따 답장' 누르면 나에게만 리마인드" — 코드 **2.7-F 답장 마감 알림** (`PRD.md` §3.2 P1: "내가 '이따 답장' 누르면 나에게만 리마인드" — 코드
전무, 아이디어 회의 문서에만 존재, 2026-08-03 발견) 전무, 아이디어 회의 문서에만 존재, 2026-08-03 발견 — **완료(부분 검증)** 2026-08-03, 아래 참고)
- [ ] 메시지/대화에 "이따 답장" 스누즈 액션 + 리마인드 시각 저장(서버 또는 온디바이스 — 개인 - [x] 메시지/대화에 "이따 답장" 스누즈 액션 + 리마인드 시각 저장 — 순수 온디바이스
전용 알림이라 온디바이스 우선 원칙에 맞는 쪽으로 설계) (`mobile/lib/db/tables.dart`의 `ConversationSnoozes` drift 테이블, 서버에는 전혀
- [ ] 리마인드 도달 시 로컬 알림(다른 사람에게는 보이지 않음, 본인에게만) 전달되지 않음). `chat_screen.dart` 앱바에 "이따 답장" 아이콘 → `SimpleDialog`
- [ ] `chat_screen.dart`/`conversation_list_screen.dart`에 스누즈 표시·취소 UI 빠른 선택(`1시간 후`/`저녁에`/`내일`, `mobile/lib/services/snooze_service.dart`
`SnoozeQuickPick`) + 스누즈 해제. 기본값은 빠른 선택 없이도 쓸 수 있게 상수
`kDefaultSnoozeDuration`(2시간)로 노출은 해 두었지만 UI 자체는 빠른 선택 3개만
제공(추가 커스텀 시각 입력은 P1 범위상 생략). 사람이 실제로 답장을 보내면(휴먼
전송이든 트윈 승인 전송이든) 자동으로 스누즈 해제(`_clearSnoozeQuiet()`)
- [x] 리마인드 도달 시 로컬 알림 — **`flutter_local_notifications`(+`timezone`) 실제
연동, 단 실기기/에뮬레이터로 발사·탭 동작까지 검증하지는 못함.** 이 샌드박스는
Flutter 웹 빌드와 Playwright 브라우저 자동화만 가능하고 실제 Android/iOS
기기·에뮬레이터가 없어 `zonedSchedule()`이 만든 예약이 실제로 울리는지는 확인할
수 없다 — 검증한 것은 `mobile/lib/services/snooze_controller.dart`가 스케줄러
인터페이스(`SnoozeNotificationScheduler`)를 목(mock)으로 바꿔 넣었을 때 올바른
id·시각·페이로드로 호출되는가뿐(`test/snooze_controller_test.dart`). 대신 진짜로
검증 가능한 **인앱 대체 노출**을 항상 함께 켜 둔다 — 앱을 열 때(대화 목록 로드,
채팅방 진입) 마감이 지난 스누즈는 배지/배너로 반드시 보인다(아래 항목). 웹 빌드는
플러그인 자체가 웹을 지원하지 않아 `snooze_notification_service_web.dart`
no-op 스텁으로 대체(웹은 프리뷰 서피스일 뿐 릴리스 타깃이 아님)
- [x] `chat_screen.dart`/`conversation_list_screen.dart`에 스누즈 표시·취소 UI —
채팅방은 앱바 아이콘 상태(`Icons.alarm_on`) + 마감 지난 스누즈 배너("스누즈
해제" 버튼), 대화 목록은 서브타이틀 아래 "답장 마감 (탭하여 해제)" 배지(탭하면
채팅방을 열지 않고도 바로 해제). 마감 판정은 순수 함수 `isSnoozePastDue(now,
snoozedUntil)`로 분리해 고정 시각으로 단위 테스트(`test/snooze_service_test.dart`)
**스코프 밖 (제안 아님, 참고용)**: 이미지/파일 전송·읽음표시·타이핑 인디케이터 등 "일반 메신저" **스코프 밖 (제안 아님, 참고용)**: 이미지/파일 전송·읽음표시·타이핑 인디케이터 등 "일반 메신저"
테이블스테이크 기능은 `PRD.md`에 명시되지 않음 — 콘텐츠 공백의 또 다른 후보일 수 있으나 이건 테이블스테이크 기능은 `PRD.md`에 명시되지 않음 — 콘텐츠 공백의 또 다른 후보일 수 있으나 이건

View File

@ -13,9 +13,9 @@ import 'package:sqlite3/open.dart';
import 'tables.dart'; import 'tables.dart';
part 'app_database.g.dart'; part 'app_database_native.g.dart';
@DriftDatabase(tables: [ToneSamples, LocalKv]) @DriftDatabase(tables: [ToneSamples, LocalKv, ConversationSnoozes])
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase(super.e, {this.encrypted = false}); AppDatabase(super.e, {this.encrypted = false});
@ -23,7 +23,18 @@ class AppDatabase extends _$AppDatabase {
final bool encrypted; final bool encrypted;
@override @override
int get schemaVersion => 1; int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
// v1 -> v2: roadmap.md §2.7-F .
if (from < 2) {
await m.createTable(conversationSnoozes);
}
},
);
/// In-memory DB for unit tests (no SQLCipher / filesystem). /// In-memory DB for unit tests (no SQLCipher / filesystem).
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory(), encrypted: false); factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory(), encrypted: false);
@ -76,6 +87,42 @@ class AppDatabase extends _$AppDatabase {
Future<void> setBoolKv(String key, bool value) async { Future<void> setBoolKv(String key, bool value) async {
await setKv(key, value ? '1' : '0'); await setKv(key, value ? '1' : '0');
} }
/// (roadmap.md §2.7-F) "이따 답장" [conversationId]
/// . .
Future<void> setSnoozedUntil(int conversationId, DateTime until) async {
await into(conversationSnoozes).insertOnConflictUpdate(
ConversationSnoozesCompanion.insert(
conversationId: conversationId.toString(),
snoozedUntilMs: until.millisecondsSinceEpoch,
),
);
}
Future<DateTime?> getSnoozedUntil(int conversationId) async {
final row = await (select(conversationSnoozes)
..where((t) => t.conversationId.equals(conversationId.toString())))
.getSingleOrNull();
if (row == null) return null;
// isUtc: true millisecondsSinceEpoch는
// , UTC로 DateTime.== ( ) .
return DateTime.fromMillisecondsSinceEpoch(row.snoozedUntilMs, isUtc: true);
}
Future<void> clearSnooze(int conversationId) async {
await (delete(conversationSnoozes)
..where((t) => t.conversationId.equals(conversationId.toString())))
.go();
}
/// (`conversation_list_screen.dart`) .
Future<Map<int, DateTime>> loadAllSnoozes() async {
final rows = await select(conversationSnoozes).get();
return {
for (final r in rows)
int.parse(r.conversationId): DateTime.fromMillisecondsSinceEpoch(r.snoozedUntilMs, isUtc: true),
};
}
} }
const _kDbPassphrase = 'db_passphrase_v1'; const _kDbPassphrase = 'db_passphrase_v1';

View File

@ -532,16 +532,256 @@ class LocalKvCompanion extends UpdateCompanion<LocalKvData> {
} }
} }
class $ConversationSnoozesTable extends ConversationSnoozes
with TableInfo<$ConversationSnoozesTable, ConversationSnooze> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$ConversationSnoozesTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _conversationIdMeta = const VerificationMeta(
'conversationId',
);
@override
late final GeneratedColumn<String> conversationId = GeneratedColumn<String>(
'conversation_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _snoozedUntilMsMeta = const VerificationMeta(
'snoozedUntilMs',
);
@override
late final GeneratedColumn<int> snoozedUntilMs = GeneratedColumn<int>(
'snoozed_until_ms',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [conversationId, snoozedUntilMs];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'conversation_snoozes';
@override
VerificationContext validateIntegrity(
Insertable<ConversationSnooze> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('conversation_id')) {
context.handle(
_conversationIdMeta,
conversationId.isAcceptableOrUnknown(
data['conversation_id']!,
_conversationIdMeta,
),
);
} else if (isInserting) {
context.missing(_conversationIdMeta);
}
if (data.containsKey('snoozed_until_ms')) {
context.handle(
_snoozedUntilMsMeta,
snoozedUntilMs.isAcceptableOrUnknown(
data['snoozed_until_ms']!,
_snoozedUntilMsMeta,
),
);
} else if (isInserting) {
context.missing(_snoozedUntilMsMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {conversationId};
@override
ConversationSnooze map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return ConversationSnooze(
conversationId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}conversation_id'],
)!,
snoozedUntilMs: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}snoozed_until_ms'],
)!,
);
}
@override
$ConversationSnoozesTable createAlias(String alias) {
return $ConversationSnoozesTable(attachedDatabase, alias);
}
}
class ConversationSnooze extends DataClass
implements Insertable<ConversationSnooze> {
/// `Conversation.id` (drift
/// text ).
final String conversationId;
final int snoozedUntilMs;
const ConversationSnooze({
required this.conversationId,
required this.snoozedUntilMs,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['conversation_id'] = Variable<String>(conversationId);
map['snoozed_until_ms'] = Variable<int>(snoozedUntilMs);
return map;
}
ConversationSnoozesCompanion toCompanion(bool nullToAbsent) {
return ConversationSnoozesCompanion(
conversationId: Value(conversationId),
snoozedUntilMs: Value(snoozedUntilMs),
);
}
factory ConversationSnooze.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return ConversationSnooze(
conversationId: serializer.fromJson<String>(json['conversationId']),
snoozedUntilMs: serializer.fromJson<int>(json['snoozedUntilMs']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'conversationId': serializer.toJson<String>(conversationId),
'snoozedUntilMs': serializer.toJson<int>(snoozedUntilMs),
};
}
ConversationSnooze copyWith({String? conversationId, int? snoozedUntilMs}) =>
ConversationSnooze(
conversationId: conversationId ?? this.conversationId,
snoozedUntilMs: snoozedUntilMs ?? this.snoozedUntilMs,
);
ConversationSnooze copyWithCompanion(ConversationSnoozesCompanion data) {
return ConversationSnooze(
conversationId: data.conversationId.present
? data.conversationId.value
: this.conversationId,
snoozedUntilMs: data.snoozedUntilMs.present
? data.snoozedUntilMs.value
: this.snoozedUntilMs,
);
}
@override
String toString() {
return (StringBuffer('ConversationSnooze(')
..write('conversationId: $conversationId, ')
..write('snoozedUntilMs: $snoozedUntilMs')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(conversationId, snoozedUntilMs);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ConversationSnooze &&
other.conversationId == this.conversationId &&
other.snoozedUntilMs == this.snoozedUntilMs);
}
class ConversationSnoozesCompanion extends UpdateCompanion<ConversationSnooze> {
final Value<String> conversationId;
final Value<int> snoozedUntilMs;
final Value<int> rowid;
const ConversationSnoozesCompanion({
this.conversationId = const Value.absent(),
this.snoozedUntilMs = const Value.absent(),
this.rowid = const Value.absent(),
});
ConversationSnoozesCompanion.insert({
required String conversationId,
required int snoozedUntilMs,
this.rowid = const Value.absent(),
}) : conversationId = Value(conversationId),
snoozedUntilMs = Value(snoozedUntilMs);
static Insertable<ConversationSnooze> custom({
Expression<String>? conversationId,
Expression<int>? snoozedUntilMs,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (conversationId != null) 'conversation_id': conversationId,
if (snoozedUntilMs != null) 'snoozed_until_ms': snoozedUntilMs,
if (rowid != null) 'rowid': rowid,
});
}
ConversationSnoozesCompanion copyWith({
Value<String>? conversationId,
Value<int>? snoozedUntilMs,
Value<int>? rowid,
}) {
return ConversationSnoozesCompanion(
conversationId: conversationId ?? this.conversationId,
snoozedUntilMs: snoozedUntilMs ?? this.snoozedUntilMs,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (conversationId.present) {
map['conversation_id'] = Variable<String>(conversationId.value);
}
if (snoozedUntilMs.present) {
map['snoozed_until_ms'] = Variable<int>(snoozedUntilMs.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('ConversationSnoozesCompanion(')
..write('conversationId: $conversationId, ')
..write('snoozedUntilMs: $snoozedUntilMs, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase { abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e); _$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this); $AppDatabaseManager get managers => $AppDatabaseManager(this);
late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this); late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this);
late final $LocalKvTable localKv = $LocalKvTable(this); late final $LocalKvTable localKv = $LocalKvTable(this);
late final $ConversationSnoozesTable conversationSnoozes =
$ConversationSnoozesTable(this);
@override @override
Iterable<TableInfo<Table, Object?>> get allTables => Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>(); allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override @override
List<DatabaseSchemaEntity> get allSchemaEntities => [toneSamples, localKv]; List<DatabaseSchemaEntity> get allSchemaEntities => [
toneSamples,
localKv,
conversationSnoozes,
];
} }
typedef $$ToneSamplesTableCreateCompanionBuilder = typedef $$ToneSamplesTableCreateCompanionBuilder =
@ -862,6 +1102,169 @@ typedef $$LocalKvTableProcessedTableManager =
LocalKvData, LocalKvData,
PrefetchHooks Function() PrefetchHooks Function()
>; >;
typedef $$ConversationSnoozesTableCreateCompanionBuilder =
ConversationSnoozesCompanion Function({
required String conversationId,
required int snoozedUntilMs,
Value<int> rowid,
});
typedef $$ConversationSnoozesTableUpdateCompanionBuilder =
ConversationSnoozesCompanion Function({
Value<String> conversationId,
Value<int> snoozedUntilMs,
Value<int> rowid,
});
class $$ConversationSnoozesTableFilterComposer
extends Composer<_$AppDatabase, $ConversationSnoozesTable> {
$$ConversationSnoozesTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get conversationId => $composableBuilder(
column: $table.conversationId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get snoozedUntilMs => $composableBuilder(
column: $table.snoozedUntilMs,
builder: (column) => ColumnFilters(column),
);
}
class $$ConversationSnoozesTableOrderingComposer
extends Composer<_$AppDatabase, $ConversationSnoozesTable> {
$$ConversationSnoozesTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get conversationId => $composableBuilder(
column: $table.conversationId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get snoozedUntilMs => $composableBuilder(
column: $table.snoozedUntilMs,
builder: (column) => ColumnOrderings(column),
);
}
class $$ConversationSnoozesTableAnnotationComposer
extends Composer<_$AppDatabase, $ConversationSnoozesTable> {
$$ConversationSnoozesTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get conversationId => $composableBuilder(
column: $table.conversationId,
builder: (column) => column,
);
GeneratedColumn<int> get snoozedUntilMs => $composableBuilder(
column: $table.snoozedUntilMs,
builder: (column) => column,
);
}
class $$ConversationSnoozesTableTableManager
extends
RootTableManager<
_$AppDatabase,
$ConversationSnoozesTable,
ConversationSnooze,
$$ConversationSnoozesTableFilterComposer,
$$ConversationSnoozesTableOrderingComposer,
$$ConversationSnoozesTableAnnotationComposer,
$$ConversationSnoozesTableCreateCompanionBuilder,
$$ConversationSnoozesTableUpdateCompanionBuilder,
(
ConversationSnooze,
BaseReferences<
_$AppDatabase,
$ConversationSnoozesTable,
ConversationSnooze
>,
),
ConversationSnooze,
PrefetchHooks Function()
> {
$$ConversationSnoozesTableTableManager(
_$AppDatabase db,
$ConversationSnoozesTable table,
) : super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$ConversationSnoozesTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$ConversationSnoozesTableOrderingComposer(
$db: db,
$table: table,
),
createComputedFieldComposer: () =>
$$ConversationSnoozesTableAnnotationComposer(
$db: db,
$table: table,
),
updateCompanionCallback:
({
Value<String> conversationId = const Value.absent(),
Value<int> snoozedUntilMs = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => ConversationSnoozesCompanion(
conversationId: conversationId,
snoozedUntilMs: snoozedUntilMs,
rowid: rowid,
),
createCompanionCallback:
({
required String conversationId,
required int snoozedUntilMs,
Value<int> rowid = const Value.absent(),
}) => ConversationSnoozesCompanion.insert(
conversationId: conversationId,
snoozedUntilMs: snoozedUntilMs,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$ConversationSnoozesTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$ConversationSnoozesTable,
ConversationSnooze,
$$ConversationSnoozesTableFilterComposer,
$$ConversationSnoozesTableOrderingComposer,
$$ConversationSnoozesTableAnnotationComposer,
$$ConversationSnoozesTableCreateCompanionBuilder,
$$ConversationSnoozesTableUpdateCompanionBuilder,
(
ConversationSnooze,
BaseReferences<
_$AppDatabase,
$ConversationSnoozesTable,
ConversationSnooze
>,
),
ConversationSnooze,
PrefetchHooks Function()
>;
class $AppDatabaseManager { class $AppDatabaseManager {
final _$AppDatabase _db; final _$AppDatabase _db;
@ -870,4 +1273,6 @@ class $AppDatabaseManager {
$$ToneSamplesTableTableManager(_db, _db.toneSamples); $$ToneSamplesTableTableManager(_db, _db.toneSamples);
$$LocalKvTableTableManager get localKv => $$LocalKvTableTableManager get localKv =>
$$LocalKvTableTableManager(_db, _db.localKv); $$LocalKvTableTableManager(_db, _db.localKv);
$$ConversationSnoozesTableTableManager get conversationSnoozes =>
$$ConversationSnoozesTableTableManager(_db, _db.conversationSnoozes);
} }

View File

@ -11,6 +11,7 @@ class AppDatabase {
final Map<String, String> _kv = {}; final Map<String, String> _kv = {};
List<String> _toneSamples = []; List<String> _toneSamples = [];
final Map<int, DateTime> _snoozes = {};
factory AppDatabase.memory() => AppDatabase(encrypted: false); factory AppDatabase.memory() => AppDatabase(encrypted: false);
@ -38,5 +39,19 @@ class AppDatabase {
await setKv(key, value ? '1' : '0'); await setKv(key, value ? '1' : '0');
} }
// roadmap.md §2.7-F native stub과
// (in-memory, encrypted persistence web은 ).
Future<void> setSnoozedUntil(int conversationId, DateTime until) async {
_snoozes[conversationId] = until;
}
Future<DateTime?> getSnoozedUntil(int conversationId) async => _snoozes[conversationId];
Future<void> clearSnooze(int conversationId) async {
_snoozes.remove(conversationId);
}
Future<Map<int, DateTime>> loadAllSnoozes() async => Map<int, DateTime>.from(_snoozes);
Future<void> close() async {} Future<void> close() async {}
} }

View File

@ -19,3 +19,16 @@ class LocalKv extends Table {
@override @override
Set<Column<Object>> get primaryKey => {key}; Set<Column<Object>> get primaryKey => {key};
} }
/// (roadmap.md §2.7-F) "이따 답장" .
/// ( ,
/// AGENTS.md "Tone/style learning: on-device first" ).
class ConversationSnoozes extends Table {
/// `Conversation.id` (drift
/// text ).
TextColumn get conversationId => text()();
IntColumn get snoozedUntilMs => integer()();
@override
Set<Column<Object>> get primaryKey => {conversationId};
}

View File

@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../services/snooze_service.dart';
import '../services/ws_client.dart'; import '../services/ws_client.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
@ -44,6 +45,9 @@ class _ChatScreenState extends State<ChatScreen> {
bool _loadingHistory = true; bool _loadingHistory = true;
late bool _floodBlocked; late bool _floodBlocked;
late final ApiClient _api; late final ApiClient _api;
// (roadmap.md §2.7-F) "이따 답장" .
// DB에서 .
DateTime? _snoozedUntil;
@override @override
void initState() { void initState() {
@ -56,6 +60,85 @@ class _ChatScreenState extends State<ChatScreen> {
_socket = ConversationSocket(widget.conversationId)..connect(); _socket = ConversationSocket(widget.conversationId)..connect();
_sub = _socket!.events.listen(_onEvent); _sub = _socket!.events.listen(_onEvent);
_loadHistory(); _loadHistory();
_loadSnooze();
}
Future<void> _loadSnooze() async {
final controller = context.read<SessionState>().snoozeController;
if (controller == null) return;
final until = await controller.loadSnoozedUntil(widget.conversationId);
if (!mounted) return;
setState(() => _snoozedUntil = until);
}
bool get _snoozePastDue => isSnoozePastDue(DateTime.now(), _snoozedUntil);
String _formatSnoozeUntil(DateTime dt) =>
'${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
/// "이따 답장" (roadmap.md §2.7-F).
/// .
Future<void> _openSnoozeMenu() async {
final now = DateTime.now();
final choice = await showDialog<Object>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('이따 답장'),
children: [
for (final pick in SnoozeQuickPick.values)
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, pick),
child: Text(pick.label),
),
if (_snoozedUntil != null)
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, 'clear'),
child: Text('스누즈 해제', style: TextStyle(color: Theme.of(ctx).colorScheme.error)),
),
],
),
);
if (choice == null || !mounted) return;
if (choice == 'clear') {
await _clearSnooze();
return;
}
if (choice is SnoozeQuickPick) {
await _applySnooze(resolveSnoozeQuickPick(choice, now));
}
}
Future<void> _applySnooze(DateTime until) async {
final controller = context.read<SessionState>().snoozeController;
if (controller != null) {
await controller.applySnooze(
conversationId: widget.conversationId,
until: until,
title: '답장 마감',
body: '${widget.title ?? "대화방 #${widget.conversationId}"}에 답장할 시간이에요',
);
}
if (!mounted) return;
setState(() {
_snoozedUntil = until;
_banner = '이따 답장: ${_formatSnoozeUntil(until)}에 리마인드합니다 (본인에게만 표시).';
});
}
/// "스누즈 해제" .
Future<void> _clearSnooze() async {
await _clearSnoozeQuiet();
if (!mounted) return;
setState(() => _banner = '스누즈를 해제했습니다.');
}
/// ( ).
Future<void> _clearSnoozeQuiet() async {
if (_snoozedUntil == null) return;
final controller = context.read<SessionState>().snoozeController;
if (controller != null) await controller.clearSnooze(widget.conversationId);
if (!mounted) return;
setState(() => _snoozedUntil = null);
} }
Future<void> _loadHistory() async { Future<void> _loadHistory() async {
@ -164,6 +247,8 @@ class _ChatScreenState extends State<ChatScreen> {
setState(() => _messages.add(msg)); setState(() => _messages.add(msg));
} }
_scrollToEnd(); _scrollToEnd();
// roadmap.md §2.7-F: .
_clearSnoozeQuiet();
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() => _banner = '전송 실패 (${e.statusCode})'); setState(() => _banner = '전송 실패 (${e.statusCode})');
} finally { } finally {
@ -245,6 +330,8 @@ class _ChatScreenState extends State<ChatScreen> {
if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg); if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg);
}); });
_scrollToEnd(); _scrollToEnd();
// roadmap.md §2.7-F: .
_clearSnoozeQuiet();
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() { setState(() {
_banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}'; _banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}';
@ -491,6 +578,13 @@ class _ChatScreenState extends State<ChatScreen> {
onPressed: _openSummary, onPressed: _openSummary,
icon: const Icon(Icons.summarize_outlined), icon: const Icon(Icons.summarize_outlined),
), ),
IconButton(
// (roadmap.md §2.7-F) "이따 답장" .
// .
tooltip: _snoozedUntil != null ? '이따 답장 (${_formatSnoozeUntil(_snoozedUntil!)})' : '이따 답장',
onPressed: _busy ? null : _openSnoozeMenu,
icon: Icon(_snoozedUntil != null ? Icons.alarm_on : Icons.alarm_add_outlined),
),
IconButton( IconButton(
tooltip: '거부권 (와카뷰 자동응대 중단)', tooltip: '거부권 (와카뷰 자동응대 중단)',
onPressed: _busy ? null : _veto, onPressed: _busy ? null : _veto,
@ -512,6 +606,14 @@ class _ChatScreenState extends State<ChatScreen> {
TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')), TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')),
], ],
), ),
if (_snoozePastDue)
MaterialBanner(
leading: Icon(Icons.alarm, color: theme.colorScheme.tertiary),
content: Text('답장 마감 시간이 지났습니다 (${_formatSnoozeUntil(_snoozedUntil!)}).'),
actions: [
TextButton(onPressed: _busy ? null : _clearSnooze, child: const Text('스누즈 해제')),
],
),
Expanded( Expanded(
child: _loadingHistory child: _loadingHistory
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())

View File

@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../services/snooze_service.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/gradient_text.dart'; import '../widgets/gradient_text.dart';
import '../widgets/my_user_id_chip.dart'; import '../widgets/my_user_id_chip.dart';
@ -22,6 +23,9 @@ class ConversationListScreen extends StatefulWidget {
class _ConversationListScreenState extends State<ConversationListScreen> { class _ConversationListScreenState extends State<ConversationListScreen> {
List<ConversationSummary> _rooms = []; List<ConversationSummary> _rooms = [];
Map<int, String> _peerNames = {}; Map<int, String> _peerNames = {};
// (roadmap.md §2.7-F) id별 "이따 답장" .
// (ConversationSummary) DB에서 .
Map<int, DateTime> _snoozes = {};
bool _loading = true; bool _loading = true;
String? _error; String? _error;
@ -53,9 +57,11 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
// Names are optional enrichment. // Names are optional enrichment.
} }
} }
final snoozes = await session.snoozeController?.loadAllSnoozes() ?? <int, DateTime>{};
setState(() { setState(() {
_rooms = list; _rooms = list;
_peerNames = names; _peerNames = names;
_snoozes = snoozes;
}); });
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}'); setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}');
@ -64,6 +70,15 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
} }
} }
/// (roadmap.md §2.7-F
/// "마감 지남" ).
Future<void> _clearSnoozeFromList(int conversationId) async {
final controller = context.read<SessionState>().snoozeController;
if (controller != null) await controller.clearSnooze(conversationId);
if (!mounted) return;
setState(() => _snoozes.remove(conversationId));
}
String _titleFor(ConversationSummary room, int? me) { String _titleFor(ConversationSummary room, int? me) {
if (me == null) return '대화방 #${room.id}'; if (me == null) return '대화방 #${room.id}';
final peers = room.userIds.where((id) => id != me).toList(); final peers = room.userIds.where((id) => id != me).toList();
@ -335,94 +350,34 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
), ),
), ),
for (final room in _rooms) for (final room in _rooms)
Material( Builder(
color: Colors.transparent, builder: (context) {
child: InkWell( final snoozedUntil = _snoozes[room.id];
onTap: () async { final snoozePastDue = isSnoozePastDue(DateTime.now(), snoozedUntil);
await Navigator.of(context).push( return _ConversationRow(
MaterialPageRoute( room: room,
builder: (_) => ChatScreen( me: me,
conversationId: room.id, title: _titleFor(room, me),
title: _titleFor(room, me), subtitle: _subtitleFor(room, me),
isGroup: room.isGroup, avatarColor: _avatarColor(context, room.id),
twinDisabledByFlood: room.twinDisabledByFlood, onAvatarColor: _onAvatarColor(context, room.id),
), snoozePastDue: snoozePastDue,
), onClearSnooze: () => _clearSnoozeFromList(room.id),
); onTap: () async {
await _load(); await Navigator.of(context).push(
}, MaterialPageRoute(
child: Padding( builder: (_) => ChatScreen(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), conversationId: room.id,
child: Row( title: _titleFor(room, me),
children: [ isGroup: room.isGroup,
CircleAvatar( twinDisabledByFlood: room.twinDisabledByFlood,
radius: 22,
backgroundColor: _avatarColor(context, room.id),
child: Icon(
room.isGroup ? Icons.groups_outlined : Icons.person_outline,
size: 20,
color: _onAvatarColor(context, room.id),
), ),
), ),
const SizedBox(width: 12), );
Expanded( await _load();
child: Column( },
crossAxisAlignment: CrossAxisAlignment.start, );
children: [ },
Text(
_titleFor(room, me),
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 2),
Row(
children: [
if (room.twinDisabledByPeer || room.twinDisabledByFlood) ...[
Icon(
room.twinDisabledByFlood ? Icons.pause_circle_outline : Icons.block,
size: 13,
color: theme.colorScheme.error,
),
const SizedBox(width: 4),
],
Expanded(
child: Text(
_subtitleFor(room, me),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: room.twinDisabledByPeer || room.twinDisabledByFlood
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
if (room.unreadCount > 0) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(10),
),
child: Text(
room.unreadCount > 99 ? '99+' : '${room.unreadCount}',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 8),
],
Icon(Icons.chevron_right, size: 18, color: theme.colorScheme.onSurfaceVariant),
],
),
),
),
), ),
const SizedBox(height: 72), const SizedBox(height: 72),
], ],
@ -431,3 +386,128 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
); );
} }
} }
class _ConversationRow extends StatelessWidget {
const _ConversationRow({
required this.room,
required this.me,
required this.title,
required this.subtitle,
required this.avatarColor,
required this.onAvatarColor,
required this.snoozePastDue,
required this.onClearSnooze,
required this.onTap,
});
final ConversationSummary room;
final int? me;
final String title;
final String subtitle;
final Color avatarColor;
final Color onAvatarColor;
final bool snoozePastDue;
final VoidCallback onClearSnooze;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final blockedColor = room.twinDisabledByPeer || room.twinDisabledByFlood;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: avatarColor,
child: Icon(
room.isGroup ? Icons.groups_outlined : Icons.person_outline,
size: 20,
color: onAvatarColor,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 2),
Row(
children: [
if (blockedColor) ...[
Icon(
room.twinDisabledByFlood ? Icons.pause_circle_outline : Icons.block,
size: 13,
color: theme.colorScheme.error,
),
const SizedBox(width: 4),
],
Expanded(
child: Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: blockedColor ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
// (roadmap.md §2.7-F)
// . .
if (snoozePastDue) ...[
const SizedBox(height: 4),
InkWell(
borderRadius: BorderRadius.circular(4),
onTap: onClearSnooze,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.alarm, size: 13, color: theme.colorScheme.tertiary),
const SizedBox(width: 4),
Text(
'답장 마감 (탭하여 해제)',
style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.tertiary),
),
],
),
),
],
],
),
),
if (room.unreadCount > 0) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(10),
),
child: Text(
room.unreadCount > 99 ? '99+' : '${room.unreadCount}',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 8),
],
Icon(Icons.chevron_right, size: 18, color: theme.colorScheme.onSurfaceVariant),
],
),
),
),
);
}
}

View File

@ -0,0 +1,39 @@
import '../db/app_database.dart';
import 'snooze_notification_service.dart';
/// (roadmap.md §2.7-F) DB( )
/// (OS ) . `chat_screen.dart`/
/// `conversation_list_screen.dart` .
///
/// AGENTS.md "Tone/style learning: on-device first"
/// , ·
/// . [scheduler] (mock) "DB에 정확히 반영되고
/// id// "까지 검증한다 — 실제 OS 알림이
/// ( / ).
class SnoozeController {
SnoozeController({required AppDatabase db, required SnoozeNotificationScheduler scheduler})
: _db = db,
_scheduler = scheduler;
final AppDatabase _db;
final SnoozeNotificationScheduler _scheduler;
Future<DateTime?> loadSnoozedUntil(int conversationId) => _db.getSnoozedUntil(conversationId);
Future<Map<int, DateTime>> loadAllSnoozes() => _db.loadAllSnoozes();
Future<void> applySnooze({
required int conversationId,
required DateTime until,
required String title,
required String body,
}) async {
await _db.setSnoozedUntil(conversationId, until);
await _scheduler.scheduleReminder(conversationId: conversationId, title: title, body: body, at: until);
}
Future<void> clearSnooze(int conversationId) async {
await _db.clearSnooze(conversationId);
await _scheduler.cancelReminder(conversationId);
}
}

View File

@ -0,0 +1,10 @@
/// (roadmap.md §2.7-F) OS
/// export로 (`../db/app_database.dart` ).
///
/// Flutter Web은 `flutter_local_notifications` ,
///
/// (`app_database_web.dart` ) no-op .
library;
export 'snooze_notification_service_native.dart'
if (dart.library.html) 'snooze_notification_service_web.dart';

View File

@ -0,0 +1,80 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
/// (roadmap.md §2.7-F, PRD.md §3.2 "내가 '이따 답장' 누르면 나에게만
/// ")의 스케줄러 인터페이스. 다른 사람에게는 절대 보이지 않는, 이 기기에만
/// AGENTS.md .
///
/// Android/iOS ·
/// / . `zonedSchedule()` /
/// `cancel()` id·· ,
/// (mock) (`test/snooze_notification_scheduler_test.dart`)
/// .
abstract class SnoozeNotificationScheduler {
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
});
Future<void> cancelReminder(int conversationId);
}
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
LocalSnoozeNotificationScheduler({FlutterLocalNotificationsPlugin? plugin})
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
final FlutterLocalNotificationsPlugin _plugin;
bool _initialized = false;
static const _channelId = 'reply_snooze_reminders';
static const _channelName = '답장 마감 알림';
static const _channelDescription = '"이따 답장" 스누즈 마감 시각 알림 (본인 기기에만 표시, 상대에게는 보이지 않음)';
Future<void> _ensureInitialized() async {
if (_initialized) return;
tz_data.initializeTimeZones();
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosInit = DarwinInitializationSettings();
await _plugin.initialize(
settings: const InitializationSettings(android: androidInit, iOS: iosInit),
);
_initialized = true;
}
@override
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
}) async {
await _ensureInitialized();
await _plugin.zonedSchedule(
id: conversationId,
title: title,
body: body,
scheduledDate: tz.TZDateTime.from(at, tz.local),
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
channelDescription: _channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
),
iOS: DarwinNotificationDetails(),
),
// SCHEDULE_EXACT_ALARM
// (Doze ).
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
);
}
@override
Future<void> cancelReminder(int conversationId) async {
await _plugin.cancel(id: conversationId);
}
}

View File

@ -0,0 +1,27 @@
/// Web `flutter_local_notifications` ,
/// (`app_database_web.dart` ).
/// (`AppDatabase`) , OS no-op
/// / "마감 지남" · .
abstract class SnoozeNotificationScheduler {
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
});
Future<void> cancelReminder(int conversationId);
}
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
@override
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
}) async {}
@override
Future<void> cancelReminder(int conversationId) async {}
}

View File

@ -0,0 +1,49 @@
/// (roadmap.md §2.7-F, PRD.md §3.2 P1 "내가 '이따 답장' 누르면 나에게만
/// ") 순수 로직. `DateTime.now()`에 의존하지 않고 `now`를 인자로 받는 함수들이라
/// .
library;
/// "이따 답장" ( ) .
const Duration kDefaultSnoozeDuration = Duration(hours: 2);
/// / .
enum SnoozeQuickPick { oneHour, thisEvening, tomorrowMorning }
extension SnoozeQuickPickLabel on SnoozeQuickPick {
String get label => switch (this) {
SnoozeQuickPick.oneHour => '1시간 후',
SnoozeQuickPick.thisEvening => '저녁에',
SnoozeQuickPick.tomorrowMorning => '내일',
};
}
/// [now] .
///
/// - 1 : 1
/// - : 19( 19)
/// - : 9
DateTime resolveSnoozeQuickPick(SnoozeQuickPick pick, DateTime now) {
switch (pick) {
case SnoozeQuickPick.oneHour:
return now.add(const Duration(hours: 1));
case SnoozeQuickPick.thisEvening:
final evening = DateTime(now.year, now.month, now.day, 19);
return now.isBefore(evening) ? evening : evening.add(const Duration(days: 1));
case SnoozeQuickPick.tomorrowMorning:
final tomorrow = now.add(const Duration(days: 1));
return DateTime(tomorrow.year, tomorrow.month, tomorrow.day, 9);
}
}
/// [now] /
/// . `snoozedUntil` false.
bool isSnoozePastDue(DateTime now, DateTime? snoozedUntil) {
if (snoozedUntil == null) return false;
return !now.isBefore(snoozedUntil);
}
/// (= ).
bool isSnoozeActive(DateTime now, DateTime? snoozedUntil) {
if (snoozedUntil == null) return false;
return now.isBefore(snoozedUntil);
}

View File

@ -7,14 +7,23 @@ import '../db/app_database.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../services/push_token_service.dart'; import '../services/push_token_service.dart';
import '../services/snooze_controller.dart';
import '../services/snooze_notification_service.dart';
class SessionState extends ChangeNotifier { class SessionState extends ChangeNotifier {
SessionState({ApiClient? api, AppDatabase? db}) SessionState({ApiClient? api, AppDatabase? db, SnoozeNotificationScheduler? snoozeScheduler})
: _api = api ?? ApiClient(), : _api = api ?? ApiClient(),
_db = db; _db = db,
_snoozeScheduler = snoozeScheduler ?? LocalSnoozeNotificationScheduler();
final ApiClient _api; final ApiClient _api;
AppDatabase? _db; AppDatabase? _db;
final SnoozeNotificationScheduler _snoozeScheduler;
/// (roadmap.md §2.7-F) DB( )
/// . DB가 ( ) null.
SnoozeController? get snoozeController =>
_db == null ? null : SnoozeController(db: _db!, scheduler: _snoozeScheduler);
User? user; User? user;
AutonomyLevel autonomyLevel = AutonomyLevel.L0; AutonomyLevel autonomyLevel = AutonomyLevel.L0;

View File

@ -177,6 +177,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
dbus:
dependency: transitive
description:
name: dbus
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
url: "https://pub.dev"
source: hosted
version: "0.7.14"
drift: drift:
dependency: "direct main" dependency: "direct main"
description: description:
@ -286,6 +294,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad"
url: "https://pub.dev"
source: hosted
version: "20.1.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
url: "https://pub.dev"
source: hosted
version: "7.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_secure_storage: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:
@ -552,6 +592,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -773,6 +821,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.11"
timezone:
dependency: "direct main"
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -845,6 +901,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:
@ -855,4 +919,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.10.0-0 <4.0.0" dart: ">=3.10.0-0 <4.0.0"
flutter: ">=3.29.0" flutter: ">=3.32.0"

View File

@ -47,6 +47,8 @@ dependencies:
google_fonts: ^6.3.2 google_fonts: ^6.3.2
firebase_core: ^4.12.1 firebase_core: ^4.12.1
firebase_messaging: ^16.4.3 firebase_messaging: ^16.4.3
flutter_local_notifications: ^20.1.0
timezone: ^0.10.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -15,4 +15,34 @@ void main() {
await db.replaceToneSamples(['하나만']); await db.replaceToneSamples(['하나만']);
expect(await db.loadToneSamples(), ['하나만']); expect(await db.loadToneSamples(), ['하나만']);
}); });
// roadmap.md §2.7-F :
// tone samples와 (in-memory DB round-trip)
// .
test('conversation snooze round-trips in memory database', () async {
final db = AppDatabase.memory();
addTearDown(db.close);
expect(await db.getSnoozedUntil(42), isNull);
final until = DateTime.utc(2026, 8, 3, 20, 0);
await db.setSnoozedUntil(42, until);
expect(await db.getSnoozedUntil(42), until);
// (id 43) .
expect(await db.getSnoozedUntil(43), isNull);
// (insertOnConflictUpdate) .
final rescheduled = DateTime.utc(2026, 8, 4, 9, 0);
await db.setSnoozedUntil(42, rescheduled);
expect(await db.getSnoozedUntil(42), rescheduled);
await db.setSnoozedUntil(43, until);
final all = await db.loadAllSnoozes();
expect(all, {42: rescheduled, 43: until});
await db.clearSnooze(42);
expect(await db.getSnoozedUntil(42), isNull);
expect(await db.loadAllSnoozes(), {43: until});
});
} }

View File

@ -0,0 +1,86 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/db/app_database.dart';
import 'package:ykavu_mobile/services/snooze_controller.dart';
import 'package:ykavu_mobile/services/snooze_notification_service.dart';
/// `flutter_local_notifications` (mock).
/// /
/// , "스케줄/취소 호출이 올바른 id·시각·페이로드로 이뤄지는가" .
class _FakeScheduler implements SnoozeNotificationScheduler {
final scheduledCalls = <Map<String, Object?>>[];
final cancelledIds = <int>[];
@override
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
}) async {
scheduledCalls.add({
'conversationId': conversationId,
'title': title,
'body': body,
'at': at,
});
}
@override
Future<void> cancelReminder(int conversationId) async {
cancelledIds.add(conversationId);
}
}
void main() {
late AppDatabase db;
late _FakeScheduler scheduler;
late SnoozeController controller;
setUp(() {
db = AppDatabase.memory();
scheduler = _FakeScheduler();
controller = SnoozeController(db: db, scheduler: scheduler);
});
tearDown(() => db.close());
test('applySnooze persists to DB and schedules with exact id/time/payload', () async {
final until = DateTime.utc(2026, 8, 3, 21, 0);
await controller.applySnooze(
conversationId: 7,
until: until,
title: '답장 마감',
body: '상대#7에게 답장할 시간이에요',
);
expect(await controller.loadSnoozedUntil(7), until);
expect(scheduler.scheduledCalls, hasLength(1));
expect(scheduler.scheduledCalls.single, {
'conversationId': 7,
'title': '답장 마감',
'body': '상대#7에게 답장할 시간이에요',
'at': until,
});
});
test('clearSnooze removes from DB and cancels the scheduled reminder', () async {
final until = DateTime.utc(2026, 8, 3, 21, 0);
await controller.applySnooze(conversationId: 9, until: until, title: 't', body: 'b');
expect(await controller.loadSnoozedUntil(9), isNotNull);
await controller.clearSnooze(9);
expect(await controller.loadSnoozedUntil(9), isNull);
expect(scheduler.cancelledIds, [9]);
});
test('loadAllSnoozes reflects multiple conversations independently', () async {
final until1 = DateTime.utc(2026, 8, 3, 21, 0);
final until2 = DateTime.utc(2026, 8, 4, 9, 0);
await controller.applySnooze(conversationId: 1, until: until1, title: 't', body: 'b');
await controller.applySnooze(conversationId: 2, until: until2, title: 't', body: 'b');
expect(await controller.loadAllSnoozes(), {1: until1, 2: until2});
expect(scheduler.scheduledCalls, hasLength(2));
});
}

View File

@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/services/snooze_service.dart';
void main() {
group('isSnoozePastDue / isSnoozeActive', () {
// "now" (DateTime.now() ).
final now = DateTime(2026, 8, 3, 12, 0);
test('no snooze set -> never past due, never active', () {
expect(isSnoozePastDue(now, null), isFalse);
expect(isSnoozeActive(now, null), isFalse);
});
test('snooze in the future -> active, not past due', () {
final until = now.add(const Duration(hours: 1));
expect(isSnoozePastDue(now, until), isFalse);
expect(isSnoozeActive(now, until), isTrue);
});
test('snooze in the past -> past due, not active', () {
final until = now.subtract(const Duration(minutes: 1));
expect(isSnoozePastDue(now, until), isTrue);
expect(isSnoozeActive(now, until), isFalse);
});
test('snooze exactly at now -> counts as past due (boundary)', () {
expect(isSnoozePastDue(now, now), isTrue);
expect(isSnoozeActive(now, now), isFalse);
});
});
group('resolveSnoozeQuickPick', () {
test('oneHour adds exactly one hour', () {
final now = DateTime(2026, 8, 3, 12, 0);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.oneHour, now), DateTime(2026, 8, 3, 13, 0));
});
test('thisEvening resolves to 19:00 today when now is before 19:00', () {
final now = DateTime(2026, 8, 3, 12, 0);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.thisEvening, now), DateTime(2026, 8, 3, 19, 0));
});
test('thisEvening rolls over to tomorrow 19:00 when now is already past 19:00', () {
final now = DateTime(2026, 8, 3, 20, 30);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.thisEvening, now), DateTime(2026, 8, 4, 19, 0));
});
test('thisEvening rolls over exactly at 19:00 (boundary is not "before")', () {
final now = DateTime(2026, 8, 3, 19, 0);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.thisEvening, now), DateTime(2026, 8, 4, 19, 0));
});
test('tomorrowMorning resolves to 09:00 the next day', () {
final now = DateTime(2026, 8, 3, 23, 45);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.tomorrowMorning, now), DateTime(2026, 8, 4, 9, 0));
});
test('quick pick labels are the fixed Korean copy used in the UI', () {
expect(SnoozeQuickPick.oneHour.label, '1시간 후');
expect(SnoozeQuickPick.thisEvening.label, '저녁에');
expect(SnoozeQuickPick.tomorrowMorning.label, '내일');
});
});
}