답장 마감 알림(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:
parent
f9fba46889
commit
95422bb012
|
|
@ -62,9 +62,18 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
|
|||
`persona.go` `resolveRelationshipNote()`(그룹은 항상 빈 문자열 — 전역 기본 메모 개념 자체가
|
||||
없어 티어/자율성과 다름), `ai-service/app/generation.py`가 메모를 `[관계 메모]` 프롬프트
|
||||
문단으로 주입. 이 김에 세 resolver가 복붙하던 "1:1 상대 Contact 찾기" 루프를
|
||||
`findCounterpartContact()` 공용 헬퍼로 추출. C6(답장 마감 알림)은 아직 todo.
|
||||
Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능
|
||||
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
|
||||
`findCounterpartContact()` 공용 헬퍼로 추출. C6(답장 마감 알림)도 **완료(부분 검증)**
|
||||
(2026-08-03, 아직 GitHub `main`에만 있고 프로덕션 미배포, 모바일 온디바이스 전용이라
|
||||
`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 순서
|
||||
|
||||
|
|
@ -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-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-C6a** | "이따 답장" 스누즈 저장 | todo | 온디바이스 우선 원칙에 맞는 저장 위치 결정 |
|
||||
| **N4-C6b** | 리마인드 로컬 알림(본인에게만) | todo | 다른 사람에게 노출되지 않음 |
|
||||
| **N4-C6c** | 스누즈 표시/취소 UI | todo | `chat_screen.dart`/`conversation_list_screen.dart` |
|
||||
| **N4-C6a** | "이따 답장" 스누즈 저장 | **done** (2026-08-03) | 순수 온디바이스 — `mobile/lib/db/tables.dart` `ConversationSnoozes` drift 테이블(서버 전송 없음), `chat_screen.dart` 앱바 "이따 답장" → 빠른 선택(1시간 후/저녁에/내일) + 해제, 실제 답장 전송 시 자동 해제 |
|
||||
| **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 | **done** (2026-08-03) | `chat_screen.dart`(앱바 아이콘 상태 + 마감 지난 스누즈 배너 + 해제 버튼), `conversation_list_screen.dart`(서브타이틀 아래 "답장 마감" 배지, 탭하여 바로 해제). 마감 판정은 순수 함수 `isSnoozePastDue()`로 분리해 고정 시각 단위 테스트(`test/snooze_service_test.dart`) |
|
||||
|
||||
### 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**
|
||||
(2026-08-03) — **Track C 콘텐츠 갭 A/B/C 전체 완료.** 2026-08-03 2차 재분석으로 D/E/F 추가
|
||||
발견, ~~Track C4 자율성 상대별 예외~~ **done** (2026-08-03), ~~Track C5 관계 메모 반영~~
|
||||
**done** (2026-08-03). 남은 것: Track C6(답장 마감 알림), **Master FCM 시크릿(N4-1/3)** →
|
||||
N4-4 스모크 → Android UI QA (N4-5~10), Track C 프로덕션 재배포(C2/C3/C4/C5는 아직 GitHub
|
||||
`main`에만 있음)
|
||||
**done** (2026-08-03), ~~Track C6 답장 마감 알림~~ **done(부분 검증)** (2026-08-03) —
|
||||
**2026-08-03 2차 갭 분석 배치(C4/C5/C6) 전체 구현 완료.** C6은 온디바이스 스누즈
|
||||
저장·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의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||
|
|
|
|||
|
|
@ -233,11 +233,31 @@
|
|||
이 둘에는 전혀 영향 없음(순수 톤/금기어 힌트, 안전 게이트 우회 아님)
|
||||
|
||||
**2.7-F 답장 마감 알림** (`PRD.md` §3.2 P1: "내가 '이따 답장' 누르면 나에게만 리마인드" — 코드
|
||||
전무, 아이디어 회의 문서에만 존재, 2026-08-03 발견)
|
||||
- [ ] 메시지/대화에 "이따 답장" 스누즈 액션 + 리마인드 시각 저장(서버 또는 온디바이스 — 개인
|
||||
전용 알림이라 온디바이스 우선 원칙에 맞는 쪽으로 설계)
|
||||
- [ ] 리마인드 도달 시 로컬 알림(다른 사람에게는 보이지 않음, 본인에게만)
|
||||
- [ ] `chat_screen.dart`/`conversation_list_screen.dart`에 스누즈 표시·취소 UI
|
||||
전무, 아이디어 회의 문서에만 존재, 2026-08-03 발견 — **완료(부분 검증)** 2026-08-03, 아래 참고)
|
||||
- [x] 메시지/대화에 "이따 답장" 스누즈 액션 + 리마인드 시각 저장 — 순수 온디바이스
|
||||
(`mobile/lib/db/tables.dart`의 `ConversationSnoozes` drift 테이블, 서버에는 전혀
|
||||
전달되지 않음). `chat_screen.dart` 앱바에 "이따 답장" 아이콘 → `SimpleDialog`로
|
||||
빠른 선택(`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`에 명시되지 않음 — 콘텐츠 공백의 또 다른 후보일 수 있으나 이건
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import 'package:sqlite3/open.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 {
|
||||
AppDatabase(super.e, {this.encrypted = false});
|
||||
|
||||
|
|
@ -23,7 +23,18 @@ class AppDatabase extends _$AppDatabase {
|
|||
final bool encrypted;
|
||||
|
||||
@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).
|
||||
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory(), encrypted: false);
|
||||
|
|
@ -76,6 +87,42 @@ class AppDatabase extends _$AppDatabase {
|
|||
Future<void> setBoolKv(String key, bool value) async {
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
_$AppDatabase(QueryExecutor e) : super(e);
|
||||
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
||||
late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this);
|
||||
late final $LocalKvTable localKv = $LocalKvTable(this);
|
||||
late final $ConversationSnoozesTable conversationSnoozes =
|
||||
$ConversationSnoozesTable(this);
|
||||
@override
|
||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||
@override
|
||||
List<DatabaseSchemaEntity> get allSchemaEntities => [toneSamples, localKv];
|
||||
List<DatabaseSchemaEntity> get allSchemaEntities => [
|
||||
toneSamples,
|
||||
localKv,
|
||||
conversationSnoozes,
|
||||
];
|
||||
}
|
||||
|
||||
typedef $$ToneSamplesTableCreateCompanionBuilder =
|
||||
|
|
@ -862,6 +1102,169 @@ typedef $$LocalKvTableProcessedTableManager =
|
|||
LocalKvData,
|
||||
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 {
|
||||
final _$AppDatabase _db;
|
||||
|
|
@ -870,4 +1273,6 @@ class $AppDatabaseManager {
|
|||
$$ToneSamplesTableTableManager(_db, _db.toneSamples);
|
||||
$$LocalKvTableTableManager get localKv =>
|
||||
$$LocalKvTableTableManager(_db, _db.localKv);
|
||||
$$ConversationSnoozesTableTableManager get conversationSnoozes =>
|
||||
$$ConversationSnoozesTableTableManager(_db, _db.conversationSnoozes);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ class AppDatabase {
|
|||
|
||||
final Map<String, String> _kv = {};
|
||||
List<String> _toneSamples = [];
|
||||
final Map<int, DateTime> _snoozes = {};
|
||||
|
||||
factory AppDatabase.memory() => AppDatabase(encrypted: false);
|
||||
|
||||
|
|
@ -38,5 +39,19 @@ class AppDatabase {
|
|||
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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,3 +19,16 @@ class LocalKv extends Table {
|
|||
@override
|
||||
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};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
|||
|
||||
import '../models/models.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/snooze_service.dart';
|
||||
import '../services/ws_client.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
|
@ -44,6 +45,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
bool _loadingHistory = true;
|
||||
late bool _floodBlocked;
|
||||
late final ApiClient _api;
|
||||
// 답장 마감 알림(roadmap.md §2.7-F) — 이 대화방에 걸린 "이따 답장" 스누즈 시각.
|
||||
// 서버에는 존재하지 않는 순수 온디바이스 상태라 화면에 들어올 때 로컬 DB에서 로드한다.
|
||||
DateTime? _snoozedUntil;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -56,6 +60,85 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
_socket = ConversationSocket(widget.conversationId)..connect();
|
||||
_sub = _socket!.events.listen(_onEvent);
|
||||
_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 {
|
||||
|
|
@ -164,6 +247,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
setState(() => _messages.add(msg));
|
||||
}
|
||||
_scrollToEnd();
|
||||
// roadmap.md §2.7-F: 답장을 실제로 보냈으니 걸려 있던 스누즈는 자동 해제.
|
||||
_clearSnoozeQuiet();
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _banner = '전송 실패 (${e.statusCode})');
|
||||
} finally {
|
||||
|
|
@ -245,6 +330,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg);
|
||||
});
|
||||
_scrollToEnd();
|
||||
// roadmap.md §2.7-F: 와카뷰가 대신 답장을 보냈어도 답장은 답장이므로 스누즈 해제.
|
||||
_clearSnoozeQuiet();
|
||||
} on ApiException catch (e) {
|
||||
setState(() {
|
||||
_banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}';
|
||||
|
|
@ -491,6 +578,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
onPressed: _openSummary,
|
||||
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(
|
||||
tooltip: '거부권 (와카뷰 자동응대 중단)',
|
||||
onPressed: _busy ? null : _veto,
|
||||
|
|
@ -512,6 +606,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
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(
|
||||
child: _loadingHistory
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||
|
||||
import '../models/models.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/snooze_service.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/gradient_text.dart';
|
||||
import '../widgets/my_user_id_chip.dart';
|
||||
|
|
@ -22,6 +23,9 @@ class ConversationListScreen extends StatefulWidget {
|
|||
class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||
List<ConversationSummary> _rooms = [];
|
||||
Map<int, String> _peerNames = {};
|
||||
// 답장 마감 알림(roadmap.md §2.7-F) — 대화방 id별 "이따 답장" 스누즈 마감 시각.
|
||||
// 서버 응답(ConversationSummary)에는 없는 순수 온디바이스 상태라 로컬 DB에서 별도 로드.
|
||||
Map<int, DateTime> _snoozes = {};
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
|
|
@ -53,9 +57,11 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
// Names are optional enrichment.
|
||||
}
|
||||
}
|
||||
final snoozes = await session.snoozeController?.loadAllSnoozes() ?? <int, DateTime>{};
|
||||
setState(() {
|
||||
_rooms = list;
|
||||
_peerNames = names;
|
||||
_snoozes = snoozes;
|
||||
});
|
||||
} on ApiException catch (e) {
|
||||
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) {
|
||||
if (me == null) return '대화방 #${room.id}';
|
||||
final peers = room.userIds.where((id) => id != me).toList();
|
||||
|
|
@ -335,9 +350,19 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
),
|
||||
),
|
||||
for (final room in _rooms)
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final snoozedUntil = _snoozes[room.id];
|
||||
final snoozePastDue = isSnoozePastDue(DateTime.now(), snoozedUntil);
|
||||
return _ConversationRow(
|
||||
room: room,
|
||||
me: me,
|
||||
title: _titleFor(room, me),
|
||||
subtitle: _subtitleFor(room, me),
|
||||
avatarColor: _avatarColor(context, room.id),
|
||||
onAvatarColor: _onAvatarColor(context, room.id),
|
||||
snoozePastDue: snoozePastDue,
|
||||
onClearSnooze: () => _clearSnoozeFromList(room.id),
|
||||
onTap: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
|
|
@ -351,17 +376,59 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
);
|
||||
await _load();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 72),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(context, room.id),
|
||||
backgroundColor: avatarColor,
|
||||
child: Icon(
|
||||
room.isGroup ? Icons.groups_outlined : Icons.person_outline,
|
||||
size: 20,
|
||||
color: _onAvatarColor(context, room.id),
|
||||
color: onAvatarColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
|
@ -370,13 +437,13 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_titleFor(room, me),
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
if (room.twinDisabledByPeer || room.twinDisabledByFlood) ...[
|
||||
if (blockedColor) ...[
|
||||
Icon(
|
||||
room.twinDisabledByFlood ? Icons.pause_circle_outline : Icons.block,
|
||||
size: 13,
|
||||
|
|
@ -386,18 +453,36 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
_subtitleFor(room, me),
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: room.twinDisabledByPeer || room.twinDisabledByFlood
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -423,11 +508,6 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 72),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -7,14 +7,23 @@ import '../db/app_database.dart';
|
|||
import '../models/models.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/push_token_service.dart';
|
||||
import '../services/snooze_controller.dart';
|
||||
import '../services/snooze_notification_service.dart';
|
||||
|
||||
class SessionState extends ChangeNotifier {
|
||||
SessionState({ApiClient? api, AppDatabase? db})
|
||||
SessionState({ApiClient? api, AppDatabase? db, SnoozeNotificationScheduler? snoozeScheduler})
|
||||
: _api = api ?? ApiClient(),
|
||||
_db = db;
|
||||
_db = db,
|
||||
_snoozeScheduler = snoozeScheduler ?? LocalSnoozeNotificationScheduler();
|
||||
|
||||
final ApiClient _api;
|
||||
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;
|
||||
AutonomyLevel autonomyLevel = AutonomyLevel.L0;
|
||||
|
|
|
|||
|
|
@ -177,6 +177,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.14"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -286,6 +294,38 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -552,6 +592,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -773,6 +821,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -845,6 +901,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -855,4 +919,4 @@ packages:
|
|||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.0-0 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
flutter: ">=3.32.0"
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ dependencies:
|
|||
google_fonts: ^6.3.2
|
||||
firebase_core: ^4.12.1
|
||||
firebase_messaging: ^16.4.3
|
||||
flutter_local_notifications: ^20.1.0
|
||||
timezone: ^0.10.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -15,4 +15,34 @@ void main() {
|
|||
await db.replaceToneSamples(['하나만']);
|
||||
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});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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, '내일');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue