Compare commits
21 Commits
cursor/doc
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
6a8833751c | |
|
|
cd367a5715 | |
|
|
57fa4dca3c | |
|
|
c28c6ca2e8 | |
|
|
ed8cbef8fe | |
|
|
e595703c6c | |
|
|
2b46db63ca | |
|
|
526bc5bf76 | |
|
|
fed6739a56 | |
|
|
4aa21a6099 | |
|
|
40c36a20c3 | |
|
|
4a38231656 | |
|
|
b61b3bc0fb | |
|
|
ec5761d602 | |
|
|
866f36e319 | |
|
|
7486e6e15e | |
|
|
5d029e0467 | |
|
|
98f511eae5 | |
|
|
febf1fc637 | |
|
|
a956db754b | |
|
|
c35fad2737 |
12
.env.example
12
.env.example
|
|
@ -27,3 +27,15 @@ PUBLIC_API_BASE=https://msn.iykyka.com
|
||||||
|
|
||||||
# Host port mapped to web:80
|
# Host port mapped to web:80
|
||||||
WEB_HOST_PORT=8088
|
WEB_HOST_PORT=8088
|
||||||
|
|
||||||
|
# --- N4-W4 Web Push (Flutter web build-args / dart-define; never commit real values) ---
|
||||||
|
# Firebase Console → Project settings → Your apps → Web app (msn.iykyka.com)
|
||||||
|
# + Cloud Messaging → Web Push certificates (VAPID key pair).
|
||||||
|
# Leave empty to keep install: placeholder tokens until secrets are ready.
|
||||||
|
FIREBASE_API_KEY=
|
||||||
|
FIREBASE_AUTH_DOMAIN=
|
||||||
|
FIREBASE_PROJECT_ID=
|
||||||
|
FIREBASE_STORAGE_BUCKET=
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID=
|
||||||
|
FIREBASE_APP_ID=
|
||||||
|
FIREBASE_VAPID_KEY=
|
||||||
|
|
|
||||||
16
AGENTS.md
16
AGENTS.md
|
|
@ -10,8 +10,8 @@ copying content into prompts or new files.
|
||||||
|
|
||||||
When documents disagree, follow this order:
|
When documents disagree, follow this order:
|
||||||
|
|
||||||
1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q7 (확정)
|
1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q8 (확정)
|
||||||
and Q8/Q9 (계정·설정 IA / L2 의미 — 제안 until Master confirms)
|
and Q9 (L2 의미 — 제안 until Master confirms)
|
||||||
2. [`docs/vision.md`](docs/vision.md) / [`docs/PRD.md`](docs/PRD.md) / [`docs/tech-design.md`](docs/tech-design.md)
|
2. [`docs/vision.md`](docs/vision.md) / [`docs/PRD.md`](docs/PRD.md) / [`docs/tech-design.md`](docs/tech-design.md)
|
||||||
3. [`docs/roadmap.md`](docs/roadmap.md) / [`docs/risk-log.md`](docs/risk-log.md)
|
3. [`docs/roadmap.md`](docs/roadmap.md) / [`docs/risk-log.md`](docs/risk-log.md)
|
||||||
4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide
|
4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide
|
||||||
|
|
@ -19,12 +19,12 @@ When documents disagree, follow this order:
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Q1~Q7 in `decision-log.md` are **확정** (Phase 1 C, 2026-07-30). PoC-dependent
|
- Q1~Q8 in `decision-log.md` are **확정** (Q1~Q7: Phase 1 C, 2026-07-30;
|
||||||
sub-questions (default autonomy level, whitelist defaults, final branding)
|
Q8 account/settings IA: 2026-08-03). PoC-dependent sub-questions (default
|
||||||
stay open — do not invent those. Q8/Q9 (logout/settings IA, L2 semantics) are
|
autonomy level, whitelist defaults, final branding) stay open — do not invent
|
||||||
**제안** — implement only after Master marks them 확정; see
|
those. Q9 (L2 receive-triggered auto-reply) remains **제안** — implement only
|
||||||
`docs/account-settings-ia.md`. To reverse a Q, update `decision-log.md` and
|
after Master marks it 확정; see `docs/account-settings-ia.md`. To reverse a Q,
|
||||||
derived docs in the same change.
|
update `decision-log.md` and derived docs in the same change.
|
||||||
- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for
|
- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for
|
||||||
current working answers.
|
current working answers.
|
||||||
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**
|
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**
|
||||||
|
|
|
||||||
76
README.md
76
README.md
|
|
@ -1,4 +1,4 @@
|
||||||
# hikikomori / 와카뷰 (가칭)
|
# hikikomori / 와카뷰 (Ykavu)
|
||||||
|
|
||||||
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 와카뷰".
|
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 와카뷰".
|
||||||
|
|
||||||
|
|
@ -6,35 +6,80 @@
|
||||||
|
|
||||||
| 경로 | 역할 |
|
| 경로 | 역할 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `docs/` | 기획·PRD·기술설계·로드맵·PoC 계획 |
|
| `docs/` | 기획·PRD·기술설계·로드맵·배포 체크리스트·PoC |
|
||||||
| `core-backend/` | Go 코어 (가입·메시지·WebSocket·자율성·안전장치) |
|
| `core-backend/` | Go 코어 (가입·메시지·WebSocket·자율성·안전장치) |
|
||||||
| `ai-service/` | Python AI 내부 API (`/draft`, `/escalate/check`) |
|
| `ai-service/` | Python AI 내부 API (`/draft`, `/escalate/check`) |
|
||||||
| `backend/` | 초기 Python 프로토타입 (참고용) |
|
| `backend/` | 초기 Python 프로토타입 (참고용) |
|
||||||
| `poc/tone-corpus/` | PoC 실험 스크립트 |
|
| `poc/tone-corpus/` | PoC 실험 스크립트 |
|
||||||
| `mobile/` | Flutter 클라이언트 (Android 우선) |
|
| `mobile/` | Flutter 클라이언트 (Android 우선 · Web 배포) |
|
||||||
|
| `secrets/` | FCM 등 시크릿 마운트용 (git에 실파일 커밋 금지) |
|
||||||
|
|
||||||
## 문서
|
## 문서 지도
|
||||||
|
|
||||||
- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) (Q1~Q7 **확정**)
|
권위 순서·에이전트 규칙: [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md).
|
||||||
- Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md)
|
|
||||||
- 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md)
|
### 기획 · 결정
|
||||||
- **배포·잔여 실행 트랙:** [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) (N1 스모크 → N2 Docker → … → N5 사람 PoC)
|
|
||||||
- 베타 직전(C): [`docs/invite-ops.md`](./docs/invite-ops.md) · [`docs/android-release.md`](./docs/android-release.md) · [`docs/prototype.md`](./docs/prototype.md)
|
| 문서 | 용도 |
|
||||||
- PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md)
|
|------|------|
|
||||||
|
| [`docs/PLANNING.md`](./docs/PLANNING.md) | 프로세스·산출물 가이드 |
|
||||||
|
| [`docs/decision-log.md`](./docs/decision-log.md) | Q1~**Q8 확정**, **Q9 제안** (단일 결정 기준) |
|
||||||
|
| [`docs/vision.md`](./docs/vision.md) | 문제·가치제안·성공 지표 |
|
||||||
|
| [`docs/PRD.md`](./docs/PRD.md) | P0 기능·유저 플로우 |
|
||||||
|
| [`docs/tech-design.md`](./docs/tech-design.md) | 온디바이스/서버 경계·자율성 엔진 |
|
||||||
|
| [`docs/risk-log.md`](./docs/risk-log.md) | 리스크·완화 추적 |
|
||||||
|
| [`docs/account-settings-ia.md`](./docs/account-settings-ia.md) | 계정·로그인·설정 IA (Q8) · L2 의미 갭 (Q9) |
|
||||||
|
|
||||||
|
### 실행 · 잔여 작업 (여기가 본체)
|
||||||
|
|
||||||
|
| 문서 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| [`docs/roadmap.md`](./docs/roadmap.md) | Phase 체크리스트 (`[x]` / `[~]` / `[ ]`) |
|
||||||
|
| [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) | **배포·잔여 실행 트랙** (DONE/NOW/NEXT · Track A~C · N1~N5 · N4-W) |
|
||||||
|
| [`docs/web-upgrade.md`](./docs/web-upgrade.md) | **웹 고도화** (데모/프리뷰 · W0~W7 · Web Push · PWA). Q9 비범위 |
|
||||||
|
|
||||||
|
남은 일: 웹은 `web-upgrade.md` / `deploy-checklist` §N4-W. Android/FCM 실기기·APK·SHARE_URL·PoC는 §N4·§N5.
|
||||||
|
|
||||||
|
### 배포 · 운영
|
||||||
|
|
||||||
|
| 문서 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| [`docs/deploy-docker.md`](./docs/deploy-docker.md) | Compose·이미지 |
|
||||||
|
| [`docs/deploy-portainer.md`](./docs/deploy-portainer.md) | Portainer·컷오버 |
|
||||||
|
| [`docs/ops-backup.md`](./docs/ops-backup.md) | 백업 |
|
||||||
|
| [`docs/invite-ops.md`](./docs/invite-ops.md) | 초대 코드 운영 |
|
||||||
|
| [`docs/fcm-setup.md`](./docs/fcm-setup.md) | FCM Android N4-1~4 · Web Push(W4) dart-define 주입 |
|
||||||
|
| [`docs/android-release.md`](./docs/android-release.md) | 내부 release APK 서명 |
|
||||||
|
| [`docs/tester-guide.md`](./docs/tester-guide.md) | 테스터 안내 · `DEMO-YKAVU` |
|
||||||
|
|
||||||
|
### PoC · 회의
|
||||||
|
|
||||||
|
| 문서 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| [`docs/poc-plan.md`](./docs/poc-plan.md) | PoC #1/#3 실행 계획 |
|
||||||
|
| [`docs/poc-materials.md`](./docs/poc-materials.md) | 모집·역할극 자료 |
|
||||||
|
| [`docs/user-interview-guide.md`](./docs/user-interview-guide.md) | Q3 자율성 인터뷰 |
|
||||||
|
| [`docs/prototype.md`](./docs/prototype.md) | 클릭 프로토타입 앵커 · `SHARE_URL` (Master 기입) |
|
||||||
|
| [`docs/meeting-review-summary.md`](./docs/meeting-review-summary.md) | 회의 1페이지 요약 |
|
||||||
|
|
||||||
## AI 에이전트 규칙
|
## AI 에이전트 규칙
|
||||||
|
|
||||||
- [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md)
|
- [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md)
|
||||||
- **브랜치:** 작업·머지는 항상 `main`
|
- **브랜치:** 작업·머지는 항상 `main`
|
||||||
- **리모트:** GitHub `origin` + Gitea `gitea`(iykyka) — `main` 갱신 후 `./scripts/push-both.sh`
|
- **리모트:** GitHub `origin` + Gitea `gitea`(iykyka) — `main` 갱신 후 `./scripts/push-both.sh`
|
||||||
|
- **Q9** (L2 수신 트리거 자동응대)는 Master 확정 전 구현 금지
|
||||||
|
|
||||||
## 현재 단계
|
## 현재 단계
|
||||||
|
|
||||||
- Phase 1 **A~C** + **N2 컷오버 + N3 안정화 완료** — 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com)
|
- Phase 1 **A~C** + Track A/B/C + **Q8**(로그인·설정·로그아웃) 코드 완료
|
||||||
- 테스터 안내: [`docs/tester-guide.md`](./docs/tester-guide.md) · 데모 코드 **`DEMO-YKAVU`**
|
- 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com) · 데모 코드 **`DEMO-YKAVU`**
|
||||||
- **다음 (선택):** N4 FCM·Android QA
|
- **웹 고도화 (W0~W7):** 구현·배포 완료 — [`docs/web-upgrade.md`](./docs/web-upgrade.md).
|
||||||
- **맨 마지막:** N5 / D — 사람 PoC (`docs/roadmap.md` §3). §3 기본값 추측 금지
|
**남은 것:** W4 **accept** (하드 리프레시·알림 허용·로그인 후 `/admin/push-test` → `sent >= 1`)
|
||||||
- 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입
|
- **후순위 (Master):** Android N4 — `google-services.json` PC 배치 · 실기기 FCM · UI 탭 · APK · `SHARE_URL`
|
||||||
|
- **결정 대기:** Q9 — L2 의미 (카피 정렬 ± 수신 자동응대) — 웹 트랙에도 **비범위**
|
||||||
|
- **맨 마지막:** N5 / D — 사람 PoC. §3 기본값 추측 금지
|
||||||
|
|
||||||
|
상세 현황: [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) §0.
|
||||||
|
|
||||||
## 로컬 실행 (요약)
|
## 로컬 실행 (요약)
|
||||||
|
|
||||||
|
|
@ -60,6 +105,7 @@ cd mobile && flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
|
||||||
|
|
||||||
- 구성: [`docs/deploy-docker.md`](./docs/deploy-docker.md)
|
- 구성: [`docs/deploy-docker.md`](./docs/deploy-docker.md)
|
||||||
- Portainer·컷오버: [`docs/deploy-portainer.md`](./docs/deploy-portainer.md)
|
- Portainer·컷오버: [`docs/deploy-portainer.md`](./docs/deploy-portainer.md)
|
||||||
|
- FCM: [`docs/fcm-setup.md`](./docs/fcm-setup.md)
|
||||||
- 서버 기동: `./scripts/server-up.sh` (호스트에서 `.env` 채운 뒤)
|
- 서버 기동: `./scripts/server-up.sh` (호스트에서 `.env` 채운 뒤)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
@ -46,29 +47,49 @@ func contactJSON(ct Contact) gin.H {
|
||||||
}
|
}
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
InviteCode string `json:"invite_code" binding:"required"`
|
InviteCode string `json:"invite_code" binding:"required"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||||
|
// Q8b: re-enter closed beta with invite code (+ display_name for shared DEMO).
|
||||||
r.POST("/auth/login", func(c *gin.Context) {
|
r.POST("/auth/login", func(c *gin.Context) {
|
||||||
var req loginRequest
|
var req loginRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var invite InviteCode
|
|
||||||
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if invite.UsedAt == nil || invite.UsedByUserID == nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invite code not yet used for signup"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var user User
|
var user User
|
||||||
if err := db.First(&user, *invite.UsedByUserID).Error; err != nil {
|
if demoInviteEnabled() && isDemoInviteCode(req.InviteCode) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
|
name := strings.TrimSpace(req.DisplayName)
|
||||||
return
|
if name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "display_name is required for DEMO invite login"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Demo signups store a unique per-user invite_code, not DEMO-YKAVU.
|
||||||
|
// Match the most recent user with this display_name (testers reuse names rarely).
|
||||||
|
if err := db.Where("display_name = ?", name).Order("id desc").First(&user).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"detail": "no user with that display_name under DEMO signup"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var invite InviteCode
|
||||||
|
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if invite.UsedAt == nil || invite.UsedByUserID == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invite code not yet used for signup"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.First(&user, *invite.UsedByUserID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if name := strings.TrimSpace(req.DisplayName); name != "" && user.DisplayName != name {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"detail": "display_name does not match this invite"})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
session, err := createSession(db, user.ID)
|
session, err := createSession(db, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -257,15 +278,7 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||||
query.Order("id asc").Find(&messages)
|
query.Order("id asc").Find(&messages)
|
||||||
out := make([]gin.H, 0, len(messages))
|
out := make([]gin.H, 0, len(messages))
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
out = append(out, gin.H{
|
out = append(out, messageJSON(m))
|
||||||
"id": m.ID,
|
|
||||||
"conversation_id": m.ConversationID,
|
|
||||||
"sender_id": m.SenderID,
|
|
||||||
"sender_mode": m.SenderMode,
|
|
||||||
"text": m.Text,
|
|
||||||
"retracted": m.Retracted,
|
|
||||||
"created_at": m.CreatedAt,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"messages": out})
|
c.JSON(http.StatusOK, gin.H{"messages": out})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ func TestLoginReturnsSessionToken(t *testing.T) {
|
||||||
t.Fatalf("signup: %d", signup.StatusCode)
|
t.Fatalf("signup: %d", signup.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
login := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: code})
|
login := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: code, DisplayName: "로그인"})
|
||||||
if login.StatusCode != http.StatusOK {
|
if login.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("expected 200 login, got %d", login.StatusCode)
|
t.Fatalf("expected 200 login, got %d", login.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +74,23 @@ func TestLoginReturnsSessionToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDemoLoginRequiresDisplayName(t *testing.T) {
|
||||||
|
t.Setenv("ALLOW_DEMO_INVITE", "1")
|
||||||
|
server, _ := setupTestServer(t)
|
||||||
|
signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: demoInviteCode, DisplayName: "데모유저"})
|
||||||
|
if signup.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("demo signup: %d", signup.StatusCode)
|
||||||
|
}
|
||||||
|
missing := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: demoInviteCode})
|
||||||
|
if missing.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 without display_name, got %d", missing.StatusCode)
|
||||||
|
}
|
||||||
|
ok := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: demoInviteCode, DisplayName: "데모유저"})
|
||||||
|
if ok.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 demo login, got %d", ok.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConversationContactMessageHistoryAndEscalationLogs(t *testing.T) {
|
func TestConversationContactMessageHistoryAndEscalationLogs(t *testing.T) {
|
||||||
server, _ := setupTestServer(t)
|
server, _ := setupTestServer(t)
|
||||||
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
|
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,15 @@ type registerDeviceRequest struct {
|
||||||
|
|
||||||
func messageJSON(m Message) gin.H {
|
func messageJSON(m Message) gin.H {
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"id": m.ID,
|
"id": m.ID,
|
||||||
"conversation_id": m.ConversationID,
|
"conversation_id": m.ConversationID,
|
||||||
"sender_id": m.SenderID,
|
"sender_id": m.SenderID,
|
||||||
"sender_mode": m.SenderMode,
|
"sender_mode": m.SenderMode,
|
||||||
"text": m.Text,
|
"text": m.Text,
|
||||||
"retracted": m.Retracted,
|
"retracted": m.Retracted,
|
||||||
"created_at": m.CreatedAt,
|
"draft_edited": m.DraftEdited,
|
||||||
|
"naturalness_rating": m.NaturalnessRating,
|
||||||
|
"created_at": m.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,6 +253,8 @@ const adminDashboardHTML = `<!doctype html>
|
||||||
['twin msgs', data.messages_twin_total],
|
['twin msgs', data.messages_twin_total],
|
||||||
['escalations', data.escalations_total],
|
['escalations', data.escalations_total],
|
||||||
['peer veto rate', (data.peer_veto_rate || 0).toFixed(3)],
|
['peer veto rate', (data.peer_veto_rate || 0).toFixed(3)],
|
||||||
|
['초안 무수정 발송률', (data.draft_unedited_rate || 0).toFixed(3) + ' (n=' + (data.draft_edited_tracked_total || 0) + ')'],
|
||||||
|
['자연스러움 긍정률', (data.naturalness_positive_rate || 0).toFixed(3) + ' (n=' + (data.naturalness_ratings_total || 0) + ')'],
|
||||||
['draft req', data.draft_requests],
|
['draft req', data.draft_requests],
|
||||||
['draft err rate', (data.draft_error_rate || 0).toFixed(3)],
|
['draft err rate', (data.draft_error_rate || 0).toFixed(3)],
|
||||||
['draft avg ms', (data.draft_latency_avg_ms || 0).toFixed(1)],
|
['draft avg ms', (data.draft_latency_avg_ms || 0).toFixed(1)],
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,20 @@ type sendMessageRequest struct {
|
||||||
// L2 draft outside the whitelist (PRD.md §2.2). Ignored for
|
// L2 draft outside the whitelist (PRD.md §2.2). Ignored for
|
||||||
// human-authored messages.
|
// human-authored messages.
|
||||||
Approved bool `json:"approved"`
|
Approved bool `json:"approved"`
|
||||||
|
// OriginalDraftText is the AI draft's text before any edits, sent by the
|
||||||
|
// client alongside the (possibly edited) final Text so the server can
|
||||||
|
// diff them into Message.DraftEdited (PRD.md §5 L1 approval-rate proxy,
|
||||||
|
// deploy-checklist.md N4-12). Optional -- omitted/empty means "not
|
||||||
|
// derived from a draft, or an older client", and DraftEdited stays nil
|
||||||
|
// rather than being guessed. Only consulted for SenderMode == twin.
|
||||||
|
OriginalDraftText string `json:"original_draft_text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageFeedbackRequest is the explicit naturalness signal (vision.md
|
||||||
|
// metric, PRD.md §5, deploy-checklist.md N4-12) — "이 답장 나답아요?" one-tap
|
||||||
|
// thumbs up/down on a twin-authored message.
|
||||||
|
type messageFeedbackRequest struct {
|
||||||
|
Natural bool `json:"natural"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateTwinSettingsRequest struct {
|
type updateTwinSettingsRequest struct {
|
||||||
|
|
@ -125,6 +139,34 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal)
|
peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L1 approval-rate proxy (PRD.md §5 "초안을 수정 없이 그대로 발송한
|
||||||
|
// 비율", vision.md 자연스러움 지표, deploy-checklist.md N4-12):
|
||||||
|
// draft_edited is only set (non-nil) for twin-mode messages sent
|
||||||
|
// through the draft-approval flow with an original draft to diff
|
||||||
|
// against -- messages where it's nil (human-authored, or no draft
|
||||||
|
// text sent) are excluded from both counts so they can't dilute
|
||||||
|
// the rate either way. Zero-tracked-messages guarded the same way
|
||||||
|
// peer_veto_rate guards zero conversations above.
|
||||||
|
var draftTrackedTotal, draftEditedTrue int64
|
||||||
|
db.Model(&Message{}).Where("sender_mode = ? AND draft_edited IS NOT NULL", SenderTwin).Count(&draftTrackedTotal)
|
||||||
|
db.Model(&Message{}).Where("sender_mode = ? AND draft_edited = ?", SenderTwin, true).Count(&draftEditedTrue)
|
||||||
|
var draftUneditedRate float64
|
||||||
|
if draftTrackedTotal > 0 {
|
||||||
|
draftUneditedRate = float64(draftTrackedTotal-draftEditedTrue) / float64(draftTrackedTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit naturalness signal ("이 답장 나답아요?" 원탭, vision.md 지표,
|
||||||
|
// deploy-checklist.md N4-12): same nil-excluded-from-denominator
|
||||||
|
// shape as draft_unedited_rate above -- unrated messages don't
|
||||||
|
// count as either positive or negative.
|
||||||
|
var naturalnessTrackedTotal, naturalnessPositiveTrue int64
|
||||||
|
db.Model(&Message{}).Where("sender_mode = ? AND naturalness_rating IS NOT NULL", SenderTwin).Count(&naturalnessTrackedTotal)
|
||||||
|
db.Model(&Message{}).Where("sender_mode = ? AND naturalness_rating = ?", SenderTwin, true).Count(&naturalnessPositiveTrue)
|
||||||
|
var naturalnessPositiveRate float64
|
||||||
|
if naturalnessTrackedTotal > 0 {
|
||||||
|
naturalnessPositiveRate = float64(naturalnessPositiveTrue) / float64(naturalnessTrackedTotal)
|
||||||
|
}
|
||||||
|
|
||||||
rt := runtimeMetrics.snapshot()
|
rt := runtimeMetrics.snapshot()
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"users_total": usersTotal,
|
"users_total": usersTotal,
|
||||||
|
|
@ -139,6 +181,14 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
// vision.md doesn't pin down the exact denominator, so treat
|
// vision.md doesn't pin down the exact denominator, so treat
|
||||||
// this as a first approximation, not the final definition.
|
// this as a first approximation, not the final definition.
|
||||||
"peer_veto_rate": peerVetoRate,
|
"peer_veto_rate": peerVetoRate,
|
||||||
|
// L1 approval-rate proxy / naturalness instrumentation (PRD.md §5,
|
||||||
|
// vision.md, deploy-checklist.md N4-12). This is capture
|
||||||
|
// infrastructure, not a PoC conclusion -- see docs/roadmap.md and
|
||||||
|
// docs/deploy-checklist.md N4-12 for that distinction.
|
||||||
|
"draft_edited_tracked_total": draftTrackedTotal,
|
||||||
|
"draft_unedited_rate": draftUneditedRate,
|
||||||
|
"naturalness_ratings_total": naturalnessTrackedTotal,
|
||||||
|
"naturalness_positive_rate": naturalnessPositiveRate,
|
||||||
// Process-local draft/AI timings (roadmap B). Reset on restart.
|
// Process-local draft/AI timings (roadmap B). Reset on restart.
|
||||||
"draft_requests": rt.DraftRequests,
|
"draft_requests": rt.DraftRequests,
|
||||||
"draft_errors": rt.DraftErrors,
|
"draft_errors": rt.DraftErrors,
|
||||||
|
|
@ -371,11 +421,23 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Implicit L1-approval-rate signal (PRD.md §5, deploy-checklist.md
|
||||||
|
// N4-12): only meaningful for twin-mode sends where the client told
|
||||||
|
// us what the original draft said. Absent/empty original text
|
||||||
|
// (human messages, or a client that isn't draft-derived) leaves
|
||||||
|
// DraftEdited nil rather than guessing true/false.
|
||||||
|
var draftEdited *bool
|
||||||
|
if req.SenderMode == SenderTwin && req.OriginalDraftText != "" {
|
||||||
|
edited := req.Text != req.OriginalDraftText
|
||||||
|
draftEdited = &edited
|
||||||
|
}
|
||||||
|
|
||||||
message := Message{
|
message := Message{
|
||||||
ConversationID: convID,
|
ConversationID: convID,
|
||||||
SenderID: req.SenderID,
|
SenderID: req.SenderID,
|
||||||
SenderMode: req.SenderMode,
|
SenderMode: req.SenderMode,
|
||||||
Text: req.Text,
|
Text: req.Text,
|
||||||
|
DraftEdited: draftEdited,
|
||||||
}
|
}
|
||||||
if err := db.Create(&message).Error; err != nil {
|
if err := db.Create(&message).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
|
|
@ -432,6 +494,46 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
c.JSON(http.StatusOK, gin.H{"id": message.ID, "retracted": true})
|
c.JSON(http.StatusOK, gin.H{"id": message.ID, "retracted": true})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// "이 답장 나답아요?" 자연스러움 피드백(vision.md 지표, PRD.md §5,
|
||||||
|
// deploy-checklist.md N4-12): 트윈이 실제로 보낸 메시지 위에 붙는 가벼운
|
||||||
|
// 원탭 피드백 -- 사람이 직접 쓴 메시지는 이 지표의 대상이 아니라 400.
|
||||||
|
// 재제출은 덮어쓰기로 처리한다(409로 막지 않음): 클라이언트가 이미 한
|
||||||
|
// 번 탭한 메시지는 다시 UI를 보여주지 않아 재제출 자체가 드물고, 네트워크
|
||||||
|
// 재시도 등으로 같은 요청이 두 번 가더라도 "평가를 덮어쓴다"는 동작이
|
||||||
|
// 사용자에게 해롭지 않기 때문 -- retract의 409(한 번 되돌린 걸 다시
|
||||||
|
// 되돌릴 수 없음, 되돌리기는 상태를 한 방향으로만 바꿈)와는 성격이 다름.
|
||||||
|
r.POST("/messages/:id/feedback", func(c *gin.Context) {
|
||||||
|
msgID, ok := parseUintParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var message Message
|
||||||
|
if err := db.First(&message, msgID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"detail": "message not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if message.SenderMode != SenderTwin {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "only twin-authored messages can be rated for naturalness"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req messageFeedbackRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
natural := req.Natural
|
||||||
|
message.NaturalnessRating = &natural
|
||||||
|
if err := db.Save(&message).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, messageJSON(message))
|
||||||
|
})
|
||||||
|
|
||||||
r.POST("/conversations/:id/draft", func(c *gin.Context) {
|
r.POST("/conversations/:id/draft", func(c *gin.Context) {
|
||||||
convID, ok := parseUintParam(c, "id")
|
convID, ok := parseUintParam(c, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
|
|
@ -937,6 +937,304 @@ func TestRetractMissingMessage(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Draft-edited implicit signal (PRD.md §5, deploy-checklist.md N4-12) ---
|
||||||
|
|
||||||
|
func TestDraftEditedTrueWhenSentTextDiffersFromOriginalDraft(t *testing.T) {
|
||||||
|
server, db := setupTestServer(t)
|
||||||
|
senderID, token := mustSignup(t, server.URL, "수정함")
|
||||||
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
||||||
|
|
||||||
|
conv := Conversation{IsGroup: false}
|
||||||
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
t.Fatalf("create conversation: %v", err)
|
||||||
|
}
|
||||||
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
||||||
|
|
||||||
|
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
|
||||||
|
SenderID: senderID, Text: "ㅇㅇ 좋아 이따 보자", SenderMode: SenderTwin, Approved: true,
|
||||||
|
OriginalDraftText: "ㅇㅇ 좋아",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var out map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
msgID := uint(out["id"].(float64))
|
||||||
|
|
||||||
|
var message Message
|
||||||
|
db.First(&message, msgID)
|
||||||
|
if message.DraftEdited == nil || *message.DraftEdited != true {
|
||||||
|
t.Fatalf("expected DraftEdited=true, got %v", message.DraftEdited)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftEditedFalseWhenSentTextMatchesOriginalDraft(t *testing.T) {
|
||||||
|
server, db := setupTestServer(t)
|
||||||
|
senderID, token := mustSignup(t, server.URL, "그대로")
|
||||||
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
||||||
|
|
||||||
|
conv := Conversation{IsGroup: false}
|
||||||
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
t.Fatalf("create conversation: %v", err)
|
||||||
|
}
|
||||||
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
||||||
|
|
||||||
|
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
|
||||||
|
SenderID: senderID, Text: "ㅇㅇ 좋아", SenderMode: SenderTwin, Approved: true,
|
||||||
|
OriginalDraftText: "ㅇㅇ 좋아",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var out map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
msgID := uint(out["id"].(float64))
|
||||||
|
|
||||||
|
var message Message
|
||||||
|
db.First(&message, msgID)
|
||||||
|
if message.DraftEdited == nil || *message.DraftEdited != false {
|
||||||
|
t.Fatalf("expected DraftEdited=false, got %v", message.DraftEdited)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftEditedNilWhenNoOriginalDraftTextSent(t *testing.T) {
|
||||||
|
server, db := setupTestServer(t)
|
||||||
|
senderID, token := mustSignup(t, server.URL, "구버전클라")
|
||||||
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
||||||
|
|
||||||
|
conv := Conversation{IsGroup: false}
|
||||||
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
t.Fatalf("create conversation: %v", err)
|
||||||
|
}
|
||||||
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
||||||
|
|
||||||
|
// No OriginalDraftText field at all (e.g. an older client) -- must not
|
||||||
|
// be guessed as edited or unedited, stays nil.
|
||||||
|
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
|
||||||
|
SenderID: senderID, Text: "ㅇㅇ 알겠음", SenderMode: SenderTwin, Approved: true,
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var out map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
msgID := uint(out["id"].(float64))
|
||||||
|
if _, present := out["draft_edited"]; !present || out["draft_edited"] != nil {
|
||||||
|
t.Fatalf("expected draft_edited to be null in response, got %v", out["draft_edited"])
|
||||||
|
}
|
||||||
|
|
||||||
|
var message Message
|
||||||
|
db.First(&message, msgID)
|
||||||
|
if message.DraftEdited != nil {
|
||||||
|
t.Fatalf("expected DraftEdited nil, got %v", *message.DraftEdited)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDraftEditedNotSetForHumanMessages(t *testing.T) {
|
||||||
|
server, db := setupTestServer(t)
|
||||||
|
senderID, _ := mustSignup(t, server.URL, "사람메시지")
|
||||||
|
|
||||||
|
conv := Conversation{IsGroup: false}
|
||||||
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
t.Fatalf("create conversation: %v", err)
|
||||||
|
}
|
||||||
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
||||||
|
|
||||||
|
// Even if a caller weirdly sent original_draft_text on a human message,
|
||||||
|
// it must be ignored -- DraftEdited is a twin-only signal.
|
||||||
|
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
|
||||||
|
SenderID: senderID, Text: "안녕", SenderMode: SenderHuman,
|
||||||
|
OriginalDraftText: "다른 텍스트",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var out map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
msgID := uint(out["id"].(float64))
|
||||||
|
|
||||||
|
var message Message
|
||||||
|
db.First(&message, msgID)
|
||||||
|
if message.DraftEdited != nil {
|
||||||
|
t.Fatalf("expected DraftEdited nil for a human message, got %v", *message.DraftEdited)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Explicit naturalness feedback ("이 답장 나답아요?", vision.md, N4-12) ---
|
||||||
|
|
||||||
|
func TestMessageFeedbackOnTwinMessageReflectedOnRefetch(t *testing.T) {
|
||||||
|
server, db := setupTestServer(t)
|
||||||
|
senderID, token := mustSignup(t, server.URL, "피드백")
|
||||||
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
||||||
|
|
||||||
|
conv := Conversation{IsGroup: false}
|
||||||
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
t.Fatalf("create conversation: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&ConversationParticipant{ConversationID: conv.ID, UserID: senderID}).Error; err != nil {
|
||||||
|
t.Fatalf("create participant: %v", err)
|
||||||
|
}
|
||||||
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
||||||
|
|
||||||
|
sendResp := postJSON(t, convBase+"/messages", sendMessageRequest{
|
||||||
|
SenderID: senderID, Text: "ㅇㅇ 좋아", SenderMode: SenderTwin, Approved: true,
|
||||||
|
})
|
||||||
|
var sent map[string]interface{}
|
||||||
|
json.NewDecoder(sendResp.Body).Decode(&sent)
|
||||||
|
msgID := uint(sent["id"].(float64))
|
||||||
|
|
||||||
|
feedbackResp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/feedback", messageFeedbackRequest{Natural: true})
|
||||||
|
if feedbackResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 submitting feedback, got %d", feedbackResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch via the conversation history endpoint (authenticated) and
|
||||||
|
// confirm the rating stuck.
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, convBase+"/messages", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
listResp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list messages: %v", err)
|
||||||
|
}
|
||||||
|
var listOut map[string]interface{}
|
||||||
|
json.NewDecoder(listResp.Body).Decode(&listOut)
|
||||||
|
messages := listOut["messages"].([]interface{})
|
||||||
|
var found map[string]interface{}
|
||||||
|
for _, m := range messages {
|
||||||
|
mm := m.(map[string]interface{})
|
||||||
|
if uint(mm["id"].(float64)) == msgID {
|
||||||
|
found = mm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatalf("sent message not found in refetch: %v", messages)
|
||||||
|
}
|
||||||
|
if found["naturalness_rating"] != true {
|
||||||
|
t.Fatalf("expected naturalness_rating=true on refetch, got %v", found["naturalness_rating"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resubmission overwrites rather than 409ing (documented choice: a
|
||||||
|
// bounded rating change isn't unsafe the way re-retracting would be).
|
||||||
|
overwrite := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/feedback", messageFeedbackRequest{Natural: false})
|
||||||
|
if overwrite.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 overwriting feedback, got %d", overwrite.StatusCode)
|
||||||
|
}
|
||||||
|
var overwriteOut map[string]interface{}
|
||||||
|
json.NewDecoder(overwrite.Body).Decode(&overwriteOut)
|
||||||
|
if overwriteOut["naturalness_rating"] != false {
|
||||||
|
t.Fatalf("expected naturalness_rating=false after overwrite, got %v", overwriteOut["naturalness_rating"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageFeedbackRejectsHumanMessage(t *testing.T) {
|
||||||
|
server, db := setupTestServer(t)
|
||||||
|
senderID, _ := mustSignup(t, server.URL, "사람피드백")
|
||||||
|
|
||||||
|
conv := Conversation{IsGroup: false}
|
||||||
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
t.Fatalf("create conversation: %v", err)
|
||||||
|
}
|
||||||
|
sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
|
||||||
|
SenderID: senderID, Text: "안녕", SenderMode: SenderHuman,
|
||||||
|
})
|
||||||
|
var sent map[string]interface{}
|
||||||
|
json.NewDecoder(sendResp.Body).Decode(&sent)
|
||||||
|
msgID := uint(sent["id"].(float64))
|
||||||
|
|
||||||
|
resp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/feedback", messageFeedbackRequest{Natural: true})
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 rating a human message, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageFeedbackMissingMessage(t *testing.T) {
|
||||||
|
server, _ := setupTestServer(t)
|
||||||
|
resp := postJSON(t, server.URL+"/messages/9999/feedback", messageFeedbackRequest{Natural: true})
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- /admin/metrics rate calculations for the two signals above ---
|
||||||
|
|
||||||
|
func TestAdminMetricsDraftUneditedAndNaturalnessRates(t *testing.T) {
|
||||||
|
runtimeMetrics = &RuntimeMetrics{} // see comment in TestAdminMetricsCountsMessagesEscalationsAndVeto
|
||||||
|
server, db := setupTestServer(t)
|
||||||
|
senderID, token := mustSignup(t, server.URL, "지표계산")
|
||||||
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
||||||
|
|
||||||
|
conv := Conversation{IsGroup: false}
|
||||||
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
t.Fatalf("create conversation: %v", err)
|
||||||
|
}
|
||||||
|
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
|
||||||
|
|
||||||
|
// 2 edited + 3 unedited twin sends -> draft_unedited_rate should be
|
||||||
|
// 3/5 = 0.6. A plain human message and a twin send with no
|
||||||
|
// original_draft_text must not affect the denominator either way.
|
||||||
|
postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "인간이 직접", SenderMode: SenderHuman})
|
||||||
|
postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "초안없음", SenderMode: SenderTwin, Approved: true})
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
postJSON(t, convBase+"/messages", sendMessageRequest{
|
||||||
|
SenderID: senderID, Text: "수정된 문구", SenderMode: SenderTwin, Approved: true,
|
||||||
|
OriginalDraftText: "원래 초안",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
postJSON(t, convBase+"/messages", sendMessageRequest{
|
||||||
|
SenderID: senderID, Text: "그대로", SenderMode: SenderTwin, Approved: true,
|
||||||
|
OriginalDraftText: "그대로",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
mreq, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil)
|
||||||
|
mreq.Header.Set("Authorization", "Bearer test-admin-token")
|
||||||
|
resp, err := http.DefaultClient.Do(mreq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get metrics: %v", err)
|
||||||
|
}
|
||||||
|
var out map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
|
||||||
|
if out["draft_edited_tracked_total"].(float64) != 5 {
|
||||||
|
t.Fatalf("expected 5 draft-tracked messages, got %v", out["draft_edited_tracked_total"])
|
||||||
|
}
|
||||||
|
if got := out["draft_unedited_rate"].(float64); got != 0.6 {
|
||||||
|
t.Fatalf("expected draft_unedited_rate 0.6, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No naturalness ratings submitted yet -- must not divide by zero.
|
||||||
|
if out["naturalness_ratings_total"].(float64) != 0 {
|
||||||
|
t.Fatalf("expected 0 naturalness ratings, got %v", out["naturalness_ratings_total"])
|
||||||
|
}
|
||||||
|
if out["naturalness_positive_rate"].(float64) != 0 {
|
||||||
|
t.Fatalf("expected naturalness_positive_rate 0 (zero-guarded), got %v", out["naturalness_positive_rate"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate a couple of the twin messages we just sent and check the rate
|
||||||
|
// updates too (1 positive out of 2 rated -> 0.5).
|
||||||
|
var twinMessages []Message
|
||||||
|
db.Where("sender_mode = ?", SenderTwin).Order("id asc").Find(&twinMessages)
|
||||||
|
if len(twinMessages) < 2 {
|
||||||
|
t.Fatalf("expected at least 2 twin messages, got %d", len(twinMessages))
|
||||||
|
}
|
||||||
|
postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(twinMessages[0].ID), 10)+"/feedback", messageFeedbackRequest{Natural: true})
|
||||||
|
postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(twinMessages[1].ID), 10)+"/feedback", messageFeedbackRequest{Natural: false})
|
||||||
|
|
||||||
|
resp2, err := http.DefaultClient.Do(mreq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get metrics again: %v", err)
|
||||||
|
}
|
||||||
|
var out2 map[string]interface{}
|
||||||
|
json.NewDecoder(resp2.Body).Decode(&out2)
|
||||||
|
if out2["naturalness_ratings_total"].(float64) != 2 {
|
||||||
|
t.Fatalf("expected 2 naturalness ratings, got %v", out2["naturalness_ratings_total"])
|
||||||
|
}
|
||||||
|
if got := out2["naturalness_positive_rate"].(float64); got != 0.5 {
|
||||||
|
t.Fatalf("expected naturalness_positive_rate 0.5, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDraftMissingConversation(t *testing.T) {
|
func TestDraftMissingConversation(t *testing.T) {
|
||||||
server, _ := setupTestServer(t)
|
server, _ := setupTestServer(t)
|
||||||
resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{
|
resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,22 @@ type Message struct {
|
||||||
// one-tap undo") -- set via POST /messages/:id/retract, twin-authored
|
// one-tap undo") -- set via POST /messages/:id/retract, twin-authored
|
||||||
// messages only.
|
// messages only.
|
||||||
Retracted bool `gorm:"not null;default:false"`
|
Retracted bool `gorm:"not null;default:false"`
|
||||||
CreatedAt time.Time
|
// DraftEdited is the implicit L1-approval-rate signal (PRD.md §5's
|
||||||
|
// "초안을 수정 없이 그대로 발송한 비율", vision.md's naturalness metric,
|
||||||
|
// deploy-checklist.md N4-12). Nil for human-authored messages and for
|
||||||
|
// any message not sent through the draft-approval flow (old rows, or a
|
||||||
|
// client that didn't send original_draft_text) -- only set to a
|
||||||
|
// concrete true/false for twin-mode messages where the client told us
|
||||||
|
// what the original AI draft text was, so we could diff it against
|
||||||
|
// what actually got sent. Never guessed.
|
||||||
|
DraftEdited *bool
|
||||||
|
// NaturalnessRating is the explicit "이 답장 나답아요?" signal (vision.md
|
||||||
|
// naturalness metric, PRD.md §5, deploy-checklist.md N4-12): true = 👍,
|
||||||
|
// false = 👎, nil = not rated yet. Set via POST /messages/:id/feedback,
|
||||||
|
// twin-authored messages only (rating a human's own words doesn't make
|
||||||
|
// sense for this metric).
|
||||||
|
NaturalnessRating *bool
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type TwinSettings struct {
|
type TwinSettings struct {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,25 @@ server {
|
||||||
# Docker Compose embedded DNS
|
# Docker Compose embedded DNS
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
|
||||||
# Flutter SPA
|
# Entrypoint + service worker: always revalidate (PWA / docs/web-upgrade N4-W5).
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
location = /flutter_service_worker.js {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
location = /manifest.json {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
location = /firebase-messaging-sw.js {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Flutter SPA shell — offline shell cache is SW-managed; HTML fallback for deep links.
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,14 @@ services:
|
||||||
dockerfile: mobile/Dockerfile
|
dockerfile: mobile/Dockerfile
|
||||||
args:
|
args:
|
||||||
CORE_API_BASE: ${PUBLIC_API_BASE:-https://msn.iykyka.com}
|
CORE_API_BASE: ${PUBLIC_API_BASE:-https://msn.iykyka.com}
|
||||||
|
# N4-W4 Web Push — set in host .env (never commit). Empty → install: fallback.
|
||||||
|
FIREBASE_API_KEY: ${FIREBASE_API_KEY:-}
|
||||||
|
FIREBASE_AUTH_DOMAIN: ${FIREBASE_AUTH_DOMAIN:-}
|
||||||
|
FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-}
|
||||||
|
FIREBASE_STORAGE_BUCKET: ${FIREBASE_STORAGE_BUCKET:-}
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID: ${FIREBASE_MESSAGING_SENDER_ID:-}
|
||||||
|
FIREBASE_APP_ID: ${FIREBASE_APP_ID:-}
|
||||||
|
FIREBASE_VAPID_KEY: ${FIREBASE_VAPID_KEY:-}
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_HOST_PORT:-8088}:80"
|
- "${WEB_HOST_PORT:-8088}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,9 @@
|
||||||
|
|
||||||
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
|
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
|
||||||
현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30).
|
현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30).
|
||||||
PoC 의존 하위 질문(자율성 기본값 등)과 **Q8/Q9(계정·설정 IA / L2 의미, 2026-08-03 제안)** 가
|
PoC 의존 하위 질문(자율성 기본값 등)과 **Q9(L2 의미, 제안)** 가 열려 있다.
|
||||||
열려 있다 — decision-log를 단일 기준으로 따른다. 계정 UX 상세는
|
**Q8(계정·설정 IA)은 2026-08-03 확정·구현** — decision-log를 단일 기준으로 따른다.
|
||||||
[`account-settings-ia.md`](./account-settings-ia.md).
|
계정 UX 상세는 [`account-settings-ia.md`](./account-settings-ia.md).
|
||||||
|
|
||||||
| # | 질문 | 결정 | 상태 |
|
| # | 질문 | 결정 | 상태 |
|
||||||
|---|------|------|------|
|
|---|------|------|------|
|
||||||
|
|
@ -107,7 +107,7 @@ PoC 의존 하위 질문(자율성 기본값 등)과 **Q8/Q9(계정·설정 IA /
|
||||||
3. [`tech-design.md`](./tech-design.md) — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직
|
3. [`tech-design.md`](./tech-design.md) — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직
|
||||||
4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적
|
4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적
|
||||||
5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, 자체 앱→OS 레이어 확장 시점 반영
|
5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, 자체 앱→OS 레이어 확장 시점 반영
|
||||||
6. [`decision-log.md`](./decision-log.md) — Q1~Q7 확정 + Q8/Q9 제안, 계속 누적 기록
|
6. [`decision-log.md`](./decision-log.md) — Q1~Q8 확정 + Q9 제안, 계속 누적 기록
|
||||||
6a. [`account-settings-ia.md`](./account-settings-ia.md) — 초대 가입·로그아웃·설정 IA·L2 갭 명세
|
6a. [`account-settings-ia.md`](./account-settings-ia.md) — 초대 가입·로그아웃·설정 IA·L2 갭 명세
|
||||||
7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준
|
7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준
|
||||||
8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안
|
8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,12 @@
|
||||||
### 2.0 계정 · 입장 (클로즈드 베타)
|
### 2.0 계정 · 입장 (클로즈드 베타)
|
||||||
|
|
||||||
Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**이다
|
Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**이다
|
||||||
(`decision-log.md` Q8 제안, `invite-ops.md`, `account-settings-ia.md`).
|
(`decision-log.md` Q8 확정, `invite-ops.md`, `account-settings-ia.md`).
|
||||||
|
|
||||||
1. **새로 가입** — 발급된 초대 코드 + 표시 이름 → 세션 토큰 발급 → 말투 온보딩
|
1. **새로 가입** — 발급된 초대 코드 + 표시 이름 → 세션 토큰 발급 → 말투 온보딩
|
||||||
2. **이미 가입** — 같은 초대 코드로 로그인(토큰 재발급) → 메인
|
2. **이미 가입** — 초대 코드 + 표시 이름으로 로그인(토큰 재발급) → 메인
|
||||||
|
(`DEMO-YKAVU`는 표시 이름으로 사용자 구분)
|
||||||
3. **로그아웃** — 설정에서 명시적 로그아웃 → 서버 세션 종료 + 기기 토큰 삭제 → 입장 화면
|
3. **로그아웃** — 설정에서 명시적 로그아웃 → 서버 세션 종료 + 기기 토큰 삭제 → 입장 화면
|
||||||
(2026-08-03 기준 앱 UI 미완 — P0 갭)
|
|
||||||
4. **탈퇴** — Phase 1 앱 UI 비범위 (운영 수동)
|
4. **탈퇴** — Phase 1 앱 UI 비범위 (운영 수동)
|
||||||
|
|
||||||
### 2.1 온보딩 (말투 학습)
|
### 2.1 온보딩 (말투 학습)
|
||||||
|
|
@ -56,7 +56,7 @@ Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**
|
||||||
| 기능 | 설명 |
|
| 기능 | 설명 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 초대 코드 가입·재로그인 | 클로즈드 베타 입장. 이메일/비번 없음 (`account-settings-ia.md`) |
|
| 초대 코드 가입·재로그인 | 클로즈드 베타 입장. 이메일/비번 없음 (`account-settings-ia.md`) |
|
||||||
| 로그아웃 | 설정에서 세션 종료 + 로컬 토큰 클리어 (P0 갭 — UI 미완) |
|
| 로그아웃 | 설정에서 세션 종료 + 로컬 토큰 클리어 |
|
||||||
| 설정 IA | 말투·자율성·세션·데이터흐름·로그아웃을 한 진입점에서 발견 가능 |
|
| 설정 IA | 말투·자율성·세션·데이터흐름·로그아웃을 한 진입점에서 발견 가능 |
|
||||||
| 와카뷰 뱃지 | 와카뷰가 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) |
|
| 와카뷰 뱃지 | 와카뷰가 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) |
|
||||||
| 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 |
|
| 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 |
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
# 계정 · 인증 · 설정 IA (Phase 1 갭 명세)
|
# 계정 · 인증 · 설정 IA (Phase 1 갭 명세)
|
||||||
|
|
||||||
권위: [`decision-log.md`](./decision-log.md) Q8·Q9 (제안) · [`PRD.md`](./PRD.md) · [`invite-ops.md`](./invite-ops.md).
|
권위: [`decision-log.md`](./decision-log.md) Q8(확정)·Q9(제안) · [`PRD.md`](./PRD.md) · [`invite-ops.md`](./invite-ops.md).
|
||||||
이 문서는 **구현 전에** 제품 경계를 고정하기 위한 명세다. Q8/Q9가 `확정`되기 전에는
|
Q8은 2026-08-03 확정·구현. Q9(수신 트리거 L2)는 제안 상태로 코드 범위를 넓히지 않는다.
|
||||||
코드를 크게 바꾸지 않는다.
|
|
||||||
|
|
||||||
## 1. 현재 상태 (as-is)
|
## 1. 현재 상태 (as-is)
|
||||||
|
|
||||||
|
|
@ -15,7 +14,7 @@
|
||||||
| 설정 | ⋮ → 말투 / 자율성(안에 세션·데이터흐름) | 단일 「설정」진입점·로그아웃 묶음 |
|
| 설정 | ⋮ → 말투 / 자율성(안에 세션·데이터흐름) | 단일 「설정」진입점·로그아웃 묶음 |
|
||||||
| L2 | 서버 발송 게이트 + 화이트리스트 키워드 | PRD §2.2식 **수신 트리거 자동응대** |
|
| L2 | 서버 발송 게이트 + 화이트리스트 키워드 | PRD §2.2식 **수신 트리거 자동응대** |
|
||||||
|
|
||||||
## 2. Phase 1 목표 모델 (to-be, Q8 제안)
|
## 2. Phase 1 목표 모델 (to-be, Q8 확정)
|
||||||
|
|
||||||
### 2.1 계정
|
### 2.1 계정
|
||||||
|
|
||||||
|
|
@ -30,10 +29,12 @@
|
||||||
→ 토큰 유효? → 메인(대화 목록)
|
→ 토큰 유효? → 메인(대화 목록)
|
||||||
→ 없음 → [입장 화면]
|
→ 없음 → [입장 화면]
|
||||||
├─ 새로 가입 (초대 코드 + 표시 이름) → 말투 온보딩 → 메인
|
├─ 새로 가입 (초대 코드 + 표시 이름) → 말투 온보딩 → 메인
|
||||||
└─ 이미 가입 (초대 코드로 로그인) → 메인
|
└─ 이미 가입 (초대 코드 + 표시 이름) → 메인
|
||||||
```
|
```
|
||||||
|
|
||||||
- 「이미 가입」은 같은 초대 코드로 `POST /auth/login`을 호출해 새 세션 토큰을 받는다.
|
- 「이미 가입」은 `POST /auth/login`으로 새 세션 토큰을 받는다.
|
||||||
|
- 일반 초대: 사용된 초대 코드 → 사용자. 표시 이름이 있으면 일치해야 함.
|
||||||
|
- `DEMO-YKAVU`: 표시 이름 **필수** (공유 코드라 이름으로 사용자 구분).
|
||||||
- 비밀번호 없음. 초대 코드 유출 = 계정 탈취 가능 → 베타 한정 리스크로 `invite-ops`에 명시.
|
- 비밀번호 없음. 초대 코드 유출 = 계정 탈취 가능 → 베타 한정 리스크로 `invite-ops`에 명시.
|
||||||
|
|
||||||
### 2.3 로그아웃 (P0 갭)
|
### 2.3 로그아웃 (P0 갭)
|
||||||
|
|
@ -51,7 +52,7 @@
|
||||||
|
|
||||||
| 항목 | 화면 | 비고 |
|
| 항목 | 화면 | 비고 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 말투 샘플 | `OnboardingToneScreen` | 기존 |
|
| 말투 · 페르소나 | `OnboardingToneScreen` | 설정 허브에서 진입 |
|
||||||
| 자율성 · 화이트리스트 · 관계 기본 | `AutonomySettingsScreen` | 기존 |
|
| 자율성 · 화이트리스트 · 관계 기본 | `AutonomySettingsScreen` | 기존 |
|
||||||
| 로그인 세션 | `SessionsScreen` | 기존, 로그아웃과 구분 |
|
| 로그인 세션 | `SessionsScreen` | 기존, 로그아웃과 구분 |
|
||||||
| 데이터 흐름 | `DataFlowScreen` | 기존 |
|
| 데이터 흐름 | `DataFlowScreen` | 기존 |
|
||||||
|
|
@ -92,12 +93,12 @@
|
||||||
|
|
||||||
## 4. 수락 기준 (구현 착수 시)
|
## 4. 수락 기준 (구현 착수 시)
|
||||||
|
|
||||||
**계정/설정 (Q8 확정 후)**
|
**계정/설정 (Q8 확정)**
|
||||||
|
|
||||||
- [ ] 입장 화면에 가입 / 이미 가입(로그인) 구분
|
- [x] 입장 화면에 가입 / 이미 가입(로그인) 구분
|
||||||
- [ ] 로그아웃 시 서버 세션 무효 + 로컬 토큰 삭제 + 입장 화면 복귀
|
- [x] 로그아웃 시 서버 세션 무효 + 로컬 토큰 삭제 + 입장 화면 복귀
|
||||||
- [ ] 설정 진입에서 로그아웃이 한 번의 탭 경로로 발견 가능
|
- [x] 설정 진입에서 로그아웃이 한 번의 탭 경로로 발견 가능
|
||||||
- [ ] 이메일/비번 UI를 추가하지 않음
|
- [x] 이메일/비번 UI를 추가하지 않음
|
||||||
|
|
||||||
**L2 정렬 (Q9 확정 후)**
|
**L2 정렬 (Q9 확정 후)**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,21 +34,17 @@
|
||||||
**순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 →
|
**순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 →
|
||||||
③ 자체 앱은 L3·L4 등 완전한 기능의 최종 목적지로 유지.
|
③ 자체 앱은 L3·L4 등 완전한 기능의 최종 목적지로 유지.
|
||||||
|
|
||||||
## Q8 — 계정·인증·설정 IA (클로즈드 베타 갭) — 제안
|
## Q8 — 계정·인증·설정 IA (클로즈드 베타 갭) — 확정
|
||||||
|
|
||||||
2026-08-03 Master 피드백: 앱에 **로그아웃/설정 진입이 불명확**하고, 일반 이메일·비밀번호
|
2026-08-03 Master 피드백 후 문서화 → **2026-08-03 Master 「1번부터 진행」으로 Q8 확정·구현**.
|
||||||
계정 시스템처럼 보이지 않는다. 코드부터 넣지 말고 문서에 먼저 못을 박는다.
|
|
||||||
|
|
||||||
| # | 질문 | 제안 결정 | 근거 | 상태 |
|
| # | 질문 | 결정 | 근거 | 상태 |
|
||||||
|---|------|----------|------|------|
|
|---|------|------|------|------|
|
||||||
| Q8a | Phase 1 계정 모델? | **초대 코드 클로즈드 베타 유지** (이메일/비번·소셜 로그인 **비범위**) | Q7 자체 앱 베타 + `invite-ops.md`. 인증 표면을 키우면 가설 검증보다 계정 인프라에 시간이 간다 | 제안 |
|
| Q8a | Phase 1 계정 모델? | **초대 코드 클로즈드 베타 유지** (이메일/비번·소셜 로그인 **비범위**) | Q7 + `invite-ops.md` | 확정 |
|
||||||
| Q8b | 재접속(로그인) UX? | **초대 코드 + 표시 이름 재입력**으로 토큰 재발급. 가입 화면에 「이미 가입」경로 명시 | 서버에 `POST /auth/login`(초대 코드)은 있음. 앱 UI가 없음 | 제안 |
|
| Q8b | 재접속(로그인) UX? | **초대 코드 + 표시 이름**으로 토큰 재발급. 입장 화면에 「이미 가입」탭. (`DEMO-YKAVU`는 표시 이름으로 사용자 구분) | 서버 login 확장 + 앱 UI | 확정 |
|
||||||
| Q8c | 로그아웃? | **필수 P0 갭**. 메뉴에서 로그아웃 → 서버 현재 세션 revoke + 로컬 토큰/유저 상태 클리어 → 가입/로그인 화면 | 지금 `SessionsScreen` 종료는 로컬 클리어가 없어 불완전 | 제안 |
|
| Q8c | 로그아웃? | **P0**. 설정에서 로그아웃 → 현재 세션 revoke + 로컬 토큰/유저 클리어 → 입장 화면 | `SessionsScreen`만으로는 불완전했음 | 확정 |
|
||||||
| Q8d | 설정 IA? | 대화 목록 **⋮ 또는 설정 진입점** 하나로 묶음: 말투 · 자율성 · 로그인 세션 · 데이터 흐름 · **로그아웃** | 자율성만 깊숙이 있어 발견성이 떨어짐 | 제안 |
|
| Q8d | 설정 IA? | 대화 목록 **설정** 화면: 말투 · 자율성 · 로그인 세션 · 데이터 흐름 · **로그아웃** | 발견성 | 확정 |
|
||||||
| Q8e | 계정 삭제/탈퇴? | Phase 1에서는 **운영자 수동/후속**. 베타 UX에 「탈퇴」를 넣지 않음(초대 코드 1회용 정책과 맞춤). 필요 시 C 이후 별도 Q | 탈퇴·GDPR급 삭제는 베타 가설 검증보다 큼 | 제안 |
|
| Q8e | 계정 삭제/탈퇴? | Phase 1 앱 UI **비범위** (운영 수동) | 베타 범위 | 확정 |
|
||||||
|
|
||||||
**Master 승인 시** 위 행 상태를 `확정`으로 바꾸고 `PRD.md` / `tech-design.md` / `roadmap.md`의
|
|
||||||
대응 항목을 같이 갱신한 뒤 구현한다.
|
|
||||||
|
|
||||||
상세 명세: [`account-settings-ia.md`](./account-settings-ia.md).
|
상세 명세: [`account-settings-ia.md`](./account-settings-ia.md).
|
||||||
|
|
||||||
|
|
@ -64,5 +60,5 @@
|
||||||
- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
|
- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
|
||||||
- 상표 정식 등록 여부 (Q6은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후)
|
- 상표 정식 등록 여부 (Q6은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후)
|
||||||
- 베타 참가자 모집 규모와 방식
|
- 베타 참가자 모집 규모와 방식
|
||||||
- Q8 / Q9 제안 → Master 확정
|
- Q9 제안 → Master 확정 (Q8은 2026-08-03 확정)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,19 +37,22 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
|
||||||
|
|
||||||
- 앱 코드는 클로즈드 베타 직전 수준
|
- 앱 코드는 클로즈드 베타 직전 수준
|
||||||
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
|
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
|
||||||
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
|
- **웹 고도화:** [`web-upgrade.md`](./web-upgrade.md) — W0~W7 구현·배포 **done** · **W4 accept만** (하드 리프레시·알림·push-test)
|
||||||
|
- 후순위: **N4 Android FCM/UI** — `google-services.json` PC 배치·실기기 (서버 SA는 배치됨)
|
||||||
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
|
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
|
||||||
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
|
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
|
||||||
- **문서 갭 (2026-08-03):** 로그아웃·설정 IA·L2 의미 —
|
- **Q8 계정·설정 IA (2026-08-03 확정·구현):** 로그인 탭 · 설정 허브 · 로그아웃
|
||||||
[`decision-log.md`](./decision-log.md) Q8/Q9 **제안**,
|
- **문서 갭 잔여:** L2 의미 [`decision-log.md`](./decision-log.md) Q9 **제안** —
|
||||||
[`account-settings-ia.md`](./account-settings-ia.md). **Master 확정 전 구현 착수하지 않음**
|
[`account-settings-ia.md`](./account-settings-ia.md) §3. Master 확정 전 Q9 구현 금지
|
||||||
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
|
(**웹 트랙에도 Q9 비범위**)
|
||||||
|
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음 (Android 트랙)
|
||||||
|
|
||||||
### NEXT 순서
|
### NEXT 순서
|
||||||
|
|
||||||
```
|
```
|
||||||
N1 스모크 → N2 Docker(msn.iykyka.com) → N3 배포 안정화
|
N1 스모크 → N2 Docker(msn.iykyka.com) → N3 배포 안정화
|
||||||
→ N4 FCM·Android QA 등 → N5 사람 PoC(D) → 실제 베타 오픈
|
→ N4-W 웹 고도화(W1→…→W7) 병행 가능
|
||||||
|
→ N4 Android FCM·UI QA 등 → N5 사람 PoC(D) → 실제 베타 오픈
|
||||||
```
|
```
|
||||||
|
|
||||||
### LOCKED
|
### LOCKED
|
||||||
|
|
@ -204,14 +207,29 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
|
||||||
| **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-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`) |
|
| **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)
|
### N4-W — 웹 고도화 — [`web-upgrade.md`](./web-upgrade.md)
|
||||||
|
|
||||||
|
데모/프리뷰 표면 유지. **Q9 비범위.** Wi 단위 승인 후 구현. 서버 FCM SA는 Android와 공유.
|
||||||
|
|
||||||
|
| ID | 작업 | Status | 비고 |
|
||||||
|
|----|------|--------|------|
|
||||||
|
| **N4-W0** | 제품 경계 · 문서 랜딩 | **done** (2026-08-04) | 성공 정의·비범위·규율 고정 |
|
||||||
|
| **N4-W1** | 웹 로컬 지속성 (prefs/idb) | **done** (2026-08-04) | `app_database_web.dart` SharedPreferences · Drift WASM 비채택 · 라이브 하드 리프레시 스모크는 배포 후 |
|
||||||
|
| **N4-W2** | 데모 메신저 UX | **done** (2026-08-04) | ≥900px 분할·WS 상태·ID 복사·웹 안내 배너 |
|
||||||
|
| **N4-W3** | 인앱·Notification API | **done** (2026-08-04) | 웹 Timer+Notification · 포커스 past-due · 설정 권한 |
|
||||||
|
| **N4-W4** | Web Push (FCM + VAPID) | **web-live** (2026-08-04) | 시크릿·`:web:`·SW 배포 done · **accept만:** 하드 리프레시·알림·실 토큰·push-test |
|
||||||
|
| **N4-W5** | PWA | **done** (2026-08-04) | manifest 테마 · nginx no-cache · OfflineBanner |
|
||||||
|
| **N4-W6** | 테스터 가이드 | **done** (2026-08-04) | `tester-guide.md` 웹 절 |
|
||||||
|
| **N4-W7** | 품질 | **done** (2026-08-04) | `scripts/e2e_web_smoke.sh` + 부록 QA (Playwright는 후속) |
|
||||||
|
|
||||||
|
### FCM (Android) — [`fcm-setup.md`](./fcm-setup.md)
|
||||||
|
|
||||||
| ID | 작업 | Status | 완료 조건 |
|
| ID | 작업 | Status | 완료 조건 |
|
||||||
|----|------|--------|-----------|
|
|----|------|--------|-----------|
|
||||||
| **N4-1** | Firebase + `google-services.json` | doing | Master: 앱 등록됨 · JSON을 Android 빌드 PC에 배치 |
|
| **N4-1** | Firebase + `google-services.json` | doing | Master: 앱 등록됨 · JSON을 Android 빌드 PC에 배치 |
|
||||||
| **N4-2** | 실 FCM registration token | done* | `PushTokenService` — Firebase 있으면 실 토큰, 없으면 `install:` (*전송은 N4-1 후) |
|
| **N4-2** | 실 FCM registration token | done* | `PushTokenService` — Firebase 있으면 실 토큰, 없으면 `install:` (*전송은 N4-1 후) |
|
||||||
| **N4-3** | 서버 FCM 자격증명 (HTTP v1) | doing | Master: `secrets/firebase-service-account.json` (레거시 서버 키 대신) |
|
| **N4-3** | 서버 FCM 자격증명 (HTTP v1) | **done** (2026-08) | 프로덕션 `secrets/firebase-service-account.json` 마운트됨. 웹 W4와 공유 |
|
||||||
| **N4-4** | 푸시 수신 | blocked | N4-1+N4-3 후 `/admin/push-test` + 기기 수신 |
|
| **N4-4** | 푸시 수신 (Android) | blocked | N4-1+N4-3 후 `/admin/push-test` + 기기 수신 |
|
||||||
|
|
||||||
### Android UI 탭 (`mobile/README.md`)
|
### Android UI 탭 (`mobile/README.md`)
|
||||||
|
|
||||||
|
|
@ -232,10 +250,10 @@ API 계층은 프로덕션에서 검증됨 (`CORE_API_BASE=https://msn.iykyka.co
|
||||||
| ID | 작업 | Status | 비고 |
|
| ID | 작업 | Status | 비고 |
|
||||||
|----|------|--------|------|
|
|----|------|--------|------|
|
||||||
| **N4-11** | 오프라인 메시지 큐 | **done (부분 검증)** (2026-08-03) | 서버는 이미 모든 메시지를 DB에 durable하게 저장 — 별도 큐를 새로 만들지 않고 `GET /conversations/:id/messages?since_id=`(신규, 파라미터 없으면 기존 전체 히스토리 그대로) + 클라이언트 WS 재연결(backoff 1s→2s→...→30s 캡, 성공 시 리셋, `mobile/lib/services/ws_client.dart`) + 재연결/앱 포그라운드 복귀 시 since_id 캐치업(`mobile/lib/screens/chat_screen.dart`)으로 gap을 메움. `core-backend/a1_a2_test.go`(`TestListMessagesSinceID`)와 `mobile/test/message_sync_test.dart`(backoff 계산 + 중복 없는 병합을 순수 함수로 뽑아 검증)로 확인한 부분과, **실제 소켓 재연결 타이밍·앱 백그라운드/포그라운드 전환에서 OS가 소켓을 어떻게 처리하는지는 단위 테스트로 증명 불가 — 실기기 Android QA 남음**(N4-C6b와 같은 프레이밍) |
|
| **N4-11** | 오프라인 메시지 큐 | **done (부분 검증)** (2026-08-03) | 서버는 이미 모든 메시지를 DB에 durable하게 저장 — 별도 큐를 새로 만들지 않고 `GET /conversations/:id/messages?since_id=`(신규, 파라미터 없으면 기존 전체 히스토리 그대로) + 클라이언트 WS 재연결(backoff 1s→2s→...→30s 캡, 성공 시 리셋, `mobile/lib/services/ws_client.dart`) + 재연결/앱 포그라운드 복귀 시 since_id 캐치업(`mobile/lib/screens/chat_screen.dart`)으로 gap을 메움. `core-backend/a1_a2_test.go`(`TestListMessagesSinceID`)와 `mobile/test/message_sync_test.dart`(backoff 계산 + 중복 없는 병합을 순수 함수로 뽑아 검증)로 확인한 부분과, **실제 소켓 재연결 타이밍·앱 백그라운드/포그라운드 전환에서 OS가 소켓을 어떻게 처리하는지는 단위 테스트로 증명 불가 — 실기기 Android QA 남음**(N4-C6b와 같은 프레이밍) |
|
||||||
| **N4-12** | 자연스러움 피드백 UI | todo | vision 지표 |
|
| **N4-12** | 자연스러움 피드백 UI | **done (계측만)** (2026-08-03) | vision 지표. **PoC 실행/결론이 아니라 캡처 장치** — 실제 %는 N5 이후 실 사용 데이터로. 암묵 신호: `core-backend/models.go` `Message.DraftEdited`(nullable — 사람 메시지/구버전 클라는 nil, 트윈 승인-발송 경로에서 클라이언트가 `original_draft_text`를 같이 보내면 서버가 diff해 true/false) + `mobile/lib/screens/chat_screen.dart` `_sendTwinApproved`가 `_pendingDraft.text`(편집 전 원본)를 함께 전송. 명시 신호: `Message.NaturalnessRating`(nullable bool) + `POST /messages/:id/feedback`(트윈 메시지만, 400 아니면 재제출은 덮어씀 — 409 아님) + `chat_screen.dart`/`message_bubble.dart`의 "이 답장 나답아요?" 원탭 👍/👎(한 번 탭하면 그 세션에서 다시 안 보여줌, `_ratedMessageIds` + `message_sync.dart`의 `ratedMessageIdsFrom()`). `/admin/metrics`에 `draft_unedited_rate`/`naturalness_positive_rate`(둘 다 분모 0일 때 0으로 zero-guard, `peer_veto_rate`와 동일 패턴) 추가, `adminDashboardHTML`에 카드 2개 추가. 백엔드 테스트 8개(`core-backend/main_test.go`) + 모바일 테스트 5개(`mobile/test/`) 추가, 전부 통과 |
|
||||||
| **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 |
|
| **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 |
|
||||||
| **N4-14** | 내부 release APK | todo | `docs/android-release.md` |
|
| **N4-14** | 내부 release APK | todo | `docs/android-release.md` |
|
||||||
| **N4-15** | roadmap/`[~]` 동기화 | todo | 완료 시 체크 |
|
| **N4-15** | roadmap/`[~]` 동기화 | **done** (2026-08-03) | `roadmap.md` §2.1 "푸시 알림 서비스 연동"(낡은 중복 표기 → §B/N4-1~4로 교차참조), §4-4번 "남은 것"(완료된 "C 배포 준비" 제거), §2.3 온보딩 뼈대(관계 티어 스텝 추가 반영), §2.5 수동 QA 항목(N4-5~10/N4-C6b/N4-11 교차참조)을 실제 코드 상태와 맞춤. 나머지 `[~]`(FCM 푸시, 멀티디바이스 동기화, 데이터 프라이버시, L2 카피/수신트리거)는 실제로 아직 Master 액션·Q9 확정 대기 중이라 그대로 유지 — 허위로 채우지 않음 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -269,9 +287,11 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P
|
||||||
**2026-08-03 2차 갭 분석 배치(C4/C5/C6) 전체 구현 완료.** C6은 온디바이스 스누즈
|
**2026-08-03 2차 갭 분석 배치(C4/C5/C6) 전체 구현 완료.** C6은 온디바이스 스누즈
|
||||||
저장·UI는 완전히 검증(단위 테스트)됐지만, 실제 OS 로컬 알림이 실기기에서 울리는지는
|
저장·UI는 완전히 검증(단위 테스트)됐지만, 실제 OS 로컬 알림이 실기기에서 울리는지는
|
||||||
이 샌드박스(실기기/에뮬레이터 없음)에서 검증 불가 — Android UI QA(N4-5~10)에서 처음
|
이 샌드박스(실기기/에뮬레이터 없음)에서 검증 불가 — Android UI QA(N4-5~10)에서 처음
|
||||||
확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. 남은 것: **Master FCM
|
확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. Track C A~F 프로덕션
|
||||||
시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10, 답장 마감 알림 실기기 확인
|
재배포는 **완료** (`95422bb` / docs `f363e8e`, 2026-08-03).
|
||||||
포함). Track C A~F 프로덕션 재배포는 **완료** (`95422bb` / docs `f363e8e`, 2026-08-03)
|
|
||||||
|
**바로 다음:** 웹 **W4 accept** — `msn.iykyka.com` 하드 리프레시·알림 허용·로그인 후 `/admin/push-test` · 또는 Android N4-1/4.
|
||||||
|
웹 W0~W7 코드·`:web:` 시크릿 배포·서버 SA **done** (브라우저 실토큰·push-test만 남음).
|
||||||
|
|
||||||
|
|
||||||
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
# FCM 설정 (N4-1 ~ N4-4)
|
# FCM 설정 (N4-1 ~ N4-4 · N4-W4)
|
||||||
|
|
||||||
서버·클라이언트의 푸시 **코드 경로는 준비됨**.
|
서버·클라이언트의 푸시 **코드 경로는 준비됨**.
|
||||||
새 Firebase 프로젝트는 **레거시 서버 키가 비활성**인 경우가 많아, **HTTP v1 + 서비스 계정 JSON**을 쓴다.
|
새 Firebase 프로젝트는 **레거시 서버 키가 비활성**인 경우가 많아, **HTTP v1 + 서비스 계정 JSON**을 쓴다.
|
||||||
|
|
||||||
## 이미 된 것 (코드)
|
## 이미 된 것 (코드 · 프로덕션)
|
||||||
|
|
||||||
| 계층 | 동작 |
|
| 계층 | 동작 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 |
|
| Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 |
|
||||||
| Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 |
|
| Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 |
|
||||||
| core-backend | `notifyUser` + `POST /admin/push-test` — **FCM HTTP v1**(서비스 계정) 우선, 레거시 `FCM_SERVER_KEY`는 폴백 |
|
| Web (N4-W4) | `FirebaseWebConfig` + 프로덕션 `.env` `FIREBASE_*`/`VAPID` 주입 · SW populated (`:web:` APP_ID) |
|
||||||
|
| core-backend | `notifyUser` + `POST /admin/push-test` — **FCM HTTP v1**(서비스 계정) 우선 |
|
||||||
|
| 서버 SA (N4-3) | 프로덕션 `secrets/firebase-service-account.json` 마운트 **done** |
|
||||||
|
|
||||||
## Master가 할 일
|
## Master가 할 일
|
||||||
|
|
||||||
|
|
@ -17,44 +19,35 @@
|
||||||
|
|
||||||
1. [Firebase Console](https://console.firebase.google.com/) → Android 앱 추가
|
1. [Firebase Console](https://console.firebase.google.com/) → Android 앱 추가
|
||||||
package: **`com.ykavu.ykavu_mobile`**
|
package: **`com.ykavu.ykavu_mobile`**
|
||||||
2. `google-services.json`을 로컬에만 배치 (git 금지):
|
2. `google-services.json`을 **Android 빌드 PC**에만 배치 (git 금지):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp ~/Downloads/google-services.json mobile/android/app/google-services.json
|
cp ~/Downloads/google-services.json mobile/android/app/google-services.json
|
||||||
```
|
```
|
||||||
|
|
||||||
### N4-3 — 서비스 계정 JSON (HTTP v1)
|
### N4-3 — 서비스 계정 JSON (HTTP v1) — **프로덕션 done**
|
||||||
|
|
||||||
레거시 **서버 키**가 Cloud Messaging 탭에서 `사용 중지됨`이면 정상이다. 아래를 쓴다.
|
레거시 **서버 키**가 Cloud Messaging 탭에서 `사용 중지됨`이면 정상이다.
|
||||||
|
|
||||||
1. Google Cloud → 사용자 인증 정보 → 서비스 계정 만들기
|
프로덕션 호스트에 이미 배치됨:
|
||||||
(API: Firebase Cloud Messaging API, 데이터: **애플리케이션 데이터**)
|
|
||||||
2. 역할: **Firebase Cloud Messaging Admin** (없으면 Firebase 관리자 / 임시 소유자)
|
```bash
|
||||||
3. 키 유형 **JSON** 다운로드
|
# ~/project/ykavu/secrets/firebase-service-account.json
|
||||||
4. 서버(또는 이 워크스페이스)에 배치:
|
# compose: ./secrets → /secrets, FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json
|
||||||
|
```
|
||||||
|
|
||||||
|
새 환경에 다시 놓을 때:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 파일명 고정
|
|
||||||
mkdir -p secrets
|
mkdir -p secrets
|
||||||
mv ~/Downloads/iykyka-*.json secrets/firebase-service-account.json
|
mv ~/Downloads/iykyka-*.json secrets/firebase-service-account.json
|
||||||
chmod 600 secrets/firebase-service-account.json
|
chmod 600 secrets/firebase-service-account.json
|
||||||
```
|
|
||||||
|
|
||||||
5. 프로덕션 호스트에도 동일 파일:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 예: scp 후
|
|
||||||
cd ~/project/ykavu
|
|
||||||
# secrets/firebase-service-account.json 존재 확인
|
|
||||||
docker compose up -d --build core-backend
|
docker compose up -d --build core-backend
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.yml`이 `./secrets` → 컨테이너 `/secrets`로 마운트하고
|
|
||||||
`FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json`을 읽는다.
|
|
||||||
|
|
||||||
**git / 채팅에 JSON 내용을 붙여넣지 않는다.**
|
**git / 채팅에 JSON 내용을 붙여넣지 않는다.**
|
||||||
|
|
||||||
### N4-2 / N4-4 — 실기기 스모크
|
### N4-2 / N4-4 — Android 실기기 스모크
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd mobile
|
cd mobile
|
||||||
|
|
@ -71,8 +64,58 @@ curl -sS -X POST https://msn.iykyka.com/admin/push-test \
|
||||||
|
|
||||||
기대: `sent >= 1`.
|
기대: `sent >= 1`.
|
||||||
`only_placeholder_tokens` → Android에 `google-services.json` 넣고 재설치.
|
`only_placeholder_tokens` → Android에 `google-services.json` 넣고 재설치.
|
||||||
`fcm_not_configured` → 서버에 서비스 계정 파일 경로 확인.
|
`fcm_not_configured` → 서비스 계정 경로 확인.
|
||||||
|
|
||||||
## Web
|
## Web (N4-W4)
|
||||||
|
|
||||||
Web 푸시는 별도 VAPID 설정이 필요하며 이번 N4는 **Android 실푸시** 우선.
|
설계·수락: [`web-upgrade.md`](./web-upgrade.md) §N4-W4 · 실행 표: [`deploy-checklist.md`](./deploy-checklist.md) §N4-W.
|
||||||
|
|
||||||
|
**프로덕션:** Firebase Web config + VAPID + `:web:` APP_ID가 서버 `.env`에 있고 `web` 이미지에 빌드 주입됨.
|
||||||
|
SW(`firebase-messaging-sw.js`) populated. 서버 SA는 Android와 공유.
|
||||||
|
|
||||||
|
### 남은 accept (브라우저)
|
||||||
|
|
||||||
|
1. `https://msn.iykyka.com` **하드 리프레시** (또는 사이트 데이터 삭제)
|
||||||
|
2. 알림 **허용** → 로그인
|
||||||
|
3. device_tokens에 실 토큰(`platform=web`, `install:` 아님)
|
||||||
|
4. `/admin/push-test` → `sent >= 1` (탭 백그라운드에서도 알림)
|
||||||
|
|
||||||
|
로컬/스테이징에서 define이 비어 있으면 웹은 `install:`만 등록 → `only_placeholder_tokens`가 정상.
|
||||||
|
|
||||||
|
### 새 환경에 Web 시크릿을 넣을 때
|
||||||
|
|
||||||
|
배포 호스트 **`~/project/ykavu/.env`**(클라우드 에이전트 `/workspace/.env`와 별개):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FIREBASE_API_KEY=
|
||||||
|
FIREBASE_AUTH_DOMAIN= # 비우면 {projectId}.firebaseapp.com
|
||||||
|
FIREBASE_PROJECT_ID=
|
||||||
|
FIREBASE_STORAGE_BUCKET= # 비우면 {projectId}.appspot.com
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID=
|
||||||
|
FIREBASE_APP_ID= # must be 1:…:web:… (not :android:)
|
||||||
|
FIREBASE_VAPID_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build web
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로컬 Flutter web
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
flutter run -d chrome \
|
||||||
|
--dart-define=CORE_API_BASE=http://localhost:8080 \
|
||||||
|
--dart-define=FIREBASE_API_KEY=... \
|
||||||
|
--dart-define=FIREBASE_APP_ID=... \
|
||||||
|
--dart-define=FIREBASE_MESSAGING_SENDER_ID=... \
|
||||||
|
--dart-define=FIREBASE_PROJECT_ID=... \
|
||||||
|
--dart-define=FIREBASE_VAPID_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 구현 위치
|
||||||
|
|
||||||
|
- `mobile/lib/config/firebase_web_config.dart`
|
||||||
|
- `mobile/lib/services/push_token_service.dart` (`_tryFirebaseWebToken`)
|
||||||
|
- `mobile/web/firebase-messaging-sw.js` (+ `.template`, `scripts/docker_build_web.sh`)
|
||||||
|
- `mobile/Dockerfile` / `docker-compose.yml` build-args
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,10 @@
|
||||||
- [x] DB 스키마: users, invite_codes, contacts, conversations, messages, twin_settings,
|
- [x] DB 스키마: users, invite_codes, contacts, conversations, messages, twin_settings,
|
||||||
escalation_logs, whitelist_rules — `core-backend/models.go` (GORM), `backend/app/models.py`
|
escalation_logs, whitelist_rules — `core-backend/models.go` (GORM), `backend/app/models.py`
|
||||||
(Python 프로토타입)와 동일 스키마(+ invite_codes는 여기서 새로 추가)
|
(Python 프로토타입)와 동일 스키마(+ invite_codes는 여기서 새로 추가)
|
||||||
- [ ] 푸시 알림 서비스 연동
|
- [~] 푸시 알림 서비스 연동 — **2026-08-03 동기화**: 이 항목은 오래 전에 적힌 뒤 갱신 안 된
|
||||||
|
중복 표기였음. 실제 진행 상황은 §B "FCM 푸시 연동"과 `deploy-checklist.md` N4-1~4에서 추적
|
||||||
|
중 — 서버 쪽(`notifyUser`, `/admin/push-test`, 토큰 등록)은 구현·테스트 완료, 남은 건 Master의
|
||||||
|
Firebase 시크릿 배치(N4-1/3)와 그 이후 실기기 수신 확인(N4-4)뿐
|
||||||
|
|
||||||
`backend/`(Python 프로토타입)는 그대로 참고용으로 남겨둔다 — `core-backend/`(Go)가 실제로 쓰는 것.
|
`backend/`(Python 프로토타입)는 그대로 참고용으로 남겨둔다 — `core-backend/`(Go)가 실제로 쓰는 것.
|
||||||
|
|
||||||
|
|
@ -75,7 +78,8 @@
|
||||||
- [x] 기본 채팅 UI (대화 목록, 대화방) — `GET`/`POST /conversations`, 히스토리 로드,
|
- [x] 기본 채팅 UI (대화 목록, 대화방) — `GET`/`POST /conversations`, 히스토리 로드,
|
||||||
WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작
|
WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작
|
||||||
- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`,
|
- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`,
|
||||||
기기 로컬). 말투 학습 UX 디테일·카피는 **사람 PoC #1 결과를 맨 마지막에 반영**
|
기기 로컬) + 관계 티어 기본값 선택(2.7-B, 2026-08-03 추가). 말투 학습 UX 디테일·카피는
|
||||||
|
**사람 PoC #1 결과를 맨 마지막에 반영** — 이 부분만 여전히 열려 있음
|
||||||
- [x] 와카뷰 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
|
- [x] 와카뷰 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
|
||||||
서버/AI 프롬프트 측과 이어서 다듬을 것
|
서버/AI 프롬프트 측과 이어서 다듬을 것
|
||||||
- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는
|
- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는
|
||||||
|
|
@ -112,7 +116,10 @@
|
||||||
구현과 함께: L0 차단, L1 미승인 차단/승인 시 발송, L2 화이트리스트 매칭 자동발송/비매칭 시 승인
|
구현과 함께: L0 차단, L1 미승인 차단/승인 시 발송, L2 화이트리스트 매칭 자동발송/비매칭 시 승인
|
||||||
필요, 에스컬레이션이 레벨·화이트리스트·승인 여부와 무관하게 항상 우선한다는 것까지 6개 케이스
|
필요, 에스컬레이션이 레벨·화이트리스트·승인 여부와 무관하게 항상 우선한다는 것까지 6개 케이스
|
||||||
전부 실제 테스트로 확인함
|
전부 실제 테스트로 확인함
|
||||||
- [ ] 온보딩·채팅·설정 수동 QA — `mobile/` 골격 위에 실기기/에뮬레이터로 진행 (후속)
|
- [ ] 온보딩·채팅·설정 수동 QA — `mobile/` 골격 위에 실기기/에뮬레이터로 진행 (후속). API 계층은
|
||||||
|
이미 검증됨(`deploy-checklist.md` N4-5~10 "api-done") — 남은 건 화면 탭뿐. 그중 N4-C6b(스누즈
|
||||||
|
로컬 알림)·N4-11(오프라인 재연결)은 이 세션 도구엔 실기기/에뮬레이터가 없어 순수 로직만
|
||||||
|
단위테스트로 확인했고 실제 동작 확인은 이 수동 QA 라운드에서 처음 이뤄짐
|
||||||
|
|
||||||
**2.6 베타 배포 준비**
|
**2.6 베타 배포 준비**
|
||||||
- [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라
|
- [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라
|
||||||
|
|
@ -121,9 +128,12 @@
|
||||||
409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. 발급자 인증은
|
409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. 발급자 인증은
|
||||||
`ADMIN_API_TOKEN`(A2). 운영 절차·만료/회수는 C (`docs/invite-ops.md`)
|
`ADMIN_API_TOKEN`(A2). 운영 절차·만료/회수는 C (`docs/invite-ops.md`)
|
||||||
- [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은
|
- [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은
|
||||||
`/admin/metrics`의 `peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움
|
`/admin/metrics`의 `peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). **2026-08-03
|
||||||
피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기
|
자연스러움 계측 인프라 추가(deploy-checklist.md N4-12, 아래 참고)**: 암묵 신호(초안 무수정
|
||||||
보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요
|
발송률)와 명시 신호(원탭 "이 답장 나답아요?" 피드백) 둘 다 캡처되어 `/admin/metrics`에
|
||||||
|
집계된다. **주의 — 이건 PoC 실행이나 실제 수치 결론이 아니라 계측/캡처 장치일 뿐이다.**
|
||||||
|
실제 자연스러움 %는 실 베타 사용 데이터가 쌓여야 나온다(N5 이후). 안전선 위반 0건은 런타임에
|
||||||
|
"수집"하는 지표라기보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요
|
||||||
- [x] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) — **2026-08-03 재확인**:
|
- [x] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) — **2026-08-03 재확인**:
|
||||||
이 항목의 `[~]` 상태와 "대시보드 UI 자체와 생성 지연시간·오류율은 아직 없음" 설명은 실제로는
|
이 항목의 `[~]` 상태와 "대시보드 UI 자체와 생성 지연시간·오류율은 아직 없음" 설명은 실제로는
|
||||||
낡은 기록이었음(§B의 "생성 지연시간·오류율 계측"/"모니터링 대시보드(최소)" `[x]` 항목과 서로
|
낡은 기록이었음(§B의 "생성 지연시간·오류율 계측"/"모니터링 대시보드(최소)" `[x]` 항목과 서로
|
||||||
|
|
@ -299,7 +309,9 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
|
||||||
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
|
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
|
||||||
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
|
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
|
||||||
4. [~] 2.3 Flutter 클라이언트 — A3 + B(drift/SQLCipher·데이터흐름·세션) 완료 + API E2E.
|
4. [~] 2.3 Flutter 클라이언트 — A3 + B(drift/SQLCipher·데이터흐름·세션) 완료 + API E2E.
|
||||||
남은 것: 실제 Firebase FCM 토큰, Android UI 탭(`mobile/README.md`), C 배포 준비
|
**2026-08-03 동기화**: "C 배포 준비"는 6번에서 이미 완료됐으므로 여기 "남은 것"에서 제거.
|
||||||
|
실제로 남은 건 Master의 Firebase FCM 시크릿 배치(N4-1/3)와 Android UI 탭(N4-5~10,
|
||||||
|
`mobile/README.md`)뿐
|
||||||
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료.
|
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료.
|
||||||
클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께
|
클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께
|
||||||
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
|
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
|
||||||
|
|
@ -330,11 +342,11 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
|
||||||
- [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급)
|
- [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급)
|
||||||
- [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`)
|
- [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`)
|
||||||
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
|
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
|
||||||
- [ ] **계정·설정 IA 갭** (`decision-log` Q8 제안, [`account-settings-ia.md`](./account-settings-ia.md))
|
- [x] **계정·설정 IA** (`decision-log` Q8 확정, [`account-settings-ia.md`](./account-settings-ia.md))
|
||||||
— Master 확정 후 착수. 이메일/비번 도입 금지(Phase 1)
|
— 이메일/비번 도입 금지(Phase 1)
|
||||||
- [ ] 입장 화면: 새로 가입 / 이미 가입(`POST /auth/login`) 구분
|
- [x] 입장 화면: 새로 가입 / 이미 가입(`POST /auth/login`, DEMO는 display_name 필수)
|
||||||
- [ ] 로그아웃: 세션 revoke + 로컬 토큰 클리어 + 입장 화면 복귀
|
- [x] 로그아웃: 세션 revoke + 로컬 토큰 클리어 + 입장 화면 복귀
|
||||||
- [ ] 설정 진입점에 로그아웃·세션·자율성·말투·데이터흐름 묶기
|
- [x] 설정 진입점에 로그아웃·세션·자율성·말투·데이터흐름 묶기
|
||||||
|
|
||||||
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
|
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
|
||||||
- [x] 대화 목록/연락처 UI를 서버 API에 연결
|
- [x] 대화 목록/연락처 UI를 서버 API에 연결
|
||||||
|
|
@ -394,9 +406,23 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
|
||||||
|
|
||||||
단일 실행 체크리스트: **[`deploy-checklist.md`](./deploy-checklist.md)**.
|
단일 실행 체크리스트: **[`deploy-checklist.md`](./deploy-checklist.md)**.
|
||||||
|
|
||||||
순서: **N1 스모크 → N2 Docker(`msn.iykyka.com`) → N3 안정화 → N4 FCM/Android QA → N5 사람 PoC(D)**.
|
순서: **N1 스모크 → N2 Docker(`msn.iykyka.com`) → N3 안정화 → N4-W 웹 고도화(병행) /
|
||||||
Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5)는 해당 문서를 본다. 완료 시 그 문서와
|
N4 Android FCM·UI QA → N5 사람 PoC(D)**.
|
||||||
본 로드맵 §2/§5의 `[~]`/`[ ]`를 함께 갱신한다.
|
Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5, **N4-W0…W7**)는 해당 문서를 본다.
|
||||||
|
완료 시 그 문서와 본 로드맵 §2/§5의 `[~]`/`[ ]`를 함께 갱신한다.
|
||||||
|
|
||||||
|
##### 웹 고도화 (데모/프리뷰) — [`web-upgrade.md`](./web-upgrade.md)
|
||||||
|
|
||||||
|
- [x] **W0** 제품 경계 문서화 (2026-08-04) — 데모/프리뷰 · Q9 비범위 · Wi 규율
|
||||||
|
- [x] **W1** 웹 로컬 지속성 (prefs/idb · Drift WASM 비채택) — 2026-08-04
|
||||||
|
- [x] **W2** 데모 UX (분할·WS 상태·ID 복사) — 2026-08-04
|
||||||
|
- [x] **W3** 인앱 + Notification API — 2026-08-04
|
||||||
|
- [x] **W4** Web Push (FCM + VAPID · `:web:` APP_ID 프로덕션 주입) — **accept 대기:** 브라우저 하드 리프레시·알림·push-test
|
||||||
|
- [x] **W5** PWA (manifest 테마 · 셸 캐시) — 2026-08-04 (W4와 독립 선행)
|
||||||
|
- [x] **W6** 테스터 가이드 — 2026-08-04
|
||||||
|
- [x] **W7** edge 스모크 (`e2e_web_smoke.sh`) — 2026-08-04; Playwright UI는 후속
|
||||||
|
|
||||||
|
W0~W7 코드·배포 완료. W4만 브라우저 실토큰·`/admin/push-test` 확인 남음. Android N4와 서버 SA 공유.
|
||||||
|
|
||||||
|
|
||||||
## Phase 2 — L3 확장 + 베타 확대
|
## Phase 2 — L3 확장 + 베타 확대
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,9 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색
|
||||||
| 경로 | 역할 |
|
| 경로 | 역할 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `POST /auth/signup` | 미사용 초대 코드 + `display_name` → User + Session(Bearer) |
|
| `POST /auth/signup` | 미사용 초대 코드 + `display_name` → User + Session(Bearer) |
|
||||||
| `POST /auth/login` | 이미 사용된 초대 코드 → 새 Session (앱 UI 갭) |
|
| `POST /auth/login` | 사용된 초대 코드(+ 선택적 display_name) → 새 Session. DEMO는 display_name 필수 |
|
||||||
| `GET/DELETE /users/:id/sessions...` | 멀티 디바이스 목록·종료 |
|
| `GET/DELETE /users/:id/sessions...` | 멀티 디바이스 목록·종료 |
|
||||||
| 클라이언트 | `shared_preferences`에 토큰 저장; **로그아웃 시 revoke + 로컬 삭제** (갭) |
|
| 클라이언트 | `shared_preferences`에 토큰 저장; 로그아웃 시 revoke + 로컬 삭제 → 입장 화면 |
|
||||||
|
|
||||||
- Phase 1은 **비밀번호·OAuth 없음.** 초대 코드가 비밀에 해당한다.
|
- Phase 1은 **비밀번호·OAuth 없음.** 초대 코드가 비밀에 해당한다.
|
||||||
- 로그아웃은 서버 세션 무효화만으로는 부족하고, 클라이언트가 토큰을 지워야 입장 화면으로 돌아간다.
|
- 로그아웃은 서버 세션 무효화만으로는 부족하고, 클라이언트가 토큰을 지워야 입장 화면으로 돌아간다.
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,38 @@
|
||||||
|
|
||||||
## 접속
|
## 접속
|
||||||
|
|
||||||
- 웹: **https://msn.iykyka.com**
|
- 웹: **https://msn.iykyka.com** (데모/프리뷰 1차 표면)
|
||||||
|
- 권장 브라우저: **Desktop Chrome**
|
||||||
- 공용 데모 초대 코드: **`DEMO-YKAVU`**
|
- 공용 데모 초대 코드: **`DEMO-YKAVU`**
|
||||||
- 표시 이름 예시: `테스터` (원하는 이름으로 변경 가능)
|
- 표시 이름 예시: `테스터` (원하는 이름으로 변경 가능 — **이미 가입** 탭 재로그인 시 같은 이름 필요)
|
||||||
|
|
||||||
같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`).
|
같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`).
|
||||||
|
|
||||||
API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포함.
|
API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포함.
|
||||||
|
|
||||||
|
### 웹 프리뷰 팁 (N4-W)
|
||||||
|
|
||||||
|
1. **넓은 화면(≥900px)** — 왼쪽 대화 목록 · 오른쪽 채팅이 나란히 보입니다.
|
||||||
|
2. **내 ID 복사** — 목록 상단 칩 또는 설정 → 계정 행을 탭합니다.
|
||||||
|
3. **브라우저 알림** — 설정 → 「브라우저 알림」에서 허용하면 답장 마감 리마인드·(허용 시) Web Push를 받을 수 있습니다(거부해도 인앱 배지는 동작).
|
||||||
|
4. **로그인 세션 만료 시각** — 설정 → 로그인 세션의 「만료」는 **기기 로컬 시간**(`yyyy-MM-dd HH:mm`)으로 표시됩니다.
|
||||||
|
5. **홈 화면 추가(PWA)** — Chrome 주소창 옆 설치 아이콘 또는 메뉴 → 「앱 설치」.
|
||||||
|
6. **로그아웃** — 설정 → 로그아웃 → 입장 화면 「이미 가입」으로 복귀.
|
||||||
|
7. **캐시가 옛 버전일 때** — 강력 새로고침, 또는 DevTools → Application → Service Workers → Unregister 후 새로고침.
|
||||||
|
|
||||||
|
상세 설계: [`web-upgrade.md`](./web-upgrade.md).
|
||||||
|
|
||||||
## 권장 페어링 플로우 (5~10분)
|
## 권장 페어링 플로우 (5~10분)
|
||||||
|
|
||||||
두 명(또는 시크릿 창 두 개)으로 진행합니다. **대화는 닉네임이 아니라 숫자 사용자 ID로 연결됩니다.**
|
두 명(또는 시크릿 창 두 개)으로 진행합니다. **대화는 닉네임이 아니라 숫자 사용자 ID로 연결됩니다.**
|
||||||
|
|
||||||
1. **가입** — 초대 코드 `DEMO-YKAVU` 입력 (각자 다른 표시 이름 권장)
|
1. **가입** — 「새로 가입」탭 · 초대 코드 `DEMO-YKAVU` (각자 다른 표시 이름 권장). 시크릿 창 두 개로 두 사용자 가능.
|
||||||
2. **말투 샘플** — 몇 줄 적거나 스킵
|
2. **말투 샘플** — 몇 줄 적거나 스킵 (하드 리프레시 후에도 유지됨)
|
||||||
3. **내 ID 복사** — 대화 목록 상단「내 사용자 ID」칩을 탭해 복사하고 상대에게 전달
|
3. **내 ID 복사** — 대화 목록 상단「내 사용자 ID」칩을 탭해 복사하고 상대에게 전달
|
||||||
4. **연락처 추가** — 상대 표시 이름 + **상대의 숫자 ID(필수)** → 추가 → 「대화」
|
4. **연락처 추가** — 상대 표시 이름 + **상대의 숫자 ID(필수)** → 추가 → 「대화」
|
||||||
5. **메시지** — 사람 모드로 한두 줄 주고받기
|
5. **메시지** — 사람 모드로 한두 줄 주고받기 · 채팅 앱바의 「실시간」상태 확인
|
||||||
6. **와카뷰 초안** — 메뉴 → 자율성에서 **L1** → 초안이 뜨면 수정/승인하고 보내기
|
6. **와카뷰 초안** — 설정 → 자율성에서 **L1** → 채팅에서 ✨ 초안 → 수정/승인하고 보내기
|
||||||
7. (선택) L0에서는「입력창으로 옮기기」만 됩니다. L2·거부권·사후알림 함도 눌러 보세요.
|
7. (선택) L0에서는「입력창으로 옮기기」만 됩니다. L2·거부권·사후알림 함·이따 답장도 눌러 보세요.
|
||||||
|
|
||||||
### ID 없는 옛 연락처
|
### ID 없는 옛 연락처
|
||||||
|
|
||||||
|
|
@ -30,7 +43,9 @@ API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포
|
||||||
|
|
||||||
- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다.
|
- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다.
|
||||||
- **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송.
|
- **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송.
|
||||||
- 푸시(FCM): 코드 경로는 준비됨. Master가 Firebase/`FCM_SERVER_KEY`를 넣기 전에는 플레이스홀더(`docs/fcm-setup.md`).
|
- 푸시: 웹은 프로덕션에 Firebase Web(`:web:`)·VAPID가 이미 주입되어 있습니다.
|
||||||
|
**하드 리프레시**(또는 사이트 데이터 삭제) → 알림 **허용** → 로그인하면 실 FCM 토큰이 등록됩니다.
|
||||||
|
Android 실기기 푸시는 `google-services.json` 배치 후 (`docs/fcm-setup.md`).
|
||||||
- 문제/스크린샷은 Master에게 전달해 주세요.
|
- 문제/스크린샷은 Master에게 전달해 주세요.
|
||||||
- 민감 정보·실명 대화는 베타 특성상 최소화해 주세요.
|
- 민감 정보·실명 대화는 베타 특성상 최소화해 주세요.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,488 @@
|
||||||
|
# 웹 고도화 (데모/프리뷰 + Web Push + PWA)
|
||||||
|
|
||||||
|
권위: [`decision-log.md`](./decision-log.md) · [`deploy-checklist.md`](./deploy-checklist.md) · [`AGENTS.md`](../AGENTS.md).
|
||||||
|
실행 추적 ID: **N4-W0 … N4-W7** (`deploy-checklist.md` §N4-W).
|
||||||
|
|
||||||
|
## 0. 고정 결정 (2026-08-04 Master)
|
||||||
|
|
||||||
|
| # | 결정 | 상태 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | 웹 = **데모/프리뷰 1차 표면** (`https://msn.iykyka.com`). Android 동등 교체 아님 | 확정 |
|
||||||
|
| 2 | **Web Push**(FCM Web + VAPID) · **PWA**(설치·standalone·셸 SW) 포함 | 확정 |
|
||||||
|
| 3 | 본 문서로 Wi를 **완전 상세**히 짜고, **Wi 단위**로 차근차근 구현 | 확정 |
|
||||||
|
| 4 | **Q9**(L2 수신 트리거 자동응대) · PoC §3 기본값 추측 · L3/OS/B2B **비범위** | 확정 |
|
||||||
|
|
||||||
|
### 성공 정의 (데모/프리뷰)
|
||||||
|
|
||||||
|
두 테스터가 **브라우저만**으로: 가입(또는 로그인) → 사용자 ID 교환·연락처 → 대화 → 초안(✨) → L1 승인·와카뷰 뱃지 → 설정/로그아웃까지 **안정 시연**.
|
||||||
|
|
||||||
|
### 비범위 (전체 공통)
|
||||||
|
|
||||||
|
- Q9 수신 트리거 자동응대 및 “자리를 비우면 알아서 답함” 카피
|
||||||
|
- 사람 PoC #1/#3 실행 · 자율성/화이트리스트 **기본값** 확정
|
||||||
|
- Android N4 실기기 트랙(병행 가능, 본 문서 작업 분해에 넣지 않음)
|
||||||
|
- Drift WASM / SQLCipher-on-web / Play·App Store 배포
|
||||||
|
- 계정 삭제 UI (Q8e), 이미지·읽음·타이핑 등 일반 메신저 확장
|
||||||
|
|
||||||
|
### 구현 규율
|
||||||
|
|
||||||
|
1. **한 번에 W 전체 구현 금지.** `Wi`마다 Master 승인 → 코드 → 본 문서·`deploy-checklist` Status 동시 갱신.
|
||||||
|
2. Wi 완료 전 다음 Wi 착수하지 않는다 (의존 화살표 준수). 예외는 Master 명시.
|
||||||
|
3. 시크릿(Firebase Web config, VAPID, 서비스 계정) **git 커밋 금지**.
|
||||||
|
4. 서버 FCM HTTP v1 SA는 Android N4와 **공유**. 클라이언트 시크릿 경로만 플랫폼별 분리.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
W0[W0_Boundary] --> W1[W1_Persistence]
|
||||||
|
W1 --> W2[W2_DemoUX]
|
||||||
|
W2 --> W3[W3_InAppNotify]
|
||||||
|
W3 --> W4[W4_WebPush]
|
||||||
|
W4 --> W5[W5_PWA]
|
||||||
|
W5 --> W6[W6_TesterOps]
|
||||||
|
W6 --> W7[W7_Quality]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. As-is (코드 박제, 2026-08)
|
||||||
|
|
||||||
|
| 영역 | 현황 | 근거 |
|
||||||
|
|------|------|------|
|
||||||
|
| 서빙 | Flutter web + nginx, API/WS 프록시 | `mobile/Dockerfile`, `deploy/nginx-web.conf` |
|
||||||
|
| 로컬 DB | 웹 = **메모리 스텁**, 새로고침 시 말투·온보딩·스누즈 유실 | `mobile/lib/db/app_database_web.dart` |
|
||||||
|
| 세션 | `shared_preferences`에 user/token (웹에서도 대체로 유지) | `mobile/lib/state/session_state.dart` |
|
||||||
|
| 푸시 | 웹에서 FCM 즉시 skip → `install:` 플레이스홀더 | `mobile/lib/services/push_token_service.dart` |
|
||||||
|
| 스누즈 OS 알림 | 웹 no-op | `mobile/lib/services/snooze_notification_service_web.dart` |
|
||||||
|
| PWA 골격 | `manifest.json` + icons 있음. theme `#7C6FF0`(낡은 아우로라). SW 전략 미문서화 | `mobile/web/` |
|
||||||
|
| 서버 푸시 | HTTP v1 SA 마운트됨. 플레이스홀더 토큰은 skip | `core-backend/push.go`, `/admin/push-test` |
|
||||||
|
| 테마 | iMessage-inspired light + soft charcoal dark | `mobile/lib/theme/app_theme.dart` (`#007AFF` / canvas `#F2F2F7`·`#141418`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N4-W0 — 제품 경계
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Status** | **done** (본 문서 랜딩으로 경계 고정, 2026-08-04) |
|
||||||
|
| **목표** | 웹 고도화의 제품·기술 경계를 문서에 고정해 구현 범위 논쟁을 없앤다 |
|
||||||
|
| **비범위** | 코드 변경 없음 |
|
||||||
|
|
||||||
|
### 작업 분해
|
||||||
|
|
||||||
|
- [x] 데모/프리뷰 성공 정의 기록
|
||||||
|
- [x] Q9 / PoC §3 / Android N4 / Drift WASM 비범위 명시
|
||||||
|
- [x] Wi 순서·승인 규율 명시
|
||||||
|
- [x] README · roadmap · deploy-checklist · fcm-setup · mobile README 교차 링크
|
||||||
|
|
||||||
|
### 수락 기준
|
||||||
|
|
||||||
|
- [x] 본 파일 §0이 Master 결정 4항과 일치
|
||||||
|
- [x] 실행 ID N4-W*가 `deploy-checklist.md`에 존재
|
||||||
|
|
||||||
|
### Master 액션
|
||||||
|
|
||||||
|
- 없음 (문서 승인 = W0 완료)
|
||||||
|
|
||||||
|
### 위험
|
||||||
|
|
||||||
|
- “웹도 Android만큼”으로 범위가 커지면 W1에서 Drift WASM 등으로 비대화 → §0을 인용해 거절
|
||||||
|
|
||||||
|
### 터치 파일 (문서)
|
||||||
|
|
||||||
|
- `docs/web-upgrade.md` (본 파일)
|
||||||
|
- `README.md`, `docs/roadmap.md`, `docs/deploy-checklist.md`, `docs/fcm-setup.md`, `mobile/README.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N4-W1 — 웹 로컬 지속성
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Status** | **done** (2026-08-04) |
|
||||||
|
| **목표** | 하드 리프레시 후에도 말투·온보딩·스누즈·install id가 유지되는 프리뷰 UX |
|
||||||
|
| **비범위** | SQLCipher/암호화 DB, Drift WASM, 서버 측 말투 동기화 |
|
||||||
|
|
||||||
|
### As-is
|
||||||
|
|
||||||
|
- `AppDatabase` 웹 구현이 in-memory `Map` (`app_database_web.dart`)
|
||||||
|
- 인증만 `SharedPreferences`에 남아 새로고침 후 “가입된 것처럼” 보이지만 말투 온보딩이 다시 뜰 수 있음
|
||||||
|
|
||||||
|
### To-be
|
||||||
|
|
||||||
|
- 웹 `AppDatabase`를 **durable KV**로 승격
|
||||||
|
- **기술 선택 (고정):** `shared_preferences`(또는 동일 idb 백엔드)로 KV·말투 JSON·스누즈 맵 직렬화. **Drift WASM 비채택**
|
||||||
|
- `encrypted` 플래그는 웹에서 계속 `false`; `DataFlowScreen` 카피에 “웹 프리뷰는 브라우저 저장소(비암호화)” 명시
|
||||||
|
|
||||||
|
### 작업 분해
|
||||||
|
|
||||||
|
- [x] `app_database_web.dart`: open 시 SharedPreferences 로드, mutate 시 persist
|
||||||
|
- [x] 키 네임스페이스: `ykavu_web_db_kv_*`, `ykavu_web_db_tone_samples`, `ykavu_web_db_snoozes`
|
||||||
|
- [x] `AppDatabase.memory()`는 비지속(테스트용) 유지 — `open()`만 durable
|
||||||
|
- [x] 단위 테스트: `mobile/test/web_db_persistence_test.dart` (mock prefs round-trip)
|
||||||
|
- [x] `DataFlowScreen` 웹 카피 갱신
|
||||||
|
|
||||||
|
### 수락 기준
|
||||||
|
|
||||||
|
- [x] 단위 테스트: open → 저장 → 재 open 시 말투·KV·스누즈 복원
|
||||||
|
- [ ] Chrome 라이브 하드 리프레시 스모크 (배포 후 Master/에이전트 1회)
|
||||||
|
- [x] 시크릿 창(다른 프로필)과는 저장소 격리 — 브라우저 프로필 단위 SharedPreferences (기대 동작)
|
||||||
|
|
||||||
|
### Master 액션
|
||||||
|
|
||||||
|
- 승인 후 구현 착수; 별도 시크릿 없음
|
||||||
|
|
||||||
|
### 위험
|
||||||
|
|
||||||
|
- prefs 용량·JSON 파싱 오류 시 빈 DB로 폴백 (corrupt 시 wipe + 로그)
|
||||||
|
- 여러 탭 동시 기록 → last-write-wins 허용 (프리뷰)
|
||||||
|
|
||||||
|
### 예상 터치 파일
|
||||||
|
|
||||||
|
- `mobile/lib/db/app_database_web.dart`
|
||||||
|
- `mobile/lib/screens/data_flow_screen.dart` (카피)
|
||||||
|
- `mobile/test/` (웹 persistence 테스트, 선택)
|
||||||
|
- `docs/web-upgrade.md` Status → done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N4-W2 — 데모 메신저 UX
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Status** | **done** (2026-08-04) |
|
||||||
|
| **목표** | 데스크톱·모바일 브라우저에서 시연 마찰을 줄인 프리뷰 UX |
|
||||||
|
| **비범위** | 디자인 시스템 전면 교체, 카드/히어로 과설계, Q9 카피 |
|
||||||
|
|
||||||
|
### As-is
|
||||||
|
|
||||||
|
- 모바일 우선 단일 컬럼 스택; 대화 목록 ↔ 채팅 push
|
||||||
|
- `MyUserIdChip` 등 ID 노출은 있으나 페어링 CTA 발견성 편차
|
||||||
|
- WS 재연결 backoff는 구현됨, UI 인디케이터는 약함
|
||||||
|
|
||||||
|
### To-be
|
||||||
|
|
||||||
|
- **넓은 뷰포트(예: ≥900px):** 목록 | 채팅 **분할**(또는 동등 반응형). 좁은 폭은 기존 스택 유지
|
||||||
|
- WS 연결 상태: connecting / live / reconnecting(backoff) 한 줄 표시
|
||||||
|
- 내 사용자 ID **원탭 복사** CTA 강화 (대화 목록·설정)
|
||||||
|
- 웹 기대치 배너(첫 진입 또는 설정): 새로고침 후 데이터(W1 이후), 알림(W3/W4), 홈 화면 추가(W5)
|
||||||
|
- 시각: 기존 iMessage-inspired 테마 유지. 신규 카드 남발·히어로 오버레이 금지
|
||||||
|
|
||||||
|
### 작업 분해
|
||||||
|
|
||||||
|
- [x] 반응형 셸: `ConversationListScreen` ≥900px 목록|채팅 분할 · 좁은 폭 Navigator push
|
||||||
|
- [x] `ConversationSocket.linkStates` + 채팅 앱바/재연결 바
|
||||||
|
- [x] ID 복사: 목록 칩 + 설정 계정 행
|
||||||
|
- [x] 웹 전용 1회 dismissible 안내 (`WebPreviewBanner`)
|
||||||
|
- [ ] 수동: 1280px·390px 폭 시연 (배포 후)
|
||||||
|
|
||||||
|
### 수락 기준
|
||||||
|
|
||||||
|
- [x] 분할·스택 경로 코드 연결 (라이브 폭 스모크는 배포 후)
|
||||||
|
- [x] 재연결 중 「다시 연결 중…」 표시
|
||||||
|
- [x] 기존 `AppTheme` 유지
|
||||||
|
|
||||||
|
### Master 액션
|
||||||
|
|
||||||
|
- UX 톤 승인 (분할 vs 스택-only 미세 조정은 구현 PR에서 스크린샷)
|
||||||
|
|
||||||
|
### 위험
|
||||||
|
|
||||||
|
- Navigator 스택과 분할 레이아웃 충돌 → 웹만 `MasterDetail` 패턴으로 격리
|
||||||
|
|
||||||
|
### 예상 터치 파일
|
||||||
|
|
||||||
|
- `mobile/lib/main.dart`
|
||||||
|
- `mobile/lib/screens/conversation_list_screen.dart`
|
||||||
|
- `mobile/lib/screens/chat_screen.dart`
|
||||||
|
- `mobile/lib/services/ws_client.dart` (상태 노출 API)
|
||||||
|
- `mobile/lib/widgets/my_user_id_chip.dart` (또는 설정)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N4-W3 — 인앱·브라우저 알림 대체 (Push 전)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Status** | **done** (2026-08-04) |
|
||||||
|
| **목표** | 웹에서 스누즈/에스컬레이션을 OS 로컬알림 없이 **인지 가능**하게 |
|
||||||
|
| **비범위** | FCM Web Push 본구현(W4), Android `flutter_local_notifications` 변경 |
|
||||||
|
|
||||||
|
### As-is
|
||||||
|
|
||||||
|
- `snooze_notification_service_web.dart` = no-op
|
||||||
|
- 인앱 배지/배너(C6c)는 동작 (포그라운드·리프레시 후 W1 전제)
|
||||||
|
|
||||||
|
### To-be (고정 우선순위)
|
||||||
|
|
||||||
|
1. **항상:** 인앱 배지·배너·Inbox (기존)
|
||||||
|
2. **권한 허용 시:** 브라우저 **Notification API**로 스누즈 마감·(선택) 포그라운드 에스컬레이션 토스트성 알림
|
||||||
|
3. 권한 거부/미지원: 인앱만 — 실패로 치지 않음
|
||||||
|
|
||||||
|
스케줄: 웹은 Service Worker 알람이 약하므로 **포그라운드 타이머 + 다음 포커스 시 past-due 검사**를 1차 경로로 한다. (백그라운드 확정 전달은 W4)
|
||||||
|
|
||||||
|
### 작업 분해
|
||||||
|
|
||||||
|
- [x] `snooze_notification_service_web.dart`: Notification API + in-tab Timer
|
||||||
|
- [x] 포그라운드 복귀 시 `notifyPastDueOnFocus` → Notification (허용 시)
|
||||||
|
- [x] 설정 → 「브라우저 알림」권한 요청
|
||||||
|
- [x] 거부 시 인앱 배지/배너 유지 (기존 C6c)
|
||||||
|
- [ ] 에스컬레이션 전용 포그라운드 SnackBar — Inbox/기존 배너로 충분, W4 푸시와 연계
|
||||||
|
|
||||||
|
### 수락 기준
|
||||||
|
|
||||||
|
- [x] 단위: past-due만 `showImmediate` 호출
|
||||||
|
- [x] 권한 거부 경로 문서화 (인앱 배지)
|
||||||
|
- [ ] 라이브 Chrome에서 알림 허용 후 스누즈 마감 스모크 (배포 후)
|
||||||
|
|
||||||
|
### Master 액션
|
||||||
|
|
||||||
|
- 브라우저에서 알림 허용 클릭 (시연 시)
|
||||||
|
|
||||||
|
### 위험
|
||||||
|
|
||||||
|
- Safari/iOS 웹 알림 제약 → Chrome/Desktop을 데모 기준 브라우저로 명시 (W6)
|
||||||
|
|
||||||
|
### 예상 터치 파일
|
||||||
|
|
||||||
|
- `mobile/lib/services/snooze_notification_service_web.dart`
|
||||||
|
- `mobile/lib/services/snooze_controller.dart`
|
||||||
|
- `mobile/lib/screens/chat_screen.dart` / `conversation_list_screen.dart`
|
||||||
|
- `mobile/lib/screens/settings_screen.dart` (선택)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N4-W4 — Web Push (FCM)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Status** | **web-live** (2026-08-04) — `:web:` APP_ID·VAPID·SW 프로덕션 주입 완료 · **accept 대기:** 하드 리프레시→알림→로그인→실 토큰·`/admin/push-test` |
|
||||||
|
| **목표** | 웹 탭이 백그라운드여도 에스컬레이션 등 서버 `notifyUser`가 **실 FCM 웹 토큰**으로 전달 |
|
||||||
|
| **비범위** | 레거시 `FCM_SERVER_KEY` 신규 의존, 커스텀 푸시 프로토콜 |
|
||||||
|
|
||||||
|
### Shipped (프로덕션)
|
||||||
|
|
||||||
|
- `FirebaseWebConfig` + compose/`Dockerfile` build-args · SW template 치환
|
||||||
|
- `PushTokenService` 웹 분기 · `msn.iykyka.com`에 `:web:` APP_ID·VAPID 주입·재배포됨
|
||||||
|
- 서버 SA(`secrets/firebase-service-account.json`) 공유 · 시크릿 git 미커밋
|
||||||
|
|
||||||
|
### Master 액션 (accept만)
|
||||||
|
|
||||||
|
1. Chrome에서 `https://msn.iykyka.com` **하드 리프레시** (또는 사이트 데이터 삭제)
|
||||||
|
2. 알림 **허용** → DEMO/계정 로그인
|
||||||
|
3. device_tokens에 `platform=web` 실 토큰 확인 후 `POST /admin/push-test` → `sent >= 1`
|
||||||
|
|
||||||
|
### 작업 분해
|
||||||
|
|
||||||
|
- [x] 빌드 주입: `--dart-define=FIREBASE_…` + SW template 치환 (gitignore/시크릿 미커밋)
|
||||||
|
- [x] `PushTokenService` 웹 분기 + permission
|
||||||
|
- [x] Docker web 빌드에 define 전달 (`mobile/Dockerfile` / compose)
|
||||||
|
- [x] 프로덕션 `.env`에 `FIREBASE_*` / VAPID · `:web:` APP_ID · `web` 재빌드
|
||||||
|
- [ ] `/admin/push-test`로 user 지정 → Chrome 백그라운드 수신 (**브라우저 accept**)
|
||||||
|
- [x] `docs/fcm-setup.md` Web 절 동기화
|
||||||
|
- [x] `DataFlowScreen` 푸시 문구 갱신
|
||||||
|
|
||||||
|
### 수락 기준
|
||||||
|
|
||||||
|
- [ ] device_tokens에 `install:`이 아닌 토큰 + `platform=web` (**브라우저 로그인 후**)
|
||||||
|
- [ ] `POST /admin/push-test` → `sent >= 1`, 브라우저 알림 표시
|
||||||
|
- [x] 시크릿이 git history에 없음 (define/env만)
|
||||||
|
- [x] 프로덕션 SW populated (`firebase.initializeApp`, appId `:web:`)
|
||||||
|
|
||||||
|
### 위험
|
||||||
|
|
||||||
|
- SW 등록 순서/캐시로 토큰 null → 배포 후 hard refresh 절차를 W6/tester-guide에 적음
|
||||||
|
- Android `google-services.json`과 Web config 혼동 → 문서에서 경로 분리
|
||||||
|
|
||||||
|
### 터치 파일
|
||||||
|
|
||||||
|
- `mobile/lib/config/firebase_web_config.dart`
|
||||||
|
- `mobile/lib/services/push_token_service.dart`
|
||||||
|
- `mobile/web/` (messaging SW, index 훅)
|
||||||
|
- `mobile/Dockerfile` / `docker-compose.yml` / `.env.example`
|
||||||
|
- `docs/fcm-setup.md`, `docs/web-upgrade.md`
|
||||||
|
|
||||||
|
### 서버
|
||||||
|
|
||||||
|
- **코어 코드 변경 없음.** 기존 `notifyUser` / HTTP v1. 웹 토큰도 동일 messages:send
|
||||||
|
- 클라이언트 시크릿만 host `.env` → compose build-args
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N4-W5 — PWA
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Status** | **done** (2026-08-04) — W4와 독립 선행 |
|
||||||
|
| **목표** | 홈 화면 추가·standalone으로 데모 가능; 오프라인은 **셸만** |
|
||||||
|
| **비범위** | 오프라인 메시지 작성/전송, 풀 오프라인 메신저 |
|
||||||
|
|
||||||
|
### As-is
|
||||||
|
|
||||||
|
- `mobile/web/manifest.json`: name/icons/`display:standalone` 있음
|
||||||
|
- `theme_color` / `background_color` = `#7C6FF0` (현 테마와 불일치)
|
||||||
|
- `index.html` description = 플레이스홀더 (“A new Flutter project.”)
|
||||||
|
- Flutter 빌드가 `flutter_service_worker.js` 생성 가능 — 운영 캐시 버스트 미문서화
|
||||||
|
|
||||||
|
### To-be
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| theme / background | light canvas `#F2F2F7` 또는 accent `#007AFF` (택1, 구현 시 `AppTheme`와 맞춤) |
|
||||||
|
| description | 제품 한 줄: “나를 대신해 답하는, 나만의 와카뷰” |
|
||||||
|
| 오프라인 | 앱 셸 캐시만. API/WS는 온라인 필수 — 오프라인 시 명확한 배너(W2와 공유) |
|
||||||
|
| install | Chrome installability (manifest + SW + HTTPS + icons) |
|
||||||
|
|
||||||
|
### 작업 분해
|
||||||
|
|
||||||
|
- [x] `manifest.json` theme `#007AFF` / background `#F2F2F7` · orientation `any`
|
||||||
|
- [x] `index.html` description / theme-color / apple meta
|
||||||
|
- [x] nginx: `index.html` · `flutter_service_worker.js` · `manifest.json` → `Cache-Control: no-cache`
|
||||||
|
- [x] `OfflineBanner` (navigator.onLine)
|
||||||
|
- [ ] Chrome Application 패널 수동 확인 (배포 후)
|
||||||
|
|
||||||
|
### 수락 기준
|
||||||
|
|
||||||
|
- [x] manifest·index·캐시 헤더 코드 반영
|
||||||
|
- [x] 오프라인 시 배너 문구
|
||||||
|
- [ ] standalone 설치 시연 (배포 후 Master/테스터)
|
||||||
|
|
||||||
|
### Master 액션
|
||||||
|
|
||||||
|
- 배포 파이프라인에 Web Firebase define이 있으면 W4와 동일 시크릿 유지
|
||||||
|
- 컷오버 후 한 번 hard refresh / “Unregister SW” 런북 숙지
|
||||||
|
|
||||||
|
### 위험
|
||||||
|
|
||||||
|
- SW가 오래된 main.dart.js 제공 → 배포 체크리스트에 “웹 캐시 확인” 항목
|
||||||
|
|
||||||
|
### 예상 터치 파일
|
||||||
|
|
||||||
|
- `mobile/web/manifest.json`
|
||||||
|
- `mobile/web/index.html`
|
||||||
|
- `deploy/nginx-web.conf` (캐시 헤더, 필요 시)
|
||||||
|
- `docs/deploy-checklist.md` / `docs/tester-guide.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N4-W6 — 테스터 운영 폴리시
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Status** | **done** (2026-08-04) |
|
||||||
|
| **목표** | 웹 데모 스크립트가 설치·알림·페어링·로그아웃까지 재현 가능 |
|
||||||
|
| **비범위** | PoC 인터뷰 본실행, SHARE_URL(별도 N4-13) |
|
||||||
|
|
||||||
|
### 작업 분해
|
||||||
|
|
||||||
|
- [x] [`tester-guide.md`](./tester-guide.md) 웹 프리뷰 팁 · 페어링·캐시 런북
|
||||||
|
- [x] SW unregister / 강력 새로고침 안내
|
||||||
|
|
||||||
|
### 수락 기준
|
||||||
|
|
||||||
|
- [x] 가이드에 Chrome·PWA·알림·로그아웃 경로 명시
|
||||||
|
- [ ] Master walkthrough 1회 (운영)
|
||||||
|
|
||||||
|
### Master 액션
|
||||||
|
|
||||||
|
- 가이드 문구 톤 확인
|
||||||
|
|
||||||
|
### 예상 터치 파일
|
||||||
|
|
||||||
|
- `docs/tester-guide.md`
|
||||||
|
- `docs/web-upgrade.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N4-W7 — 품질
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Status** | **done** (2026-08-04, edge smoke) — Playwright UI는 후속 선택 |
|
||||||
|
| **목표** | 웹 회귀를 자동화·수동 체크리스트로 고정 |
|
||||||
|
| **비범위** | Android 에뮬레이터 UI 탭(N4-5~10), Q9 테스트 |
|
||||||
|
|
||||||
|
### 작업 분해
|
||||||
|
|
||||||
|
- [x] [`scripts/e2e_web_smoke.sh`](../scripts/e2e_web_smoke.sh) — health/demo/manifest/login-400
|
||||||
|
- [x] 부록 A 수동 QA 표 유지
|
||||||
|
- [ ] Playwright 브라우저 UI 스모크 — 선택 후속 (에이전트 환경 제약)
|
||||||
|
|
||||||
|
### 수락 기준
|
||||||
|
|
||||||
|
- [x] `./scripts/e2e_web_smoke.sh` 한 줄 실행
|
||||||
|
- [x] 부록 A가 W1~W5와 연결됨
|
||||||
|
|
||||||
|
### Master 액션
|
||||||
|
|
||||||
|
- 라이브 대상 스모크 시 DEMO 계정 충돌 주의 (display_name)
|
||||||
|
|
||||||
|
### 예상 터치 파일
|
||||||
|
|
||||||
|
- `scripts/` 또는 `mobile/integration_test/`
|
||||||
|
- `docs/web-upgrade.md` 부록
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Android N4와의 관계
|
||||||
|
|
||||||
|
| 항목 | Android N4 | Web N4-W |
|
||||||
|
|------|------------|----------|
|
||||||
|
| 서버 SA | 공유 (이미 배치된 경우 유지) | 동일 |
|
||||||
|
| 클라이언트 설정 | `google-services.json` | Firebase Web config + VAPID |
|
||||||
|
| 푸시 스모크 | 실기기 | Chrome + `/admin/push-test` |
|
||||||
|
| 로컬 DB | SQLCipher | prefs/idb (비암호화 프리뷰) |
|
||||||
|
| 일정 | Master 실기기 가능 시 | **문서 승인 후 웹 Wi 우선 가능** |
|
||||||
|
|
||||||
|
병행 시 서버·compose 충돌 최소화: web 이미지 빌드 args만 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 상태 갱신 규칙
|
||||||
|
|
||||||
|
Wi를 끝낼 때마다:
|
||||||
|
|
||||||
|
1. 본 문서 해당 Wi **Status** → `done` (또는 `done (부분)`) + 날짜
|
||||||
|
2. [`deploy-checklist.md`](./deploy-checklist.md) N4-W 표 Status 동기화
|
||||||
|
3. [`roadmap.md`](./roadmap.md) Web `[~]` 체크 갱신
|
||||||
|
4. 구현 커밋과 **같은 변경**에 문서 체크를 넣음 (허위 done 금지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록 A — 웹 수동 QA 체크리스트 (구현 후)
|
||||||
|
|
||||||
|
**환경:** Desktop Chrome · `https://msn.iykyka.com` · 필요 시 시크릿 창 2개
|
||||||
|
|
||||||
|
| # | 단계 | W 의존 | OK |
|
||||||
|
|---|------|--------|-----|
|
||||||
|
| A1 | 새로 가입 (DEMO) + 말투 저장 | W1 | [ ] |
|
||||||
|
| A2 | 하드 리프레시 후 세션·말투 유지 | W1 | [ ] |
|
||||||
|
| A3 | 두 프로필 페어링 · 메시지 · 초안 · 뱃지 | W2 | [ ] |
|
||||||
|
| A4 | WS 끊김/재연결 상태 인지 | W2 | [ ] |
|
||||||
|
| A5 | 스누즈 → 인앱 배지 (+ 알림 허용 시 Notification) | W3 | [ ] |
|
||||||
|
| A6 | 설정 → 로그아웃 → 이미 가입 로그인 | Q8 | [ ] |
|
||||||
|
| A7 | 실 FCM 웹 토큰 · push-test 수신 | W4 | [ ] |
|
||||||
|
| A8 | PWA 설치 · standalone 시연 | W5 | [ ] |
|
||||||
|
| A9 | 오프라인 시 연결 필요 안내 | W5 | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록 B — Master 시크릿·accept 체크리스트 (W4)
|
||||||
|
|
||||||
|
- [x] Firebase Web app 등록 (도메인 `msn.iykyka.com`, APP_ID `:web:`)
|
||||||
|
- [x] VAPID key
|
||||||
|
- [x] 서버 `~/project/ykavu/.env`에 `FIREBASE_*` / `FIREBASE_VAPID_KEY` (git 미포함) → `web` 재빌드
|
||||||
|
- [x] 서버 `secrets/firebase-service-account.json` 유지 (Android와 공유)
|
||||||
|
- [ ] 하드 리프레시·알림 허용·로그인 후 push-test로 `sent >= 1` 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록 C — 관련 문서
|
||||||
|
|
||||||
|
| 문서 | 관계 |
|
||||||
|
|------|------|
|
||||||
|
| [`deploy-checklist.md`](./deploy-checklist.md) | N4-W 실행 표 |
|
||||||
|
| [`fcm-setup.md`](./fcm-setup.md) | Android + Web 시크릿 절차 |
|
||||||
|
| [`tester-guide.md`](./tester-guide.md) | 테스터 스크립트 (W6) |
|
||||||
|
| [`account-settings-ia.md`](./account-settings-ia.md) | Q8 완료 · Q9 비범위 |
|
||||||
|
| [`roadmap.md`](./roadmap.md) | Phase 체크 |
|
||||||
|
| [`android-release.md`](./android-release.md) | 별트랙 (본 문서 비범위) |
|
||||||
|
|
@ -1,17 +1,37 @@
|
||||||
# Flutter Web → nginx. Build from repository root:
|
# Flutter Web → nginx. Build from repository root:
|
||||||
# docker build -f mobile/Dockerfile --build-arg CORE_API_BASE=https://msn.iykyka.com .
|
# docker build -f mobile/Dockerfile --build-arg CORE_API_BASE=https://msn.iykyka.com .
|
||||||
|
# Optional Web Push (N4-W4) — pass FIREBASE_* / FIREBASE_VAPID_KEY as build-args
|
||||||
|
# (never commit real values; see .env.example and docs/fcm-setup.md).
|
||||||
|
#
|
||||||
|
# Secrets are copied into ENV for the build stage only (not the final nginx image).
|
||||||
|
# The RUN line calls a script so values are not expanded into Docker build logs.
|
||||||
ARG FLUTTER_IMAGE=ghcr.io/cirruslabs/flutter:3.32.7
|
ARG FLUTTER_IMAGE=ghcr.io/cirruslabs/flutter:3.32.7
|
||||||
FROM ${FLUTTER_IMAGE} AS build
|
FROM ${FLUTTER_IMAGE} AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG CORE_API_BASE=https://msn.iykyka.com
|
ARG CORE_API_BASE=https://msn.iykyka.com
|
||||||
|
ARG FIREBASE_API_KEY=
|
||||||
|
ARG FIREBASE_AUTH_DOMAIN=
|
||||||
|
ARG FIREBASE_PROJECT_ID=
|
||||||
|
ARG FIREBASE_STORAGE_BUCKET=
|
||||||
|
ARG FIREBASE_MESSAGING_SENDER_ID=
|
||||||
|
ARG FIREBASE_APP_ID=
|
||||||
|
ARG FIREBASE_VAPID_KEY=
|
||||||
|
|
||||||
|
ENV CORE_API_BASE=${CORE_API_BASE} \
|
||||||
|
FIREBASE_API_KEY=${FIREBASE_API_KEY} \
|
||||||
|
FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN} \
|
||||||
|
FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID} \
|
||||||
|
FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET} \
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID} \
|
||||||
|
FIREBASE_APP_ID=${FIREBASE_APP_ID} \
|
||||||
|
FIREBASE_VAPID_KEY=${FIREBASE_VAPID_KEY}
|
||||||
|
|
||||||
COPY mobile/pubspec.yaml mobile/pubspec.lock ./
|
COPY mobile/pubspec.yaml mobile/pubspec.lock ./
|
||||||
RUN flutter pub get
|
RUN flutter pub get
|
||||||
|
|
||||||
COPY mobile/ .
|
COPY mobile/ .
|
||||||
RUN flutter build web --release \
|
RUN chmod +x scripts/docker_build_web.sh && ./scripts/docker_build_web.sh
|
||||||
--dart-define=CORE_API_BASE=${CORE_API_BASE}
|
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
COPY --from=build /app/build/web /usr/share/nginx/html
|
COPY --from=build /app/build/web /usr/share/nginx/html
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# Optional: serve a host-built Flutter web tree (faster CI/agent smoke).
|
# Optional: serve a host-built Flutter web tree (faster CI/agent smoke).
|
||||||
# cd mobile && flutter build web --release --dart-define=CORE_API_BASE=https://msn.iykyka.com
|
# cd mobile && flutter build web --release --dart-define=CORE_API_BASE=https://msn.iykyka.com \
|
||||||
|
# # optional W4: --dart-define=FIREBASE_API_KEY=... --dart-define=FIREBASE_VAPID_KEY=...
|
||||||
# docker build -f mobile/Dockerfile.prebuilt -t ykavu-web .
|
# docker build -f mobile/Dockerfile.prebuilt -t ykavu-web .
|
||||||
# Production/Portainer should prefer mobile/Dockerfile (multi-stage flutter build).
|
# Production/Portainer should prefer mobile/Dockerfile (multi-stage flutter build).
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
# 와카뷰 mobile (Flutter)
|
# 와카뷰 mobile (Flutter)
|
||||||
|
|
||||||
Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). **v1은 Android 빌드만** 대상으로 한다
|
Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3).
|
||||||
(`docs/tech-design.md` §8).
|
**릴리즈 타깃(암호화 DB·OS 알림):** Android.
|
||||||
|
**데모/프리뷰 1차 표면:** Flutter **Web** (`https://msn.iykyka.com`) —
|
||||||
|
고도화 설계·단계: [`docs/web-upgrade.md`](../docs/web-upgrade.md) (W0~W7).
|
||||||
|
|
||||||
## 지금 있는 것
|
## 지금 있는 것
|
||||||
|
|
||||||
|
|
@ -63,14 +65,29 @@ HTTP로 검증한다.
|
||||||
sudo apt-get install -y libsqlite3-dev libsqlcipher1
|
sudo apt-get install -y libsqlite3-dev libsqlcipher1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 웹 (데모/프리뷰)
|
||||||
|
|
||||||
|
- 로컬 DB: `lib/db/app_database_web.dart` — **W1 done**: SharedPreferences 지속성(비암호화 프리뷰)
|
||||||
|
- 스누즈 OS 알림: 웹 no-op → **W3** Notification API / 인앱
|
||||||
|
- 푸시: 프로덕션은 `FIREBASE_*` + VAPID 주입됨 · 로컬은 dart-define 없으면 `install:` ([`docs/fcm-setup.md`](../docs/fcm-setup.md) Web 절)
|
||||||
|
- PWA: **W5 done** — manifest 테마·설치·셸 캐시
|
||||||
|
- **Q9**(수신 자동응대)는 웹 트랙 비범위
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 웹 로컬 (core-backend 기동 후)
|
||||||
|
cd mobile && flutter run -d chrome --dart-define=CORE_API_BASE=http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
## 푸시 (FCM)
|
## 푸시 (FCM)
|
||||||
|
|
||||||
- 코드: `lib/services/push_token_service.dart` — Firebase 가능 시 실 토큰, 아니면 `install:`
|
- 코드: `lib/services/push_token_service.dart` — Firebase 가능 시 실 토큰, 아니면 `install:`
|
||||||
- Master 설정: [`docs/fcm-setup.md`](../docs/fcm-setup.md) (`google-services.json` + `FCM_SERVER_KEY`)
|
- Android: [`docs/fcm-setup.md`](../docs/fcm-setup.md) (`google-services.json`)
|
||||||
|
- Web: [`docs/web-upgrade.md`](../docs/web-upgrade.md) W4 + 동일 `fcm-setup` Web 절
|
||||||
|
|
||||||
## 아직 없는 것
|
## 아직 없는 것
|
||||||
|
|
||||||
- Master Firebase 시크릿 주입 후 실기기 푸시 스모크 (N4-1/3/4)
|
- 웹 W4 **accept** — 브라우저 실 FCM 토큰·`/admin/push-test` (코드·`:web:` 배포는 완료, [`web-upgrade.md`](../docs/web-upgrade.md))
|
||||||
- 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화
|
- Android: `google-services.json` PC 배치 후 실기기 푸시 스모크 (N4-1/4; 서버 SA는 N4-3 done)
|
||||||
|
- 멀티디바이스 실시간 설정 동기화 고도화
|
||||||
- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
|
- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
|
||||||
- iOS 빌드 (v1 범위 밖)
|
- iOS 빌드 (v1 범위 밖)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
|
||||||
|
/// Firebase Web + VAPID settings injected at build time via `--dart-define`.
|
||||||
|
///
|
||||||
|
/// Never commit real values. Pass them through Docker build-args / `.env`
|
||||||
|
/// (see `.env.example` and `docs/fcm-setup.md` Web section).
|
||||||
|
class FirebaseWebConfig {
|
||||||
|
const FirebaseWebConfig({
|
||||||
|
required this.apiKey,
|
||||||
|
required this.appId,
|
||||||
|
required this.messagingSenderId,
|
||||||
|
required this.projectId,
|
||||||
|
required this.vapidKey,
|
||||||
|
this.authDomain = '',
|
||||||
|
this.storageBucket = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Reads compile-time defines (`FIREBASE_*`).
|
||||||
|
factory FirebaseWebConfig.fromEnvironment() {
|
||||||
|
return const FirebaseWebConfig(
|
||||||
|
apiKey: String.fromEnvironment('FIREBASE_API_KEY'),
|
||||||
|
appId: String.fromEnvironment('FIREBASE_APP_ID'),
|
||||||
|
messagingSenderId: String.fromEnvironment('FIREBASE_MESSAGING_SENDER_ID'),
|
||||||
|
projectId: String.fromEnvironment('FIREBASE_PROJECT_ID'),
|
||||||
|
vapidKey: String.fromEnvironment('FIREBASE_VAPID_KEY'),
|
||||||
|
authDomain: String.fromEnvironment('FIREBASE_AUTH_DOMAIN'),
|
||||||
|
storageBucket: String.fromEnvironment('FIREBASE_STORAGE_BUCKET'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String apiKey;
|
||||||
|
final String appId;
|
||||||
|
final String messagingSenderId;
|
||||||
|
final String projectId;
|
||||||
|
final String vapidKey;
|
||||||
|
final String authDomain;
|
||||||
|
final String storageBucket;
|
||||||
|
|
||||||
|
/// True when the minimum set for FCM Web token registration is present.
|
||||||
|
bool get isReady =>
|
||||||
|
apiKey.isNotEmpty &&
|
||||||
|
appId.isNotEmpty &&
|
||||||
|
messagingSenderId.isNotEmpty &&
|
||||||
|
projectId.isNotEmpty &&
|
||||||
|
vapidKey.isNotEmpty;
|
||||||
|
|
||||||
|
/// [FirebaseOptions] for [Firebase.initializeApp], or null when not ready.
|
||||||
|
FirebaseOptions? get options {
|
||||||
|
if (!isReady) return null;
|
||||||
|
final domain =
|
||||||
|
authDomain.isNotEmpty ? authDomain : '$projectId.firebaseapp.com';
|
||||||
|
final bucket = storageBucket.isNotEmpty
|
||||||
|
? storageBucket
|
||||||
|
: '$projectId.appspot.com';
|
||||||
|
return FirebaseOptions(
|
||||||
|
apiKey: apiKey,
|
||||||
|
appId: appId,
|
||||||
|
messagingSenderId: messagingSenderId,
|
||||||
|
projectId: projectId,
|
||||||
|
authDomain: domain,
|
||||||
|
storageBucket: bucket,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,32 +1,125 @@
|
||||||
/// Web/Chrome stub for [AppDatabase].
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Web/Chrome [AppDatabase] backed by [SharedPreferences] (IndexedDB under the hood).
|
||||||
///
|
///
|
||||||
/// SQLCipher / dart:ffi cannot compile for Flutter Web. Chrome runs use an
|
/// SQLCipher / dart:ffi cannot compile for Flutter Web. Persistence is
|
||||||
/// in-tab memory map so UI + API flows still work. Persistence is not encrypted
|
/// **not encrypted** — acceptable for the demo/preview surface
|
||||||
/// and is lost on refresh (web is a preview surface, not the release target).
|
/// (`docs/web-upgrade.md` N4-W1). Drift WASM is intentionally not used.
|
||||||
|
///
|
||||||
|
/// [AppDatabase.memory] stays ephemeral (unit tests / fallbacks).
|
||||||
class AppDatabase {
|
class AppDatabase {
|
||||||
AppDatabase({this.encrypted = false});
|
AppDatabase({
|
||||||
|
this.encrypted = false,
|
||||||
|
SharedPreferences? prefs,
|
||||||
|
bool persist = false,
|
||||||
|
}) : _prefs = prefs,
|
||||||
|
_persist = persist && prefs != null;
|
||||||
|
|
||||||
/// Always false on web — there is no SQLCipher path.
|
/// Always false on web — there is no SQLCipher path.
|
||||||
final bool encrypted;
|
final bool encrypted;
|
||||||
|
|
||||||
|
final SharedPreferences? _prefs;
|
||||||
|
final bool _persist;
|
||||||
|
|
||||||
final Map<String, String> _kv = {};
|
final Map<String, String> _kv = {};
|
||||||
List<String> _toneSamples = [];
|
List<String> _toneSamples = [];
|
||||||
final Map<int, DateTime> _snoozes = {};
|
final Map<int, DateTime> _snoozes = {};
|
||||||
|
|
||||||
factory AppDatabase.memory() => AppDatabase(encrypted: false);
|
static const _kTone = 'ykavu_web_db_tone_samples';
|
||||||
|
static const _kSnoozes = 'ykavu_web_db_snoozes';
|
||||||
|
static const _kvPrefix = 'ykavu_web_db_kv_';
|
||||||
|
|
||||||
static Future<AppDatabase> open() async => AppDatabase(encrypted: false);
|
factory AppDatabase.memory() => AppDatabase(encrypted: false, persist: false);
|
||||||
|
|
||||||
|
/// Opens a durable preview DB (survives hard refresh in the same browser profile).
|
||||||
|
static Future<AppDatabase> open() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final db = AppDatabase(encrypted: false, prefs: prefs, persist: true);
|
||||||
|
await db._hydrate();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _hydrate() async {
|
||||||
|
final prefs = _prefs;
|
||||||
|
if (!_persist || prefs == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final toneRaw = prefs.getString(_kTone);
|
||||||
|
if (toneRaw != null && toneRaw.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(toneRaw);
|
||||||
|
if (decoded is List) {
|
||||||
|
_toneSamples = decoded.map((e) => e.toString()).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_toneSamples = [];
|
||||||
|
await prefs.remove(_kTone);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final snoozeRaw = prefs.getString(_kSnoozes);
|
||||||
|
if (snoozeRaw != null && snoozeRaw.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(snoozeRaw);
|
||||||
|
if (decoded is Map) {
|
||||||
|
_snoozes
|
||||||
|
..clear()
|
||||||
|
..addEntries(
|
||||||
|
decoded.entries.map((e) {
|
||||||
|
final id = int.tryParse(e.key.toString());
|
||||||
|
final ms = e.value is int
|
||||||
|
? e.value as int
|
||||||
|
: int.tryParse(e.value.toString());
|
||||||
|
if (id == null || ms == null) return null;
|
||||||
|
return MapEntry(id, DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true));
|
||||||
|
}).whereType<MapEntry<int, DateTime>>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_snoozes.clear();
|
||||||
|
await prefs.remove(_kSnoozes);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final key in prefs.getKeys()) {
|
||||||
|
if (!key.startsWith(_kvPrefix)) continue;
|
||||||
|
final logical = key.substring(_kvPrefix.length);
|
||||||
|
final value = prefs.getString(key);
|
||||||
|
if (value != null) _kv[logical] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistTone() async {
|
||||||
|
if (!_persist || _prefs == null) return;
|
||||||
|
await _prefs.setString(_kTone, jsonEncode(_toneSamples));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistSnoozes() async {
|
||||||
|
if (!_persist || _prefs == null) return;
|
||||||
|
final map = <String, int>{
|
||||||
|
for (final e in _snoozes.entries) e.key.toString(): e.value.toUtc().millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
await _prefs.setString(_kSnoozes, jsonEncode(map));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistKv(String key, String value) async {
|
||||||
|
if (!_persist || _prefs == null) return;
|
||||||
|
await _prefs.setString('$_kvPrefix$key', value);
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<String>> loadToneSamples() async => List<String>.from(_toneSamples);
|
Future<List<String>> loadToneSamples() async => List<String>.from(_toneSamples);
|
||||||
|
|
||||||
Future<void> replaceToneSamples(List<String> samples) async {
|
Future<void> replaceToneSamples(List<String> samples) async {
|
||||||
_toneSamples = List<String>.from(samples);
|
_toneSamples = List<String>.from(samples);
|
||||||
|
await _persistTone();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> getKv(String key) async => _kv[key];
|
Future<String?> getKv(String key) async => _kv[key];
|
||||||
|
|
||||||
Future<void> setKv(String key, String value) async {
|
Future<void> setKv(String key, String value) async {
|
||||||
_kv[key] = value;
|
_kv[key] = value;
|
||||||
|
await _persistKv(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
|
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
|
||||||
|
|
@ -39,16 +132,17 @@ class AppDatabase {
|
||||||
await setKv(key, value ? '1' : '0');
|
await setKv(key, value ? '1' : '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
// roadmap.md §2.7-F 답장 마감 알림 — native stub과 동일한 시그니처
|
/// roadmap.md §2.7-F — on-device snooze; web preview persists via SharedPreferences.
|
||||||
// (in-memory, encrypted persistence 없음 — web은 프리뷰 서피스일 뿐 릴리스 타깃이 아님).
|
|
||||||
Future<void> setSnoozedUntil(int conversationId, DateTime until) async {
|
Future<void> setSnoozedUntil(int conversationId, DateTime until) async {
|
||||||
_snoozes[conversationId] = until;
|
_snoozes[conversationId] = until.toUtc();
|
||||||
|
await _persistSnoozes();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DateTime?> getSnoozedUntil(int conversationId) async => _snoozes[conversationId];
|
Future<DateTime?> getSnoozedUntil(int conversationId) async => _snoozes[conversationId];
|
||||||
|
|
||||||
Future<void> clearSnooze(int conversationId) async {
|
Future<void> clearSnooze(int conversationId) async {
|
||||||
_snoozes.remove(conversationId);
|
_snoozes.remove(conversationId);
|
||||||
|
await _persistSnoozes();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<int, DateTime>> loadAllSnoozes() async => Map<int, DateTime>.from(_snoozes);
|
Future<Map<int, DateTime>> loadAllSnoozes() async => Map<int, DateTime>.from(_snoozes);
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,8 @@ class ChatMessage {
|
||||||
required this.text,
|
required this.text,
|
||||||
required this.retracted,
|
required this.retracted,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
this.draftEdited,
|
||||||
|
this.naturalnessRating,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
|
|
@ -183,6 +185,15 @@ class ChatMessage {
|
||||||
final String text;
|
final String text;
|
||||||
final bool retracted;
|
final bool retracted;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
/// L1 approval-rate proxy (PRD.md §5, deploy-checklist.md N4-12) — null
|
||||||
|
/// for human messages and any twin message the server couldn't diff
|
||||||
|
/// against an original draft; otherwise true = edited before send,
|
||||||
|
/// false = sent verbatim. Purely informational on the client; the server
|
||||||
|
/// is the source of truth (computed once at send time).
|
||||||
|
final bool? draftEdited;
|
||||||
|
/// "이 답장 나답아요?" explicit feedback (vision.md metric, N4-12) — null
|
||||||
|
/// = not rated yet, true = 👍, false = 👎.
|
||||||
|
final bool? naturalnessRating;
|
||||||
|
|
||||||
bool get isTwin => senderMode == SenderMode.twin;
|
bool get isTwin => senderMode == SenderMode.twin;
|
||||||
|
|
||||||
|
|
@ -196,6 +207,8 @@ class ChatMessage {
|
||||||
text: json['text'] as String? ?? '',
|
text: json['text'] as String? ?? '',
|
||||||
retracted: json['retracted'] as bool? ?? false,
|
retracted: json['retracted'] as bool? ?? false,
|
||||||
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
|
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
|
||||||
|
draftEdited: json['draft_edited'] as bool?,
|
||||||
|
naturalnessRating: json['naturalness_rating'] as bool?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ class ChatScreen extends StatefulWidget {
|
||||||
this.title,
|
this.title,
|
||||||
this.isGroup = false,
|
this.isGroup = false,
|
||||||
this.twinDisabledByFlood = false,
|
this.twinDisabledByFlood = false,
|
||||||
|
this.embedded = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int conversationId;
|
final int conversationId;
|
||||||
|
|
@ -29,6 +30,9 @@ class ChatScreen extends StatefulWidget {
|
||||||
// 보여줄 수 있게 한다.
|
// 보여줄 수 있게 한다.
|
||||||
final bool twinDisabledByFlood;
|
final bool twinDisabledByFlood;
|
||||||
|
|
||||||
|
/// When true, shown inside the wide master-detail pane (N4-W2) — no back affordance.
|
||||||
|
final bool embedded;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
}
|
}
|
||||||
|
|
@ -46,12 +50,18 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
// 호출한다는 것만 여기서 보장하고, 나머지(진짜 네트워크 단절, 백그라운드
|
// 호출한다는 것만 여기서 보장하고, 나머지(진짜 네트워크 단절, 백그라운드
|
||||||
// 전환에서 OS가 소켓을 실제로 끊는지)는 실기기 QA 대상이다.
|
// 전환에서 OS가 소켓을 실제로 끊는지)는 실기기 QA 대상이다.
|
||||||
StreamSubscription? _reconnectSub;
|
StreamSubscription? _reconnectSub;
|
||||||
|
StreamSubscription? _linkSub;
|
||||||
|
WsLinkState _linkState = WsLinkState.connecting;
|
||||||
String? _banner;
|
String? _banner;
|
||||||
DraftResult? _pendingDraft;
|
DraftResult? _pendingDraft;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
bool _loadingHistory = true;
|
bool _loadingHistory = true;
|
||||||
late bool _floodBlocked;
|
late bool _floodBlocked;
|
||||||
late final ApiClient _api;
|
late final ApiClient _api;
|
||||||
|
// "이 답장 나답아요?" 피드백(vision.md 지표, deploy-checklist.md N4-12):
|
||||||
|
// 이번 세션에서(또는 서버에서 이미) 평가된 메시지 id들 — 한 번 탭한 메시지엔
|
||||||
|
// 화면이 다시 그려져도(리로드 없이) 뱃지를 다시 보여주지 않는다.
|
||||||
|
final _ratedMessageIds = <int>{};
|
||||||
// 답장 마감 알림(roadmap.md §2.7-F) — 이 대화방에 걸린 "이따 답장" 스누즈 시각.
|
// 답장 마감 알림(roadmap.md §2.7-F) — 이 대화방에 걸린 "이따 답장" 스누즈 시각.
|
||||||
// 서버에는 존재하지 않는 순수 온디바이스 상태라 화면에 들어올 때 로컬 DB에서 로드한다.
|
// 서버에는 존재하지 않는 순수 온디바이스 상태라 화면에 들어올 때 로컬 DB에서 로드한다.
|
||||||
DateTime? _snoozedUntil;
|
DateTime? _snoozedUntil;
|
||||||
|
|
@ -66,10 +76,15 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
_banner = '도배 감지로 이 대화방의 와카뷰 자동응대가 일시중단되었습니다.';
|
_banner = '도배 감지로 이 대화방의 와카뷰 자동응대가 일시중단되었습니다.';
|
||||||
}
|
}
|
||||||
_socket = ConversationSocket(widget.conversationId)..connect();
|
_socket = ConversationSocket(widget.conversationId)..connect();
|
||||||
|
_linkState = _socket!.linkState;
|
||||||
_sub = _socket!.events.listen(_onEvent);
|
_sub = _socket!.events.listen(_onEvent);
|
||||||
// 소켓이 끊겼다가 다시 붙으면(백그라운드, 네트워크 hiccup 등) 그 사이에
|
// 소켓이 끊겼다가 다시 붙으면(백그라운드, 네트워크 hiccup 등) 그 사이에
|
||||||
// 놓친 메시지를 REST since_id로 다시 받아온다 — ws_client.dart 참고.
|
// 놓친 메시지를 REST since_id로 다시 받아온다 — ws_client.dart 참고.
|
||||||
_reconnectSub = _socket!.reconnects.listen((_) => _catchUp());
|
_reconnectSub = _socket!.reconnects.listen((_) => _catchUp());
|
||||||
|
_linkSub = _socket!.linkStates.listen((s) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _linkState = s);
|
||||||
|
});
|
||||||
_loadHistory();
|
_loadHistory();
|
||||||
_loadSnooze();
|
_loadSnooze();
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +96,9 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
// 안전망). 실기기에서 OS가 실제로 어떻게 동작하는지는 QA 대상.
|
// 안전망). 실기기에서 OS가 실제로 어떻게 동작하는지는 QA 대상.
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
_catchUp();
|
_catchUp();
|
||||||
|
// N4-W3 — past-due snooze Notification on web focus (badges already refresh).
|
||||||
|
context.read<SessionState>().snoozeController?.notifyPastDueOnFocus();
|
||||||
|
_loadSnooze();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,6 +115,7 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
_messages
|
_messages
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(merged);
|
..addAll(merged);
|
||||||
|
_syncRatedMessageIds(merged);
|
||||||
});
|
});
|
||||||
_scrollToEnd();
|
_scrollToEnd();
|
||||||
_markLatestRead();
|
_markLatestRead();
|
||||||
|
|
@ -194,6 +213,7 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(history);
|
..addAll(history);
|
||||||
_loadingHistory = false;
|
_loadingHistory = false;
|
||||||
|
_syncRatedMessageIds(history);
|
||||||
});
|
});
|
||||||
_scrollToEnd();
|
_scrollToEnd();
|
||||||
// 읽음 마커는 화면에 들어올 때가 아니라 "나갈 때"(dispose) 찍는다 --
|
// 읽음 마커는 화면에 들어올 때가 아니라 "나갈 때"(dispose) 찍는다 --
|
||||||
|
|
@ -232,6 +252,28 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 서버에서 이미 평가된 메시지(다른 세션/기기에서 탭했거나, 새로고침 전에
|
||||||
|
/// 이미 평가된 경우)를 _ratedMessageIds에 반영 — UI를 다시 보여주지 않기
|
||||||
|
/// 위함. 실제 판정은 message_sync.dart의 순수 함수(ratedMessageIdsFrom)로
|
||||||
|
/// 분리해 위젯 없이도 테스트할 수 있게 했다.
|
||||||
|
void _syncRatedMessageIds(List<ChatMessage> messages) {
|
||||||
|
_ratedMessageIds.addAll(ratedMessageIdsFrom(messages));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "이 답장 나답아요?" 원탭 피드백(vision.md 지표, deploy-checklist.md
|
||||||
|
/// N4-12) — 트윈이 보낸 메시지 위에서만 노출(MessageBubble이 이미 twin +
|
||||||
|
/// isMine + !retracted로 걸러서 호출). 한 번 탭하면 다시 평가를 받지 않음.
|
||||||
|
Future<void> _submitFeedback(ChatMessage message, bool natural) async {
|
||||||
|
setState(() => _ratedMessageIds.add(message.id));
|
||||||
|
try {
|
||||||
|
await _api.submitMessageFeedback(message.id, natural);
|
||||||
|
} on ApiException {
|
||||||
|
// Best-effort — this is a soft signal, not a safety-critical action;
|
||||||
|
// if it fails to save server-side we don't re-offer the tap (that
|
||||||
|
// would look like a broken button), we just quietly drop it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onEvent(Map<String, dynamic> event) {
|
void _onEvent(Map<String, dynamic> event) {
|
||||||
final retractionId = _socket!.parseRetractionId(event);
|
final retractionId = _socket!.parseRetractionId(event);
|
||||||
if (retractionId != null) {
|
if (retractionId != null) {
|
||||||
|
|
@ -247,6 +289,8 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
text: m.text,
|
text: m.text,
|
||||||
retracted: true,
|
retracted: true,
|
||||||
createdAt: m.createdAt,
|
createdAt: m.createdAt,
|
||||||
|
draftEdited: m.draftEdited,
|
||||||
|
naturalnessRating: m.naturalnessRating,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -269,6 +313,7 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_sub?.cancel();
|
_sub?.cancel();
|
||||||
_reconnectSub?.cancel();
|
_reconnectSub?.cancel();
|
||||||
|
_linkSub?.cancel();
|
||||||
_socket?.dispose();
|
_socket?.dispose();
|
||||||
_input.dispose();
|
_input.dispose();
|
||||||
_draftEdit.dispose();
|
_draftEdit.dispose();
|
||||||
|
|
@ -368,6 +413,10 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
text: text,
|
text: text,
|
||||||
senderMode: SenderMode.twin,
|
senderMode: SenderMode.twin,
|
||||||
approved: true,
|
approved: true,
|
||||||
|
// 초안 무수정 발송률(PRD.md §5, N4-12): draft.text는 사용자가 편집
|
||||||
|
// 컨트롤러(_draftEdit)를 건드리기 전 AI 초안 원문 — 서버가 이걸
|
||||||
|
// 최종 text와 diff해 draft_edited를 계산한다.
|
||||||
|
originalDraftText: draft.text,
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_pendingDraft = null;
|
_pendingDraft = null;
|
||||||
|
|
@ -615,7 +664,22 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
|
automaticallyImplyLeading: !widget.embedded,
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(widget.title ?? '대화방 #${widget.conversationId}'),
|
||||||
|
Text(
|
||||||
|
wsLinkStateLabel(_linkState),
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: _linkState == WsLinkState.live
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (widget.isGroup)
|
if (widget.isGroup)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
@ -639,6 +703,22 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
|
if (_linkState == WsLinkState.reconnecting)
|
||||||
|
Material(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
child: Text(
|
||||||
|
'연결이 끊겼습니다. 다시 연결하는 중…',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (_banner != null)
|
if (_banner != null)
|
||||||
MaterialBanner(
|
MaterialBanner(
|
||||||
leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant),
|
leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
|
@ -675,10 +755,18 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||||
itemCount: _messages.length,
|
itemCount: _messages.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final m = _messages[i];
|
final m = _messages[i];
|
||||||
|
final isMine = me != null && m.senderId == me;
|
||||||
return MessageBubble(
|
return MessageBubble(
|
||||||
message: m,
|
message: m,
|
||||||
isMine: me != null && m.senderId == me,
|
isMine: isMine,
|
||||||
onRetract: m.isTwin ? () => _retract(m) : null,
|
onRetract: m.isTwin ? () => _retract(m) : null,
|
||||||
|
// "이 답장 나답아요?" (vision.md 지표, N4-12): 트윈이
|
||||||
|
// 실제로 보낸(=내가 보낸) 메시지에만 노출, 이미
|
||||||
|
// 평가된 메시지는 다시 보여주지 않는다.
|
||||||
|
alreadyRated: _ratedMessageIds.contains(m.id),
|
||||||
|
onFeedback: m.isTwin && isMine && !m.retracted
|
||||||
|
? (natural) => _submitFeedback(m, natural)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,15 @@ 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';
|
||||||
import 'autonomy_settings_screen.dart';
|
import '../widgets/offline_banner.dart';
|
||||||
|
import '../widgets/web_preview_banner.dart';
|
||||||
import 'chat_screen.dart';
|
import 'chat_screen.dart';
|
||||||
import 'contacts_screen.dart';
|
import 'contacts_screen.dart';
|
||||||
import 'inbox_screen.dart';
|
import 'inbox_screen.dart';
|
||||||
import 'onboarding_tone_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
|
/// Breakpoint for list | chat master-detail (docs/web-upgrade.md N4-W2).
|
||||||
|
const double kMessengerSplitBreakpoint = 900;
|
||||||
|
|
||||||
class ConversationListScreen extends StatefulWidget {
|
class ConversationListScreen extends StatefulWidget {
|
||||||
const ConversationListScreen({super.key});
|
const ConversationListScreen({super.key});
|
||||||
|
|
@ -20,7 +24,8 @@ class ConversationListScreen extends StatefulWidget {
|
||||||
State<ConversationListScreen> createState() => _ConversationListScreenState();
|
State<ConversationListScreen> createState() => _ConversationListScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ConversationListScreenState extends State<ConversationListScreen> {
|
class _ConversationListScreenState extends State<ConversationListScreen>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
List<ConversationSummary> _rooms = [];
|
List<ConversationSummary> _rooms = [];
|
||||||
Map<int, String> _peerNames = {};
|
Map<int, String> _peerNames = {};
|
||||||
// 답장 마감 알림(roadmap.md §2.7-F) — 대화방 id별 "이따 답장" 스누즈 마감 시각.
|
// 답장 마감 알림(roadmap.md §2.7-F) — 대화방 id별 "이따 답장" 스누즈 마감 시각.
|
||||||
|
|
@ -29,12 +34,39 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
|
/// Wide-layout selection (narrow width still uses Navigator.push).
|
||||||
|
int? _selectedId;
|
||||||
|
String? _selectedTitle;
|
||||||
|
bool _selectedIsGroup = false;
|
||||||
|
bool _selectedFlood = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
// N4-W3 — tab/app focus: refresh list badges + optional web Notification.
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_onFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onFocus() async {
|
||||||
|
final controller = context.read<SessionState>().snoozeController;
|
||||||
|
await controller?.notifyPastDueOnFocus();
|
||||||
|
if (mounted) await _load();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final session = context.read<SessionState>();
|
final session = context.read<SessionState>();
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -79,6 +111,39 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
setState(() => _snoozes.remove(conversationId));
|
setState(() => _snoozes.remove(conversationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get _useSplit {
|
||||||
|
final width = MediaQuery.sizeOf(context).width;
|
||||||
|
return width >= kMessengerSplitBreakpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openChat({
|
||||||
|
required int conversationId,
|
||||||
|
required String title,
|
||||||
|
required bool isGroup,
|
||||||
|
bool twinDisabledByFlood = false,
|
||||||
|
}) async {
|
||||||
|
if (_useSplit) {
|
||||||
|
setState(() {
|
||||||
|
_selectedId = conversationId;
|
||||||
|
_selectedTitle = title;
|
||||||
|
_selectedIsGroup = isGroup;
|
||||||
|
_selectedFlood = twinDisabledByFlood;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ChatScreen(
|
||||||
|
conversationId: conversationId,
|
||||||
|
title: title,
|
||||||
|
isGroup: isGroup,
|
||||||
|
twinDisabledByFlood: twinDisabledByFlood,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) await _load();
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|
@ -205,16 +270,13 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
isGroup: isGroup,
|
isGroup: isGroup,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => ChatScreen(
|
|
||||||
conversationId: conv.id,
|
|
||||||
title: isGroup ? '그룹 #${conv.id}' : (_peerNames[peers.first] ?? '상대 #${peers.first}'),
|
|
||||||
isGroup: isGroup,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await _load();
|
await _load();
|
||||||
|
if (!mounted) return;
|
||||||
|
await _openChat(
|
||||||
|
conversationId: conv.id,
|
||||||
|
title: isGroup ? '그룹 #${conv.id}' : (_peerNames[peers.first] ?? '상대 #${peers.first}'),
|
||||||
|
isGroup: isGroup,
|
||||||
|
);
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}');
|
setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}');
|
||||||
}
|
}
|
||||||
|
|
@ -241,8 +303,9 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final me = session.user?.id;
|
final me = session.user?.id;
|
||||||
|
final split = _useSplit;
|
||||||
|
|
||||||
return Scaffold(
|
final listPane = Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: GradientText('와카뷰', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)),
|
title: GradientText('와카뷰', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -262,30 +325,14 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.contacts_outlined),
|
icon: const Icon(Icons.contacts_outlined),
|
||||||
),
|
),
|
||||||
PopupMenuButton<VoidCallback>(
|
IconButton(
|
||||||
tooltip: '더보기',
|
tooltip: '설정',
|
||||||
icon: const Icon(Icons.more_vert),
|
onPressed: () {
|
||||||
onSelected: (action) => action(),
|
Navigator.of(context).push(
|
||||||
itemBuilder: (context) => [
|
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||||
PopupMenuItem(
|
);
|
||||||
value: () => Navigator.of(context)
|
},
|
||||||
.push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen())),
|
icon: const Icon(Icons.settings_outlined),
|
||||||
child: const ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: Icon(Icons.record_voice_over_outlined),
|
|
||||||
title: Text('말투 샘플'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
value: () => Navigator.of(context)
|
|
||||||
.push(MaterialPageRoute(builder: (_) => const AutonomySettingsScreen())),
|
|
||||||
child: const ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: Icon(Icons.tune),
|
|
||||||
title: Text('자율성 설정'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
],
|
],
|
||||||
|
|
@ -295,95 +342,130 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
tooltip: '새 대화',
|
tooltip: '새 대화',
|
||||||
child: const Icon(Icons.chat_outlined),
|
child: const Icon(Icons.chat_outlined),
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
body: Column(
|
||||||
onRefresh: _load,
|
children: [
|
||||||
child: _loading
|
const OfflineBanner(),
|
||||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
const WebPreviewBanner(),
|
||||||
: ListView(
|
Expanded(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
child: RefreshIndicator(
|
||||||
children: [
|
onRefresh: _load,
|
||||||
Padding(
|
child: _loading
|
||||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
||||||
child: Text(
|
: ListView(
|
||||||
session.user == null ? '' : '안녕하세요, ${session.user!.displayName}님',
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
children: [
|
||||||
),
|
Padding(
|
||||||
),
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||||
if (me != null)
|
child: Text(
|
||||||
Padding(
|
session.user == null ? '' : '안녕하세요, ${session.user!.displayName}님',
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
child: MyUserIdChip(userId: me),
|
),
|
||||||
),
|
),
|
||||||
if (_error != null)
|
if (me != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
child: MyUserIdChip(userId: me),
|
||||||
),
|
),
|
||||||
if (_rooms.isEmpty)
|
if (_error != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(32, 48, 32, 32),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Column(
|
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||||
children: [
|
),
|
||||||
Text('대화방이 없습니다', style: theme.textTheme.titleMedium),
|
if (_rooms.isEmpty)
|
||||||
const SizedBox(height: 8),
|
Padding(
|
||||||
Text(
|
padding: const EdgeInsets.fromLTRB(32, 48, 32, 32),
|
||||||
'1) 내 ID를 상대에게 알려 주세요\n'
|
child: Column(
|
||||||
'2) 연락처에 상대의 숫자 ID를 넣고 추가\n'
|
children: [
|
||||||
'3) 연락처에서 「대화」또는 행을 탭하세요',
|
Text('대화방이 없습니다', style: theme.textTheme.titleMedium),
|
||||||
textAlign: TextAlign.center,
|
const SizedBox(height: 8),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
Text(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
'1) 아래 「내 사용자 ID」를 탭해 복사한 뒤 상대에게 알려 주세요\n'
|
||||||
height: 1.5,
|
'2) 연락처에 상대의 숫자 ID를 넣고 추가\n'
|
||||||
|
'3) 연락처에서 「대화」또는 행을 탭하세요',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||||
|
);
|
||||||
|
await _load();
|
||||||
|
},
|
||||||
|
child: const Text('연락처 열기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
for (final room in _rooms)
|
||||||
OutlinedButton(
|
Builder(
|
||||||
onPressed: () async {
|
builder: (context) {
|
||||||
await Navigator.of(context).push(
|
final snoozedUntil = _snoozes[room.id];
|
||||||
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
final snoozePastDue = isSnoozePastDue(DateTime.now(), snoozedUntil);
|
||||||
);
|
return _ConversationRow(
|
||||||
await _load();
|
room: room,
|
||||||
},
|
me: me,
|
||||||
child: const Text('연락처 열기'),
|
title: _titleFor(room, me),
|
||||||
),
|
subtitle: _subtitleFor(room, me),
|
||||||
],
|
avatarColor: _avatarColor(context, room.id),
|
||||||
),
|
onAvatarColor: _onAvatarColor(context, room.id),
|
||||||
),
|
snoozePastDue: snoozePastDue,
|
||||||
for (final room in _rooms)
|
selected: split && _selectedId == room.id,
|
||||||
Builder(
|
onClearSnooze: () => _clearSnoozeFromList(room.id),
|
||||||
builder: (context) {
|
onTap: () => _openChat(
|
||||||
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(
|
|
||||||
builder: (_) => ChatScreen(
|
|
||||||
conversationId: room.id,
|
conversationId: room.id,
|
||||||
title: _titleFor(room, me),
|
title: _titleFor(room, me),
|
||||||
isGroup: room.isGroup,
|
isGroup: room.isGroup,
|
||||||
twinDisabledByFlood: room.twinDisabledByFlood,
|
twinDisabledByFlood: room.twinDisabledByFlood,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
await _load();
|
),
|
||||||
},
|
const SizedBox(height: 72),
|
||||||
);
|
],
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 72),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!split) return listPane;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 360,
|
||||||
|
child: listPane,
|
||||||
|
),
|
||||||
|
VerticalDivider(width: 1, thickness: 1, color: theme.dividerColor),
|
||||||
|
Expanded(
|
||||||
|
child: _selectedId == null
|
||||||
|
? Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Text(
|
||||||
|
'왼쪽에서 대화를 선택하세요',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ChatScreen(
|
||||||
|
key: ValueKey(_selectedId),
|
||||||
|
conversationId: _selectedId!,
|
||||||
|
title: _selectedTitle,
|
||||||
|
isGroup: _selectedIsGroup,
|
||||||
|
twinDisabledByFlood: _selectedFlood,
|
||||||
|
embedded: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,6 +480,7 @@ class _ConversationRow extends StatelessWidget {
|
||||||
required this.snoozePastDue,
|
required this.snoozePastDue,
|
||||||
required this.onClearSnooze,
|
required this.onClearSnooze,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.selected = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ConversationSummary room;
|
final ConversationSummary room;
|
||||||
|
|
@ -407,6 +490,7 @@ class _ConversationRow extends StatelessWidget {
|
||||||
final Color avatarColor;
|
final Color avatarColor;
|
||||||
final Color onAvatarColor;
|
final Color onAvatarColor;
|
||||||
final bool snoozePastDue;
|
final bool snoozePastDue;
|
||||||
|
final bool selected;
|
||||||
final VoidCallback onClearSnooze;
|
final VoidCallback onClearSnooze;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
|
@ -415,7 +499,7 @@ class _ConversationRow extends StatelessWidget {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final blockedColor = room.twinDisabledByPeer || room.twinDisabledByFlood;
|
final blockedColor = room.twinDisabledByPeer || room.twinDisabledByFlood;
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: selected ? theme.colorScheme.primary.withValues(alpha: 0.08) : Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|
@ -438,7 +522,10 @@ class _ConversationRow extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: selected ? theme.colorScheme.primary : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
|
@ -74,7 +75,9 @@ class DataFlowScreen extends StatelessWidget {
|
||||||
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
|
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
|
||||||
session.localDbEncrypted
|
session.localDbEncrypted
|
||||||
? '저장: drift + SQLCipher(암호화)'
|
? '저장: drift + SQLCipher(암호화)'
|
||||||
: '저장: 메모리/웹 스텁(이 환경에 SQLCipher 없음 — Chrome·Linux 폴백)',
|
: kIsWeb
|
||||||
|
? '저장: 브라우저 저장소(비암호화 · 웹 프리뷰 — 같은 프로필에서는 새로고침 후에도 유지)'
|
||||||
|
: '저장: 메모리 폴백(이 환경에 SQLCipher 없음 — Linux CI 등)',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
|
@ -90,7 +93,8 @@ class DataFlowScreen extends StatelessWidget {
|
||||||
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
|
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
|
||||||
'채팅 릴레이: 보낸 메시지 본문\n'
|
'채팅 릴레이: 보낸 메시지 본문\n'
|
||||||
'계정: 표시 이름·초대 코드·세션 토큰\n'
|
'계정: 표시 이름·초대 코드·세션 토큰\n'
|
||||||
'푸시: Firebase 연결 시 실 FCM 토큰 등록 · 미연결 시 install 플레이스홀더 (docs/fcm-setup.md)',
|
'푸시: Android는 google-services.json, 웹은 FIREBASE_*+VAPID 빌드 주입 시 실 FCM 토큰 등록 · '
|
||||||
|
'미연결 시 install 플레이스홀더 (docs/fcm-setup.md · web-upgrade W4)',
|
||||||
),
|
),
|
||||||
_section(
|
_section(
|
||||||
context,
|
context,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,17 @@ import '../state/session_state.dart';
|
||||||
class SessionsScreen extends StatefulWidget {
|
class SessionsScreen extends StatefulWidget {
|
||||||
const SessionsScreen({super.key});
|
const SessionsScreen({super.key});
|
||||||
|
|
||||||
|
/// Formats API `expires_at` (UTC RFC3339) for the device local timezone.
|
||||||
|
static String formatExpiresAtLocal(Object? raw) {
|
||||||
|
if (raw == null) return '';
|
||||||
|
final parsed = DateTime.tryParse(raw.toString());
|
||||||
|
if (parsed == null) return raw.toString();
|
||||||
|
final local = parsed.toLocal();
|
||||||
|
String two(int n) => n.toString().padLeft(2, '0');
|
||||||
|
return '${local.year}-${two(local.month)}-${two(local.day)} '
|
||||||
|
'${two(local.hour)}:${two(local.minute)}';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SessionsScreen> createState() => _SessionsScreenState();
|
State<SessionsScreen> createState() => _SessionsScreenState();
|
||||||
}
|
}
|
||||||
|
|
@ -57,13 +68,16 @@ class _SessionsScreenState extends State<SessionsScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (ok != true) return;
|
if (ok != true || !mounted) return;
|
||||||
|
if (isCurrent) {
|
||||||
|
// Q8c — clear local token and return to entry via AuthGate.
|
||||||
|
await session.logout();
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await session.api.revokeSession(session.user!.id, id.toInt());
|
await session.api.revokeSession(session.user!.id, id.toInt());
|
||||||
if (isCurrent && mounted) {
|
|
||||||
// Soft signal — full logout clearing is a follow-up; reload list for now.
|
|
||||||
setState(() => _error = '현재 세션이 종료되었습니다. 앱을 다시 시작해 주세요.');
|
|
||||||
}
|
|
||||||
await _load();
|
await _load();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
setState(() => _error = '세션 종료 실패 (${e.statusCode})');
|
setState(() => _error = '세션 종료 실패 (${e.statusCode})');
|
||||||
|
|
@ -101,7 +115,10 @@ class _SessionsScreenState extends State<SessionsScreen> {
|
||||||
s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}',
|
s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}',
|
||||||
style: theme.textTheme.titleSmall,
|
style: theme.textTheme.titleSmall,
|
||||||
),
|
),
|
||||||
subtitle: Text('만료: ${s['expires_at'] ?? ''}', style: theme.textTheme.bodySmall),
|
subtitle: Text(
|
||||||
|
'만료: ${SessionsScreen.formatExpiresAtLocal(s['expires_at'])}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
tooltip: '세션 종료',
|
tooltip: '세션 종료',
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../services/snooze_notification_service.dart';
|
||||||
|
import '../state/session_state.dart';
|
||||||
|
import 'autonomy_settings_screen.dart';
|
||||||
|
import 'data_flow_screen.dart';
|
||||||
|
import 'onboarding_tone_screen.dart';
|
||||||
|
import 'sessions_screen.dart';
|
||||||
|
|
||||||
|
/// Account / settings hub (Q8).
|
||||||
|
///
|
||||||
|
/// Groups tone, autonomy, sessions, data-flow, and logout in one place
|
||||||
|
/// so the conversation list menu stays short.
|
||||||
|
class SettingsScreen extends StatelessWidget {
|
||||||
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
|
Future<void> _confirmLogout(BuildContext context) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('로그아웃'),
|
||||||
|
content: const Text(
|
||||||
|
'이 기기에서 로그아웃할까요?\n'
|
||||||
|
'다시 쓰려면 초대 코드와 표시 이름으로 로그인해야 합니다.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('취소'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('로그아웃'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true || !context.mounted) return;
|
||||||
|
|
||||||
|
final session = context.read<SessionState>();
|
||||||
|
await session.logout();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
// Pop back to root; AuthGate rebuilds to SignupScreen.
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final session = context.watch<SessionState>();
|
||||||
|
final user = session.user;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('설정')),
|
||||||
|
body: ListView(
|
||||||
|
children: [
|
||||||
|
if (user != null)
|
||||||
|
ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
child: Text(
|
||||||
|
user.displayName.isNotEmpty
|
||||||
|
? String.fromCharCode(user.displayName.runes.first).toUpperCase()
|
||||||
|
: '?',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(user.displayName),
|
||||||
|
subtitle: Text(
|
||||||
|
'user #${user.id} · 탭하여 ID 복사',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.copy_rounded, size: 18),
|
||||||
|
onTap: () async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: '${user.id}'));
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('내 사용자 ID ${user.id} 를 복사했습니다')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.record_voice_over_outlined),
|
||||||
|
title: const Text('말투 · 페르소나'),
|
||||||
|
subtitle: const Text('트윈이 쓰는 말투와 역할'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const OnboardingToneScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.tune),
|
||||||
|
title: const Text('자율성 수준'),
|
||||||
|
subtitle: const Text('L0 · L1 · L2'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const AutonomySettingsScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.devices_outlined),
|
||||||
|
title: const Text('로그인 세션'),
|
||||||
|
subtitle: const Text('기기별 세션 조회 · 폐기'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const SessionsScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.account_tree_outlined),
|
||||||
|
title: const Text('데이터 흐름'),
|
||||||
|
subtitle: const Text('내 데이터가 어디로 가는지'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const DataFlowScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (kIsWeb)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.notifications_active_outlined),
|
||||||
|
title: const Text('브라우저 알림'),
|
||||||
|
subtitle: const Text('답장 마감 리마인드 (허용 시)'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () async {
|
||||||
|
final ok = await LocalSnoozeNotificationScheduler().ensurePermission();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
ok
|
||||||
|
? '브라우저 알림이 허용되었습니다'
|
||||||
|
: '알림이 거부되었거나 지원되지 않습니다. 인앱 배지는 그대로 동작합니다.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.logout, color: theme.colorScheme.error),
|
||||||
|
title: Text(
|
||||||
|
'로그아웃',
|
||||||
|
style: TextStyle(color: theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
subtitle: const Text('이 기기 세션 종료'),
|
||||||
|
onTap: () => _confirmLogout(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import '../widgets/brand_mark.dart';
|
||||||
import '../widgets/gradient_text.dart';
|
import '../widgets/gradient_text.dart';
|
||||||
import '../widgets/primary_gradient_button.dart';
|
import '../widgets/primary_gradient_button.dart';
|
||||||
|
|
||||||
|
/// Closed-beta entry: signup (new invite) or login (already registered).
|
||||||
class SignupScreen extends StatefulWidget {
|
class SignupScreen extends StatefulWidget {
|
||||||
const SignupScreen({super.key});
|
const SignupScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -16,12 +17,26 @@ class SignupScreen extends StatefulWidget {
|
||||||
State<SignupScreen> createState() => _SignupScreenState();
|
State<SignupScreen> createState() => _SignupScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SignupScreenState extends State<SignupScreen> {
|
class _SignupScreenState extends State<SignupScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
final _invite = TextEditingController();
|
final _invite = TextEditingController();
|
||||||
final _name = TextEditingController();
|
final _name = TextEditingController();
|
||||||
|
late final TabController _tabs;
|
||||||
|
|
||||||
|
bool get _isLogin => _tabs.index == 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabs = TabController(length: 2, vsync: this);
|
||||||
|
_tabs.addListener(() {
|
||||||
|
if (!_tabs.indexIsChanging) setState(() {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_tabs.dispose();
|
||||||
_invite.dispose();
|
_invite.dispose();
|
||||||
_name.dispose();
|
_name.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|
@ -33,6 +48,16 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _submit(SessionState session) async {
|
||||||
|
final invite = _invite.text.trim();
|
||||||
|
final name = _name.text.trim();
|
||||||
|
if (_isLogin) {
|
||||||
|
await session.login(invite, name);
|
||||||
|
} else {
|
||||||
|
await session.signup(invite, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
|
|
@ -40,79 +65,100 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
final scheme = theme.colorScheme;
|
final scheme = theme.colorScheme;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: LayoutBuilder(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
builder: (context, constraints) {
|
||||||
child: Column(
|
return SingleChildScrollView(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
children: [
|
child: ConstrainedBox(
|
||||||
const Spacer(flex: 2),
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
const Center(child: BrandMark(size: 72)),
|
child: IntrinsicHeight(
|
||||||
const SizedBox(height: 20),
|
child: Column(
|
||||||
GradientText(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
'와카뷰',
|
children: [
|
||||||
textAlign: TextAlign.center,
|
const Spacer(flex: 2),
|
||||||
style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800),
|
const Center(child: BrandMark(size: 72)),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
const SizedBox(height: 8),
|
GradientText(
|
||||||
Text(
|
'와카뷰',
|
||||||
'나를 대신해 답하는, 나만의 와카뷰',
|
textAlign: TextAlign.center,
|
||||||
textAlign: TextAlign.center,
|
style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800),
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const Spacer(flex: 1),
|
Text(
|
||||||
Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge),
|
'나를 대신해 답하는, 나만의 와카뷰',
|
||||||
const SizedBox(height: 12),
|
textAlign: TextAlign.center,
|
||||||
_DemoTestPanel(
|
style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
onFill: _fillDemoCredentials,
|
),
|
||||||
onCopy: () async {
|
const Spacer(flex: 1),
|
||||||
await Clipboard.setData(const ClipboardData(text: AppConfig.demoInviteCode));
|
TabBar(
|
||||||
if (!context.mounted) return;
|
controller: _tabs,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
tabs: const [
|
||||||
const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')),
|
Tab(text: '새로 가입'),
|
||||||
);
|
Tab(text: '이미 가입'),
|
||||||
},
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
Text(
|
||||||
controller: _invite,
|
_isLogin
|
||||||
decoration: const InputDecoration(
|
? '초대 코드와 표시 이름으로 다시 로그인합니다'
|
||||||
labelText: '초대 코드',
|
: '초대 코드로 클로즈드 베타에 참여합니다',
|
||||||
prefixIcon: Icon(Icons.vpn_key),
|
style: theme.textTheme.labelLarge,
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.next,
|
const SizedBox(height: 12),
|
||||||
textCapitalization: TextCapitalization.characters,
|
_DemoTestPanel(
|
||||||
),
|
onFill: _fillDemoCredentials,
|
||||||
const SizedBox(height: 12),
|
onCopy: () async {
|
||||||
TextField(
|
await Clipboard.setData(
|
||||||
controller: _name,
|
const ClipboardData(text: AppConfig.demoInviteCode),
|
||||||
decoration: const InputDecoration(
|
);
|
||||||
labelText: '표시 이름',
|
if (!context.mounted) return;
|
||||||
prefixIcon: Icon(Icons.person_outline),
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
),
|
const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')),
|
||||||
textInputAction: TextInputAction.done,
|
);
|
||||||
onSubmitted: (_) {
|
},
|
||||||
if (!session.loading) {
|
),
|
||||||
session.signup(_invite.text.trim(), _name.text.trim());
|
const SizedBox(height: 16),
|
||||||
}
|
TextField(
|
||||||
},
|
controller: _invite,
|
||||||
),
|
decoration: const InputDecoration(
|
||||||
if (session.error != null) ...[
|
labelText: '초대 코드',
|
||||||
const SizedBox(height: 12),
|
prefixIcon: Icon(Icons.vpn_key),
|
||||||
Text(session.error!, style: TextStyle(color: scheme.error)),
|
),
|
||||||
],
|
textInputAction: TextInputAction.next,
|
||||||
const SizedBox(height: 20),
|
textCapitalization: TextCapitalization.characters,
|
||||||
_PressScale(
|
),
|
||||||
child: PrimaryGradientButton(
|
const SizedBox(height: 12),
|
||||||
label: '시작하기',
|
TextField(
|
||||||
loading: session.loading,
|
controller: _name,
|
||||||
onPressed: session.loading
|
decoration: InputDecoration(
|
||||||
? null
|
labelText: '표시 이름',
|
||||||
: () => session.signup(_invite.text.trim(), _name.text.trim()),
|
hintText: _isLogin ? '가입 때 쓴 이름 (DEMO는 필수)' : null,
|
||||||
|
prefixIcon: const Icon(Icons.person_outline),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) {
|
||||||
|
if (!session.loading) _submit(session);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (session.error != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(session.error!, style: TextStyle(color: scheme.error)),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_PressScale(
|
||||||
|
child: PrimaryGradientButton(
|
||||||
|
label: _isLogin ? '로그인' : '시작하기',
|
||||||
|
loading: session.loading,
|
||||||
|
onPressed: session.loading ? null : () => _submit(session),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
);
|
||||||
],
|
},
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,27 @@ class ApiClient {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Q8b — re-issue a session for an existing invite-based account.
|
||||||
|
Future<({User user, String token})> login({
|
||||||
|
required String inviteCode,
|
||||||
|
required String displayName,
|
||||||
|
}) async {
|
||||||
|
final json = await _json('POST', '/auth/login', body: {
|
||||||
|
'invite_code': inviteCode,
|
||||||
|
'display_name': displayName,
|
||||||
|
});
|
||||||
|
final token = json['token'] as String? ?? '';
|
||||||
|
authToken = token;
|
||||||
|
return (
|
||||||
|
user: User(
|
||||||
|
id: json['id'] as int,
|
||||||
|
displayName: json['display_name'] as String? ?? displayName,
|
||||||
|
inviteCode: inviteCode,
|
||||||
|
),
|
||||||
|
token: token,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<ConversationSummary>> listConversations() async {
|
Future<List<ConversationSummary>> listConversations() async {
|
||||||
final obj = await _getObject('/conversations');
|
final obj = await _getObject('/conversations');
|
||||||
final list = (obj['conversations'] as List<dynamic>? ?? const []);
|
final list = (obj['conversations'] as List<dynamic>? ?? const []);
|
||||||
|
|
@ -173,12 +194,28 @@ class ApiClient {
|
||||||
required String text,
|
required String text,
|
||||||
SenderMode senderMode = SenderMode.human,
|
SenderMode senderMode = SenderMode.human,
|
||||||
bool approved = false,
|
bool approved = false,
|
||||||
|
// 초안 무수정 발송률(PRD.md §5, deploy-checklist.md N4-12): 트윈
|
||||||
|
// 승인-발송 경로에서만 AI 초안 원문을 같이 보내 서버가 diff할 수 있게
|
||||||
|
// 한다. null이면(사람 메시지, 또는 초안 기반이 아닌 발송) 아예 필드를
|
||||||
|
// 넣지 않음 — relationshipTier/autonomyLevel 등과 같은 "값 있을 때만
|
||||||
|
// 포함" 관례.
|
||||||
|
String? originalDraftText,
|
||||||
}) async {
|
}) async {
|
||||||
final json = await _json('POST', '/conversations/$conversationId/messages', body: {
|
final json = await _json('POST', '/conversations/$conversationId/messages', body: {
|
||||||
'sender_id': senderId,
|
'sender_id': senderId,
|
||||||
'text': text,
|
'text': text,
|
||||||
'sender_mode': senderMode == SenderMode.twin ? 'twin' : 'human',
|
'sender_mode': senderMode == SenderMode.twin ? 'twin' : 'human',
|
||||||
if (approved) 'approved': true,
|
if (approved) 'approved': true,
|
||||||
|
if (originalDraftText != null) 'original_draft_text': originalDraftText,
|
||||||
|
});
|
||||||
|
return ChatMessage.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "이 답장 나답아요?" 자연스러움 피드백(vision.md 지표, N4-12) — 트윈
|
||||||
|
/// 메시지에만 허용. 재제출 시 서버가 덮어쓴다(에러 아님).
|
||||||
|
Future<ChatMessage> submitMessageFeedback(int messageId, bool natural) async {
|
||||||
|
final json = await _json('POST', '/messages/$messageId/feedback', body: {
|
||||||
|
'natural': natural,
|
||||||
});
|
});
|
||||||
return ChatMessage.fromJson(json);
|
return ChatMessage.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,3 +53,12 @@ List<ChatMessage> mergeNewMessages(
|
||||||
if (toAdd.isEmpty) return List.of(existing);
|
if (toAdd.isEmpty) return List.of(existing);
|
||||||
return [...existing, ...toAdd];
|
return [...existing, ...toAdd];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "이 답장 나답아요?" 피드백(vision.md 지표, deploy-checklist.md N4-12) 순수
|
||||||
|
/// 로직: [messages] 중 이미 평가된(naturalness_rating != null) 메시지 id들만
|
||||||
|
/// 뽑아낸다. chat_screen.dart는 히스토리를 새로 불러올 때마다(또는 캐치업
|
||||||
|
/// 때마다) 이 결과를 세션 상태의 "이미 평가됨" 집합에 합쳐 — 서버가 이미
|
||||||
|
/// 알고 있는 평가는 화면이 다시 그려져도 뱅지/버튼을 다시 보여주지 않는다.
|
||||||
|
Set<int> ratedMessageIdsFrom(List<ChatMessage> messages) {
|
||||||
|
return messages.where((m) => m.naturalnessRating != null).map((m) => m.id).toSet();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,20 @@ import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../config/firebase_web_config.dart';
|
||||||
|
|
||||||
/// Resolves a device push token for core-backend registration.
|
/// Resolves a device push token for core-backend registration.
|
||||||
///
|
///
|
||||||
/// Prefer a real FCM registration token when Firebase is configured
|
/// Prefer a real FCM registration token when Firebase is configured
|
||||||
/// (`google-services.json` on Android). Otherwise fall back to a stable
|
/// (`google-services.json` on Android, dart-define Web config + VAPID on web).
|
||||||
/// `install:` placeholder that the server intentionally skips for delivery.
|
/// Otherwise fall back to a stable `install:` placeholder that the server
|
||||||
|
/// intentionally skips for delivery.
|
||||||
class PushTokenService {
|
class PushTokenService {
|
||||||
PushTokenService({this.storeInstallId, this.readInstallId});
|
PushTokenService({
|
||||||
|
this.storeInstallId,
|
||||||
|
this.readInstallId,
|
||||||
|
FirebaseWebConfig? webConfig,
|
||||||
|
}) : webConfig = webConfig ?? FirebaseWebConfig.fromEnvironment();
|
||||||
|
|
||||||
/// Persist a newly generated install id (KV / secure storage).
|
/// Persist a newly generated install id (KV / secure storage).
|
||||||
final Future<void> Function(String installId)? storeInstallId;
|
final Future<void> Function(String installId)? storeInstallId;
|
||||||
|
|
@ -18,6 +25,9 @@ class PushTokenService {
|
||||||
/// Load a previously stored install id.
|
/// Load a previously stored install id.
|
||||||
final Future<String?> Function()? readInstallId;
|
final Future<String?> Function()? readInstallId;
|
||||||
|
|
||||||
|
/// Injected for tests; production uses [FirebaseWebConfig.fromEnvironment].
|
||||||
|
final FirebaseWebConfig webConfig;
|
||||||
|
|
||||||
Future<({String token, String platform, bool isFcm})> resolve() async {
|
Future<({String token, String platform, bool isFcm})> resolve() async {
|
||||||
final platform = _platformLabel();
|
final platform = _platformLabel();
|
||||||
final fcm = await _tryFirebaseToken();
|
final fcm = await _tryFirebaseToken();
|
||||||
|
|
@ -29,8 +39,9 @@ class PushTokenService {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _tryFirebaseToken() async {
|
Future<String?> _tryFirebaseToken() async {
|
||||||
// Web push needs a separate Firebase web config + VAPID; skip until provided.
|
if (kIsWeb) {
|
||||||
if (kIsWeb) return null;
|
return _tryFirebaseWebToken();
|
||||||
|
}
|
||||||
if (defaultTargetPlatform != TargetPlatform.android &&
|
if (defaultTargetPlatform != TargetPlatform.android &&
|
||||||
defaultTargetPlatform != TargetPlatform.iOS) {
|
defaultTargetPlatform != TargetPlatform.iOS) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -50,6 +61,31 @@ class PushTokenService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// N4-W4 — Web Push via FCM + VAPID (build-time dart-define secrets).
|
||||||
|
Future<String?> _tryFirebaseWebToken() async {
|
||||||
|
if (!webConfig.isReady) {
|
||||||
|
debugPrint(
|
||||||
|
'FCM web skipped: FIREBASE_* / VAPID dart-defines not set '
|
||||||
|
'(see docs/fcm-setup.md Web)',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final options = webConfig.options!;
|
||||||
|
if (Firebase.apps.isEmpty) {
|
||||||
|
await Firebase.initializeApp(options: options);
|
||||||
|
}
|
||||||
|
final messaging = FirebaseMessaging.instance;
|
||||||
|
await messaging.requestPermission(alert: true, badge: true, sound: true);
|
||||||
|
final token = await messaging.getToken(vapidKey: webConfig.vapidKey);
|
||||||
|
if (token == null || token.isEmpty) return null;
|
||||||
|
return token;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('FCM web token unavailable (using install placeholder): $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> _ensureInstallId() async {
|
Future<String> _ensureInstallId() async {
|
||||||
final existing = await readInstallId?.call();
|
final existing = await readInstallId?.call();
|
||||||
if (existing != null && existing.isNotEmpty) return existing;
|
if (existing != null && existing.isNotEmpty) return existing;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import '../db/app_database.dart';
|
import '../db/app_database.dart';
|
||||||
import 'snooze_notification_service.dart';
|
import 'snooze_notification_service.dart';
|
||||||
|
import 'snooze_service.dart';
|
||||||
|
|
||||||
/// 답장 마감 알림(roadmap.md §2.7-F) 오케스트레이션 — 온디바이스 DB(스누즈 시각 저장)와
|
/// 답장 마감 알림(roadmap.md §2.7-F) 오케스트레이션 — 온디바이스 DB(스누즈 시각 저장)와
|
||||||
/// 알림 스케줄러(OS 로컬 알림) 호출을 한 곳에 묶는다. `chat_screen.dart`/
|
/// 알림 스케줄러(OS 로컬 알림 / 웹 Notification) 호출을 한 곳에 묶는다.
|
||||||
/// `conversation_list_screen.dart`가 공유해서 쓴다.
|
/// `chat_screen.dart` / `conversation_list_screen.dart`가 공유해서 쓴다.
|
||||||
///
|
///
|
||||||
/// 서버 호출은 전혀 없다 — AGENTS.md "Tone/style learning: on-device first"와 동일한
|
/// 서버 호출은 전혀 없다 — AGENTS.md "Tone/style learning: on-device first"와 동일한
|
||||||
/// 이유로, 이 리마인드는 순전히 이 기기·이 사용자만을 위한 것이라 서버에 올라갈 이유가
|
/// 이유로, 이 리마인드는 순전히 이 기기·이 사용자만을 위한 것이라 서버에 올라갈 이유가
|
||||||
|
|
@ -36,4 +37,18 @@ class SnoozeController {
|
||||||
await _db.clearSnooze(conversationId);
|
await _db.clearSnooze(conversationId);
|
||||||
await _scheduler.cancelReminder(conversationId);
|
await _scheduler.cancelReminder(conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Web N4-W3: when the tab/app resumes, surface past-due snoozes once via
|
||||||
|
/// [SnoozeNotificationScheduler.showImmediate] (no-op on native).
|
||||||
|
Future<void> notifyPastDueOnFocus({DateTime? now}) async {
|
||||||
|
final all = await loadAllSnoozes();
|
||||||
|
final t = now ?? DateTime.now();
|
||||||
|
for (final e in all.entries) {
|
||||||
|
if (!isSnoozePastDue(t, e.value)) continue;
|
||||||
|
await _scheduler.showImmediate(
|
||||||
|
title: '답장 마감',
|
||||||
|
body: '대화 #${e.key} — 답장할 시간입니다',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
/// 답장 마감 알림(roadmap.md §2.7-F)의 실제 OS 로컬 알림 스케줄러 — 플랫폼별 구현을
|
/// 답장 마감 알림(roadmap.md §2.7-F)의 실제 OS 로컬 알림 스케줄러 — 플랫폼별 구현을
|
||||||
/// 조건부 export로 분리한다 (`../db/app_database.dart`와 동일한 패턴).
|
/// 조건부 export로 분리한다 (`../db/app_database.dart`와 동일한 패턴).
|
||||||
///
|
///
|
||||||
/// Flutter Web은 `flutter_local_notifications`가 공식 지원하지 않는 플랫폼이고, 이
|
/// Flutter Web은 `flutter_local_notifications` 대신 브라우저 Notification API +
|
||||||
/// 프로젝트의 웹 빌드는 애초에 프리뷰 서피스일 뿐 릴리스 타깃이 아니다
|
/// in-tab Timer를 쓴다 (`snooze_notification_service_web.dart`, docs/web-upgrade N4-W3).
|
||||||
/// (`app_database_web.dart` 주석 참고) — 웹에서는 스케줄링 호출이 조용히 no-op 처리된다.
|
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'snooze_notification_service_native.dart'
|
export 'snooze_notification_service_native.dart'
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ abstract class SnoozeNotificationScheduler {
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> cancelReminder(int conversationId);
|
Future<void> cancelReminder(int conversationId);
|
||||||
|
|
||||||
|
/// Immediate notification (web past-due catch-up). Native uses OS schedule only.
|
||||||
|
Future<void> showImmediate({required String title, required String body}) async {}
|
||||||
|
|
||||||
|
Future<bool> ensurePermission() async => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
|
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
|
||||||
|
|
@ -77,4 +82,12 @@ class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
|
||||||
Future<void> cancelReminder(int conversationId) async {
|
Future<void> cancelReminder(int conversationId) async {
|
||||||
await _plugin.cancel(id: conversationId);
|
await _plugin.cancel(id: conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> showImmediate({required String title, required String body}) async {
|
||||||
|
// Native path relies on zonedSchedule; no separate immediate channel here.
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> ensurePermission() async => true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
/// Web 빌드 스텁 — `flutter_local_notifications`는 웹을 지원하지 않고, 이 프로젝트의
|
import 'dart:async';
|
||||||
/// 웹 빌드는 프리뷰 서피스일 뿐 릴리스 타깃이 아니다(`app_database_web.dart` 주석 참고).
|
// Web-only conditional import target (see snooze_notification_service.dart).
|
||||||
/// 스누즈 저장(`AppDatabase`)은 웹에서도 동작하지만, 실제 OS 알림 스케줄링만 no-op —
|
// ignore: avoid_web_libraries_in_flutter, deprecated_member_use
|
||||||
/// 대화 목록/채팅방의 "마감 지남" 배지·배너는 이 스텁과 무관하게 그대로 동작한다.
|
import 'dart:html' as html;
|
||||||
|
|
||||||
|
/// Web reminder scheduler (docs/web-upgrade.md N4-W3).
|
||||||
|
///
|
||||||
|
/// Uses the browser Notification API when permission is granted. Scheduling is
|
||||||
|
/// in-tab [Timer]-based (no SW alarm) — if the tab is closed before [at], the
|
||||||
|
/// in-app past-due badge/banner remains the source of truth after reopen.
|
||||||
abstract class SnoozeNotificationScheduler {
|
abstract class SnoozeNotificationScheduler {
|
||||||
Future<void> scheduleReminder({
|
Future<void> scheduleReminder({
|
||||||
required int conversationId,
|
required int conversationId,
|
||||||
|
|
@ -11,17 +17,68 @@ abstract class SnoozeNotificationScheduler {
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> cancelReminder(int conversationId);
|
Future<void> cancelReminder(int conversationId);
|
||||||
|
|
||||||
|
Future<void> showImmediate({required String title, required String body});
|
||||||
|
|
||||||
|
Future<bool> ensurePermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
|
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
|
||||||
|
final Map<int, Timer> _timers = {};
|
||||||
|
|
||||||
|
bool get permissionGranted {
|
||||||
|
try {
|
||||||
|
return html.Notification.permission == 'granted';
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> ensurePermission() async {
|
||||||
|
try {
|
||||||
|
final current = html.Notification.permission;
|
||||||
|
if (current == 'granted') return true;
|
||||||
|
if (current == 'denied') return false;
|
||||||
|
final result = await html.Notification.requestPermission();
|
||||||
|
return result == 'granted';
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> showImmediate({required String title, required String body}) async {
|
||||||
|
try {
|
||||||
|
if (!permissionGranted) return;
|
||||||
|
html.Notification(title, body: body);
|
||||||
|
} catch (_) {
|
||||||
|
// Unsupported browser — ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> scheduleReminder({
|
Future<void> scheduleReminder({
|
||||||
required int conversationId,
|
required int conversationId,
|
||||||
required String title,
|
required String title,
|
||||||
required String body,
|
required String body,
|
||||||
required DateTime at,
|
required DateTime at,
|
||||||
}) async {}
|
}) async {
|
||||||
|
await ensurePermission();
|
||||||
|
await cancelReminder(conversationId);
|
||||||
|
final delay = at.toUtc().difference(DateTime.now().toUtc());
|
||||||
|
if (delay.isNegative || delay == Duration.zero) {
|
||||||
|
await showImmediate(title: title, body: body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_timers[conversationId] = Timer(delay, () {
|
||||||
|
_timers.remove(conversationId);
|
||||||
|
showImmediate(title: title, body: body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> cancelReminder(int conversationId) async {}
|
Future<void> cancelReminder(int conversationId) async {
|
||||||
|
_timers.remove(conversationId)?.cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,20 @@ import '../config.dart';
|
||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
import 'message_sync.dart';
|
import 'message_sync.dart';
|
||||||
|
|
||||||
|
/// UI-facing WebSocket link state for a conversation (docs/web-upgrade.md N4-W2).
|
||||||
|
enum WsLinkState {
|
||||||
|
connecting,
|
||||||
|
live,
|
||||||
|
reconnecting,
|
||||||
|
}
|
||||||
|
|
||||||
/// Conversation-scoped WebSocket relay (`GET /ws/conversations/:id`).
|
/// Conversation-scoped WebSocket relay (`GET /ws/conversations/:id`).
|
||||||
///
|
///
|
||||||
/// 오프라인 큐 캐치업(roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
|
/// 오프라인 큐 캐치업(roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
|
||||||
/// 소켓이 끊기면(백그라운드, 네트워크 hiccup 등) `onDone`/`onError`에서 그냥
|
/// 소켓이 끊기면(백그라운드, 네트워크 hiccup 등) `onDone`/`onError`에서 그냥
|
||||||
/// 멈추는 게 아니라 [nextReconnectDelay]로 계산한 backoff 후 재연결을 시도한다.
|
/// 멈추는 게 아니라 [nextReconnectDelay]로 계산한 backoff 후 재연결을 시도한다.
|
||||||
/// 재연결에 성공하면 [reconnects] 스트림으로 신호를 보내 — 화면(chat_screen.dart)이
|
/// 재연결에 성공하면 [reconnects] 스트림으로 신호를 보내 — 화면(chat_screen.dart)이
|
||||||
/// 그 신호를 받아 REST `since_id` 캐치업을 트리거할 수 있게 한다. 실제 소켓
|
/// 그 신호를 받아 REST `since_id` 캐치업을 트리거할 수 있게 한다.
|
||||||
/// 타이밍(진짜 네트워크 단절 등)은 단위 테스트로 결정적으로 검증할 수 없어
|
|
||||||
/// 실기기 QA가 필요하다 — [nextReconnectDelay] 자체의 계산 로직만 순수
|
|
||||||
/// 함수로 분리해 테스트했다(mobile/test/message_sync_test.dart).
|
|
||||||
class ConversationSocket {
|
class ConversationSocket {
|
||||||
ConversationSocket(this.conversationId);
|
ConversationSocket(this.conversationId);
|
||||||
|
|
||||||
|
|
@ -25,10 +29,12 @@ class ConversationSocket {
|
||||||
StreamSubscription? _channelSub;
|
StreamSubscription? _channelSub;
|
||||||
final _controller = StreamController<Map<String, dynamic>>.broadcast();
|
final _controller = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
final _reconnectController = StreamController<void>.broadcast();
|
final _reconnectController = StreamController<void>.broadcast();
|
||||||
|
final _linkController = StreamController<WsLinkState>.broadcast();
|
||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
int _reconnectAttempt = 0;
|
int _reconnectAttempt = 0;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
bool _everConnected = false;
|
bool _everConnected = false;
|
||||||
|
WsLinkState _linkState = WsLinkState.connecting;
|
||||||
|
|
||||||
Stream<Map<String, dynamic>> get events => _controller.stream;
|
Stream<Map<String, dynamic>> get events => _controller.stream;
|
||||||
|
|
||||||
|
|
@ -37,18 +43,38 @@ class ConversationSocket {
|
||||||
/// 히스토리를 로드하므로, 이 신호는 "끊겼다가 다시 붙었을 때"만 의미가 있다).
|
/// 히스토리를 로드하므로, 이 신호는 "끊겼다가 다시 붙었을 때"만 의미가 있다).
|
||||||
Stream<void> get reconnects => _reconnectController.stream;
|
Stream<void> get reconnects => _reconnectController.stream;
|
||||||
|
|
||||||
|
/// connecting → live → reconnecting → live …
|
||||||
|
Stream<WsLinkState> get linkStates => _linkController.stream;
|
||||||
|
|
||||||
|
WsLinkState get linkState => _linkState;
|
||||||
|
|
||||||
|
void _setLinkState(WsLinkState next) {
|
||||||
|
if (_disposed || _linkState == next) return;
|
||||||
|
_linkState = next;
|
||||||
|
if (!_linkController.isClosed) _linkController.add(next);
|
||||||
|
}
|
||||||
|
|
||||||
void connect() {
|
void connect() {
|
||||||
final wasReconnect = _everConnected;
|
final wasReconnect = _everConnected;
|
||||||
|
_setLinkState(wasReconnect ? WsLinkState.reconnecting : WsLinkState.connecting);
|
||||||
// 이전 연결이 onError로 끊겼을 때는 cancelOnError:false라 구독이 아직
|
// 이전 연결이 onError로 끊겼을 때는 cancelOnError:false라 구독이 아직
|
||||||
// 살아 있을 수 있다 — 새 채널을 만들기 전에 정리해 leak을 막는다.
|
// 살아 있을 수 있다 — 새 채널을 만들기 전에 정리해 leak을 막는다.
|
||||||
_channelSub?.cancel();
|
_channelSub?.cancel();
|
||||||
final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId');
|
final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId');
|
||||||
_channel = WebSocketChannel.connect(uri);
|
final channel = WebSocketChannel.connect(uri);
|
||||||
|
_channel = channel;
|
||||||
_everConnected = true;
|
_everConnected = true;
|
||||||
_channelSub = _channel!.stream.listen(
|
channel.ready.then((_) {
|
||||||
|
if (_disposed) return;
|
||||||
|
_setLinkState(WsLinkState.live);
|
||||||
|
}).catchError((Object _) {
|
||||||
|
// onError / onDone will schedule reconnect.
|
||||||
|
});
|
||||||
|
_channelSub = channel.stream.listen(
|
||||||
(raw) {
|
(raw) {
|
||||||
// 데이터가 왔다는 건 연결이 살아 있다는 뜻 — backoff를 리셋한다.
|
// 데이터가 왔다는 건 연결이 살아 있다는 뜻 — backoff를 리셋한다.
|
||||||
_reconnectAttempt = 0;
|
_reconnectAttempt = 0;
|
||||||
|
_setLinkState(WsLinkState.live);
|
||||||
if (raw is! String) return;
|
if (raw is! String) return;
|
||||||
final decoded = jsonDecode(raw);
|
final decoded = jsonDecode(raw);
|
||||||
if (decoded is Map<String, dynamic>) {
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
|
@ -70,6 +96,7 @@ class ConversationSocket {
|
||||||
|
|
||||||
void _scheduleReconnect() {
|
void _scheduleReconnect() {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
_setLinkState(WsLinkState.reconnecting);
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
final delay = nextReconnectDelay(_reconnectAttempt);
|
final delay = nextReconnectDelay(_reconnectAttempt);
|
||||||
_reconnectAttempt++;
|
_reconnectAttempt++;
|
||||||
|
|
@ -100,5 +127,18 @@ class ConversationSocket {
|
||||||
await _channel?.sink.close();
|
await _channel?.sink.close();
|
||||||
await _controller.close();
|
await _controller.close();
|
||||||
await _reconnectController.close();
|
await _reconnectController.close();
|
||||||
|
await _linkController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short Korean label for chat chrome (N4-W2).
|
||||||
|
String wsLinkStateLabel(WsLinkState state) {
|
||||||
|
switch (state) {
|
||||||
|
case WsLinkState.connecting:
|
||||||
|
return '연결 중…';
|
||||||
|
case WsLinkState.live:
|
||||||
|
return '실시간';
|
||||||
|
case WsLinkState.reconnecting:
|
||||||
|
return '다시 연결 중…';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,8 @@ class SessionState extends ChangeNotifier {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Linux CI / hosts without libsqlcipher.so — fall back to memory so the
|
// Linux CI / hosts without libsqlcipher.so — fall back to memory so the
|
||||||
// app still boots; Android release path uses SQLCipher.
|
// app still boots; Android release path uses SQLCipher.
|
||||||
// Web uses an in-memory stub via conditional import (no FFI).
|
// Web uses SharedPreferences-backed AppDatabase via conditional import
|
||||||
|
// (docs/web-upgrade.md N4-W1); this catch is for native open failures.
|
||||||
debugPrint('Encrypted DB unavailable ($e); using in-memory fallback');
|
debugPrint('Encrypted DB unavailable ($e); using in-memory fallback');
|
||||||
localDbEncrypted = false;
|
localDbEncrypted = false;
|
||||||
return AppDatabase.memory();
|
return AppDatabase.memory();
|
||||||
|
|
@ -109,15 +110,7 @@ class SessionState extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
||||||
user = result.user;
|
await _persistAuth(result.user, result.token, resetToneOnboarding: true);
|
||||||
toneOnboardingDone = false;
|
|
||||||
_db ??= await _openDb();
|
|
||||||
await _db!.setBoolKv(_kToneDone, false);
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setInt(_kUserId, result.user.id);
|
|
||||||
await prefs.setString(_kDisplayName, result.user.displayName);
|
|
||||||
await prefs.setString(_kInvite, result.user.inviteCode);
|
|
||||||
await prefs.setString(_kToken, result.token);
|
|
||||||
await _registerDeviceTokenBestEffort();
|
await _registerDeviceTokenBestEffort();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
error = '가입 실패 (${e.statusCode}): ${e.body}';
|
error = '가입 실패 (${e.statusCode}): ${e.body}';
|
||||||
|
|
@ -129,6 +122,71 @@ class SessionState extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Q8b — already registered: invite code + display name → new session.
|
||||||
|
Future<void> login(String inviteCode, String displayName) async {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final result = await _api.login(inviteCode: inviteCode, displayName: displayName);
|
||||||
|
await _persistAuth(result.user, result.token, resetToneOnboarding: false);
|
||||||
|
await _registerDeviceTokenBestEffort();
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
error = '로그인 실패 (${e.statusCode}): ${e.body}';
|
||||||
|
} catch (e) {
|
||||||
|
error = e.toString();
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistAuth(User u, String token, {required bool resetToneOnboarding}) async {
|
||||||
|
user = u;
|
||||||
|
_api.authToken = token;
|
||||||
|
_db ??= await _openDb();
|
||||||
|
if (resetToneOnboarding) {
|
||||||
|
toneOnboardingDone = false;
|
||||||
|
await _db!.setBoolKv(_kToneDone, false);
|
||||||
|
} else {
|
||||||
|
toneOnboardingDone = await _db!.getBoolKv(_kToneDone);
|
||||||
|
styleExamples = await _db!.loadToneSamples();
|
||||||
|
}
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_kUserId, u.id);
|
||||||
|
await prefs.setString(_kDisplayName, u.displayName);
|
||||||
|
await prefs.setString(_kInvite, u.inviteCode);
|
||||||
|
await prefs.setString(_kToken, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Q8c — revoke current server session when possible, then clear local auth.
|
||||||
|
Future<void> logout() async {
|
||||||
|
final u = user;
|
||||||
|
final token = _api.authToken;
|
||||||
|
if (u != null && token != null && token.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final sessions = await _api.listSessions(u.id);
|
||||||
|
for (final s in sessions) {
|
||||||
|
if (s['is_current'] == true && s['id'] is num) {
|
||||||
|
await _api.revokeSession(u.id, (s['id'] as num).toInt());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('logout revoke skipped: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user = null;
|
||||||
|
error = null;
|
||||||
|
_api.authToken = null;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_kUserId);
|
||||||
|
await prefs.remove(_kDisplayName);
|
||||||
|
await prefs.remove(_kInvite);
|
||||||
|
await prefs.remove(_kToken);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> saveToneSamples(List<String> samples, {bool markDone = true}) async {
|
Future<void> saveToneSamples(List<String> samples, {bool markDone = true}) async {
|
||||||
final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||||
styleExamples = cleaned;
|
styleExamples = cleaned;
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,18 @@ class MessageBubble extends StatelessWidget {
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.isMine,
|
required this.isMine,
|
||||||
this.onRetract,
|
this.onRetract,
|
||||||
|
this.alreadyRated = false,
|
||||||
|
this.onFeedback,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ChatMessage message;
|
final ChatMessage message;
|
||||||
final bool isMine;
|
final bool isMine;
|
||||||
final VoidCallback? onRetract;
|
final VoidCallback? onRetract;
|
||||||
|
// "이 답장 나답아요?" 피드백(vision.md 지표, deploy-checklist.md N4-12).
|
||||||
|
// alreadyRated면(이번 세션에 이미 탭했거나 서버에 기록이 있으면) 버튼 대신
|
||||||
|
// 조용한 완료 표시만 보여주고 다시 탭할 수 없게 한다 — 재평가 없음.
|
||||||
|
final bool alreadyRated;
|
||||||
|
final void Function(bool natural)? onFeedback;
|
||||||
|
|
||||||
static String _time(DateTime dt) {
|
static String _time(DateTime dt) {
|
||||||
final local = dt.toLocal();
|
final local = dt.toLocal();
|
||||||
|
|
@ -131,6 +138,49 @@ class MessageBubble extends StatelessWidget {
|
||||||
label: const Text('되돌리기'),
|
label: const Text('되돌리기'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (twin && !retracted && onFeedback != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2, right: 4, left: 4),
|
||||||
|
child: alreadyRated
|
||||||
|
? Text(
|
||||||
|
'평가 고마워요',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'이 답장 나답아요?',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '나다워요',
|
||||||
|
onPressed: () => onFeedback!(true),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||||
|
iconSize: 16,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
icon: const Icon(Icons.thumb_up_outlined),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '나답지 않아요',
|
||||||
|
onPressed: () => onFeedback!(false),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||||
|
iconSize: 16,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
icon: const Icon(Icons.thumb_down_outlined),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'web_connectivity.dart';
|
||||||
|
|
||||||
|
/// Thin bar when the browser reports offline (docs/web-upgrade.md N4-W5).
|
||||||
|
/// API/WS still require a network — this only sets expectation.
|
||||||
|
class OfflineBanner extends StatefulWidget {
|
||||||
|
const OfflineBanner({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OfflineBanner> createState() => _OfflineBannerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfflineBannerState extends State<OfflineBanner> {
|
||||||
|
bool _offline = false;
|
||||||
|
StreamSubscription? _onlineSub;
|
||||||
|
StreamSubscription? _offlineSub;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (!kIsWeb) return;
|
||||||
|
_offline = webNavigatorOffline;
|
||||||
|
_onlineSub = listenWebOnline(() {
|
||||||
|
if (mounted) setState(() => _offline = false);
|
||||||
|
});
|
||||||
|
_offlineSub = listenWebOffline(() {
|
||||||
|
if (mounted) setState(() => _offline = true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_onlineSub?.cancel();
|
||||||
|
_offlineSub?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!kIsWeb || !_offline) return const SizedBox.shrink();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Material(
|
||||||
|
color: theme.colorScheme.errorContainer,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
'오프라인입니다. 메시지·로그인에는 인터넷 연결이 필요합니다.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export 'web_connectivity_stub.dart'
|
||||||
|
if (dart.library.html) 'web_connectivity_web.dart';
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
bool get webNavigatorOffline => false;
|
||||||
|
|
||||||
|
StreamSubscription<void>? listenWebOnline(void Function() onOnline) => null;
|
||||||
|
|
||||||
|
StreamSubscription<void>? listenWebOffline(void Function() onOffline) => null;
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import 'dart:async';
|
||||||
|
// ignore: avoid_web_libraries_in_flutter, deprecated_member_use
|
||||||
|
import 'dart:html' as html;
|
||||||
|
|
||||||
|
bool get webNavigatorOffline => !(html.window.navigator.onLine ?? true);
|
||||||
|
|
||||||
|
StreamSubscription<void>? listenWebOnline(void Function() onOnline) {
|
||||||
|
return html.window.onOnline.listen((_) => onOnline());
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamSubscription<void>? listenWebOffline(void Function() onOffline) {
|
||||||
|
return html.window.onOffline.listen((_) => onOffline());
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../state/session_state.dart';
|
||||||
|
|
||||||
|
/// One-time dismissible tips for the web demo/preview surface (N4-W2).
|
||||||
|
///
|
||||||
|
/// Hidden on non-web builds. Dismissal is stored in the local DB KV so it
|
||||||
|
/// survives hard refresh after W1.
|
||||||
|
class WebPreviewBanner extends StatefulWidget {
|
||||||
|
const WebPreviewBanner({super.key});
|
||||||
|
|
||||||
|
static const dismissKvKey = 'web_preview_tips_dismissed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WebPreviewBanner> createState() => _WebPreviewBannerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebPreviewBannerState extends State<WebPreviewBanner> {
|
||||||
|
bool _visible = false;
|
||||||
|
bool _loaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (kIsWeb) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||||
|
} else {
|
||||||
|
_loaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
final session = context.read<SessionState>();
|
||||||
|
final db = session.db;
|
||||||
|
final dismissed = db == null
|
||||||
|
? false
|
||||||
|
: await db.getBoolKv(WebPreviewBanner.dismissKvKey);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_visible = !dismissed;
|
||||||
|
_loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dismiss() async {
|
||||||
|
setState(() => _visible = false);
|
||||||
|
final db = context.read<SessionState>().db;
|
||||||
|
await db?.setBoolKv(WebPreviewBanner.dismissKvKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!kIsWeb || !_loaded || !_visible) return const SizedBox.shrink();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Material(
|
||||||
|
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.55),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.web_asset, size: 20, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'웹 프리뷰입니다. 말투·설정은 새로고침 후에도 이 브라우저에 남습니다. '
|
||||||
|
'브라우저 알림·홈 화면 추가는 곧 이어집니다.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(height: 1.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '닫기',
|
||||||
|
onPressed: _dismiss,
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs inside the Flutter build stage. Reads FIREBASE_* from the environment
|
||||||
|
# (set via Dockerfile ENV from build-args) so secret values are not expanded
|
||||||
|
# into the Dockerfile RUN instruction / build logs.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CORE_API_BASE="${CORE_API_BASE:-https://msn.iykyka.com}"
|
||||||
|
FIREBASE_API_KEY="${FIREBASE_API_KEY:-}"
|
||||||
|
FIREBASE_AUTH_DOMAIN="${FIREBASE_AUTH_DOMAIN:-}"
|
||||||
|
FIREBASE_PROJECT_ID="${FIREBASE_PROJECT_ID:-}"
|
||||||
|
FIREBASE_STORAGE_BUCKET="${FIREBASE_STORAGE_BUCKET:-}"
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID="${FIREBASE_MESSAGING_SENDER_ID:-}"
|
||||||
|
FIREBASE_APP_ID="${FIREBASE_APP_ID:-}"
|
||||||
|
FIREBASE_VAPID_KEY="${FIREBASE_VAPID_KEY:-}"
|
||||||
|
|
||||||
|
if [[ -n "${FIREBASE_API_KEY}" && -n "${FIREBASE_APP_ID}" && \
|
||||||
|
-n "${FIREBASE_MESSAGING_SENDER_ID}" && -n "${FIREBASE_PROJECT_ID}" ]]; then
|
||||||
|
AUTH_DOMAIN="${FIREBASE_AUTH_DOMAIN:-${FIREBASE_PROJECT_ID}.firebaseapp.com}"
|
||||||
|
STORAGE_BUCKET="${FIREBASE_STORAGE_BUCKET:-${FIREBASE_PROJECT_ID}.appspot.com}"
|
||||||
|
# Detect common misconfiguration: Android app id used for web build.
|
||||||
|
if [[ "${FIREBASE_APP_ID}" == *":android:"* ]]; then
|
||||||
|
echo "WARN: FIREBASE_APP_ID looks like an Android app id (:android:)."
|
||||||
|
echo "WARN: Web Push needs a Firebase Web app id (:web:). Token registration may fail."
|
||||||
|
fi
|
||||||
|
sed \
|
||||||
|
-e "s|__FIREBASE_API_KEY__|${FIREBASE_API_KEY}|g" \
|
||||||
|
-e "s|__FIREBASE_AUTH_DOMAIN__|${AUTH_DOMAIN}|g" \
|
||||||
|
-e "s|__FIREBASE_PROJECT_ID__|${FIREBASE_PROJECT_ID}|g" \
|
||||||
|
-e "s|__FIREBASE_STORAGE_BUCKET__|${STORAGE_BUCKET}|g" \
|
||||||
|
-e "s|__FIREBASE_MESSAGING_SENDER_ID__|${FIREBASE_MESSAGING_SENDER_ID}|g" \
|
||||||
|
-e "s|__FIREBASE_APP_ID__|${FIREBASE_APP_ID}|g" \
|
||||||
|
web/firebase-messaging-sw.js.template > web/firebase-messaging-sw.js
|
||||||
|
echo "firebase-messaging-sw.js: populated for project ${FIREBASE_PROJECT_ID}"
|
||||||
|
else
|
||||||
|
echo "firebase-messaging-sw.js: stub (FIREBASE_* build-args empty)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
flutter build web --release \
|
||||||
|
--dart-define="CORE_API_BASE=${CORE_API_BASE}" \
|
||||||
|
--dart-define="FIREBASE_API_KEY=${FIREBASE_API_KEY}" \
|
||||||
|
--dart-define="FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN}" \
|
||||||
|
--dart-define="FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID}" \
|
||||||
|
--dart-define="FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET}" \
|
||||||
|
--dart-define="FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID}" \
|
||||||
|
--dart-define="FIREBASE_APP_ID=${FIREBASE_APP_ID}" \
|
||||||
|
--dart-define="FIREBASE_VAPID_KEY=${FIREBASE_VAPID_KEY}"
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ykavu_mobile/config/firebase_web_config.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('isReady false when any required field is empty', () {
|
||||||
|
expect(
|
||||||
|
const FirebaseWebConfig(
|
||||||
|
apiKey: 'k',
|
||||||
|
appId: 'a',
|
||||||
|
messagingSenderId: '1',
|
||||||
|
projectId: 'p',
|
||||||
|
vapidKey: '',
|
||||||
|
).isReady,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
const FirebaseWebConfig(
|
||||||
|
apiKey: '',
|
||||||
|
appId: 'a',
|
||||||
|
messagingSenderId: '1',
|
||||||
|
projectId: 'p',
|
||||||
|
vapidKey: 'v',
|
||||||
|
).isReady,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isReady true and options fill default domains', () {
|
||||||
|
const cfg = FirebaseWebConfig(
|
||||||
|
apiKey: 'api',
|
||||||
|
appId: '1:123:web:abc',
|
||||||
|
messagingSenderId: '123',
|
||||||
|
projectId: 'iykyka',
|
||||||
|
vapidKey: 'vapid-public',
|
||||||
|
);
|
||||||
|
expect(cfg.isReady, isTrue);
|
||||||
|
final opts = cfg.options!;
|
||||||
|
expect(opts.apiKey, 'api');
|
||||||
|
expect(opts.projectId, 'iykyka');
|
||||||
|
expect(opts.authDomain, 'iykyka.firebaseapp.com');
|
||||||
|
expect(opts.storageBucket, 'iykyka.appspot.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit authDomain and storageBucket win over defaults', () {
|
||||||
|
const cfg = FirebaseWebConfig(
|
||||||
|
apiKey: 'api',
|
||||||
|
appId: '1:123:web:abc',
|
||||||
|
messagingSenderId: '123',
|
||||||
|
projectId: 'iykyka',
|
||||||
|
vapidKey: 'vapid-public',
|
||||||
|
authDomain: 'custom.example.com',
|
||||||
|
storageBucket: 'iykyka.firebasestorage.app',
|
||||||
|
);
|
||||||
|
final opts = cfg.options!;
|
||||||
|
expect(opts.authDomain, 'custom.example.com');
|
||||||
|
expect(opts.storageBucket, 'iykyka.firebasestorage.app');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromEnvironment without defines is not ready', () {
|
||||||
|
expect(FirebaseWebConfig.fromEnvironment().isReady, isFalse);
|
||||||
|
expect(FirebaseWebConfig.fromEnvironment().options, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:ykavu_mobile/models/models.dart';
|
import 'package:ykavu_mobile/models/models.dart';
|
||||||
import 'package:ykavu_mobile/services/message_sync.dart';
|
import 'package:ykavu_mobile/services/message_sync.dart';
|
||||||
|
|
||||||
ChatMessage _msg(int id, {bool retracted = false}) => ChatMessage(
|
ChatMessage _msg(int id, {bool retracted = false, bool? naturalnessRating}) => ChatMessage(
|
||||||
id: id,
|
id: id,
|
||||||
conversationId: 1,
|
conversationId: 1,
|
||||||
senderId: 1,
|
senderId: 1,
|
||||||
|
|
@ -10,6 +10,7 @@ ChatMessage _msg(int id, {bool retracted = false}) => ChatMessage(
|
||||||
text: 'msg-$id',
|
text: 'msg-$id',
|
||||||
retracted: retracted,
|
retracted: retracted,
|
||||||
createdAt: DateTime(2026, 8, 3),
|
createdAt: DateTime(2026, 8, 3),
|
||||||
|
naturalnessRating: naturalnessRating,
|
||||||
);
|
);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -82,4 +83,27 @@ void main() {
|
||||||
expect(merged.length, 2);
|
expect(merged.length, 2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// "이 답장 나답아요?" 피드백(vision.md 지표, deploy-checklist.md N4-12):
|
||||||
|
// chat_screen.dart는 이 순수 함수의 결과를 세션 상태의 "이미 평가됨" id
|
||||||
|
// 집합에 합쳐서, 히스토리를 다시 불러와도(화면 재진입, 캐치업 등) 이미
|
||||||
|
// 평가된 메시지 위에 뱃지/버튼을 다시 보여주지 않는다.
|
||||||
|
group('ratedMessageIdsFrom', () {
|
||||||
|
test('picks up ids with a non-null naturalness rating (true or false)', () {
|
||||||
|
final messages = [
|
||||||
|
_msg(1, naturalnessRating: true),
|
||||||
|
_msg(2, naturalnessRating: false),
|
||||||
|
_msg(3),
|
||||||
|
];
|
||||||
|
expect(ratedMessageIdsFrom(messages), {1, 2});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty list -> empty set', () {
|
||||||
|
expect(ratedMessageIdsFrom(const []), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no rated messages -> empty set', () {
|
||||||
|
expect(ratedMessageIdsFrom([_msg(1), _msg(2)]), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,4 +74,37 @@ void main() {
|
||||||
});
|
});
|
||||||
expect(withoutOverride.autonomyLevel, isNull);
|
expect(withoutOverride.autonomyLevel, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 초안 무수정 발송률 + 자연스러움 피드백(PRD.md §5, deploy-checklist.md
|
||||||
|
// N4-12): 서버가 null을 보낼 수 있는 두 필드가 각각 null/true/false를
|
||||||
|
// 그대로 통과시키는지 확인.
|
||||||
|
test('ChatMessage parses draft_edited and naturalness_rating when present', () {
|
||||||
|
final withBoth = ChatMessage.fromJson({
|
||||||
|
'id': 10,
|
||||||
|
'conversation_id': 1,
|
||||||
|
'sender_id': 1,
|
||||||
|
'sender_mode': 'twin',
|
||||||
|
'text': 'ㅇㅇ 좋아',
|
||||||
|
'retracted': false,
|
||||||
|
'draft_edited': true,
|
||||||
|
'naturalness_rating': false,
|
||||||
|
'created_at': '2026-08-03T00:00:00Z',
|
||||||
|
});
|
||||||
|
expect(withBoth.draftEdited, isTrue);
|
||||||
|
expect(withBoth.naturalnessRating, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ChatMessage defaults draft_edited and naturalness_rating to null when absent', () {
|
||||||
|
final withoutEither = ChatMessage.fromJson({
|
||||||
|
'id': 11,
|
||||||
|
'conversation_id': 1,
|
||||||
|
'sender_id': 1,
|
||||||
|
'sender_mode': 'human',
|
||||||
|
'text': '안녕',
|
||||||
|
'retracted': false,
|
||||||
|
'created_at': '2026-08-03T00:00:00Z',
|
||||||
|
});
|
||||||
|
expect(withoutEither.draftEdited, isNull);
|
||||||
|
expect(withoutEither.naturalnessRating, isNull);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ykavu_mobile/screens/sessions_screen.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('formatExpiresAtLocal converts UTC Z to local wall clock', () {
|
||||||
|
// Fixed offset via DateTime.parse keeps instant; toLocal() uses test TZ.
|
||||||
|
final raw = '2026-09-02T09:06:45.137172Z';
|
||||||
|
final out = SessionsScreen.formatExpiresAtLocal(raw);
|
||||||
|
final expected = DateTime.parse(raw).toLocal();
|
||||||
|
final two = (int n) => n.toString().padLeft(2, '0');
|
||||||
|
expect(
|
||||||
|
out,
|
||||||
|
'${expected.year}-${two(expected.month)}-${two(expected.day)} '
|
||||||
|
'${two(expected.hour)}:${two(expected.minute)}',
|
||||||
|
);
|
||||||
|
expect(out.contains('T'), isFalse);
|
||||||
|
expect(out.endsWith('Z'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatExpiresAtLocal handles null and garbage', () {
|
||||||
|
expect(SessionsScreen.formatExpiresAtLocal(null), '');
|
||||||
|
expect(SessionsScreen.formatExpiresAtLocal('not-a-date'), 'not-a-date');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:ykavu_mobile/services/snooze_notification_service.dart';
|
||||||
class _FakeScheduler implements SnoozeNotificationScheduler {
|
class _FakeScheduler implements SnoozeNotificationScheduler {
|
||||||
final scheduledCalls = <Map<String, Object?>>[];
|
final scheduledCalls = <Map<String, Object?>>[];
|
||||||
final cancelledIds = <int>[];
|
final cancelledIds = <int>[];
|
||||||
|
final immediateCalls = <Map<String, Object?>>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> scheduleReminder({
|
Future<void> scheduleReminder({
|
||||||
|
|
@ -29,6 +30,14 @@ class _FakeScheduler implements SnoozeNotificationScheduler {
|
||||||
Future<void> cancelReminder(int conversationId) async {
|
Future<void> cancelReminder(int conversationId) async {
|
||||||
cancelledIds.add(conversationId);
|
cancelledIds.add(conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> showImmediate({required String title, required String body}) async {
|
||||||
|
immediateCalls.add({'title': title, 'body': body});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> ensurePermission() async => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -83,4 +92,16 @@ void main() {
|
||||||
expect(await controller.loadAllSnoozes(), {1: until1, 2: until2});
|
expect(await controller.loadAllSnoozes(), {1: until1, 2: until2});
|
||||||
expect(scheduler.scheduledCalls, hasLength(2));
|
expect(scheduler.scheduledCalls, hasLength(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('notifyPastDueOnFocus shows immediate only for past-due snoozes', () async {
|
||||||
|
final past = DateTime.utc(2026, 8, 1, 12, 0);
|
||||||
|
final future = DateTime.utc(2026, 8, 10, 12, 0);
|
||||||
|
await controller.applySnooze(conversationId: 1, until: past, title: 't', body: 'b');
|
||||||
|
await controller.applySnooze(conversationId: 2, until: future, title: 't', body: 'b');
|
||||||
|
|
||||||
|
await controller.notifyPastDueOnFocus(now: DateTime.utc(2026, 8, 3, 12, 0));
|
||||||
|
|
||||||
|
expect(scheduler.immediateCalls, hasLength(1));
|
||||||
|
expect(scheduler.immediateCalls.single['body'], contains('#1'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:ykavu_mobile/db/app_database_web.dart';
|
||||||
|
|
||||||
|
/// N4-W1 — web AppDatabase durable round-trip (imports web impl directly so
|
||||||
|
/// VM tests exercise prefs persistence, not the native Drift path).
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('web open persists tone, kv, and snooze across reopen', () async {
|
||||||
|
final db1 = await AppDatabase.open();
|
||||||
|
addTearDown(db1.close);
|
||||||
|
|
||||||
|
await db1.replaceToneSamples(['웹에서도', '유지돼야 함']);
|
||||||
|
await db1.setBoolKv('tone_onboarding_done', true);
|
||||||
|
await db1.setKv('device_install_id', 'abc123deadbeef');
|
||||||
|
final until = DateTime.utc(2026, 8, 4, 15, 30);
|
||||||
|
await db1.setSnoozedUntil(7, until);
|
||||||
|
|
||||||
|
final db2 = await AppDatabase.open();
|
||||||
|
addTearDown(db2.close);
|
||||||
|
|
||||||
|
expect(await db2.loadToneSamples(), ['웹에서도', '유지돼야 함']);
|
||||||
|
expect(await db2.getBoolKv('tone_onboarding_done'), isTrue);
|
||||||
|
expect(await db2.getKv('device_install_id'), 'abc123deadbeef');
|
||||||
|
expect(await db2.getSnoozedUntil(7), until);
|
||||||
|
expect(db2.encrypted, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('web memory factory does not write prefs', () async {
|
||||||
|
final mem = AppDatabase.memory();
|
||||||
|
addTearDown(mem.close);
|
||||||
|
await mem.replaceToneSamples(['ephemeral']);
|
||||||
|
await mem.setBoolKv('tone_onboarding_done', true);
|
||||||
|
|
||||||
|
final opened = await AppDatabase.open();
|
||||||
|
addTearDown(opened.close);
|
||||||
|
expect(await opened.loadToneSamples(), isEmpty);
|
||||||
|
expect(await opened.getBoolKv('tone_onboarding_done'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('corrupt tone JSON is wiped and open still succeeds', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'ykavu_web_db_tone_samples': '{not-json',
|
||||||
|
});
|
||||||
|
final db = await AppDatabase.open();
|
||||||
|
addTearDown(db.close);
|
||||||
|
expect(await db.loadToneSamples(), isEmpty);
|
||||||
|
await db.replaceToneSamples(['ok']);
|
||||||
|
expect(await db.loadToneSamples(), ['ok']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ykavu_mobile/services/ws_client.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('wsLinkStateLabel covers all states', () {
|
||||||
|
expect(wsLinkStateLabel(WsLinkState.connecting), '연결 중…');
|
||||||
|
expect(wsLinkStateLabel(WsLinkState.live), '실시간');
|
||||||
|
expect(wsLinkStateLabel(WsLinkState.reconnecting), '다시 연결 중…');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
// FCM Web service worker stub.
|
||||||
|
// When Firebase Web config is injected at Docker build time, this file is
|
||||||
|
// overwritten with a populated worker (see mobile/Dockerfile).
|
||||||
|
// An empty/stub file prevents 404s while Web Push secrets are absent.
|
||||||
|
console.log('[ykavu] firebase-messaging-sw.js stub (Web Push not configured)');
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Generated at Docker build from firebase-messaging-sw.js.template — do not edit on server.
|
||||||
|
// Compat SDK is required for importScripts-based FCM service workers.
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js');
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js');
|
||||||
|
|
||||||
|
firebase.initializeApp({
|
||||||
|
apiKey: '__FIREBASE_API_KEY__',
|
||||||
|
authDomain: '__FIREBASE_AUTH_DOMAIN__',
|
||||||
|
projectId: '__FIREBASE_PROJECT_ID__',
|
||||||
|
storageBucket: '__FIREBASE_STORAGE_BUCKET__',
|
||||||
|
messagingSenderId: '__FIREBASE_MESSAGING_SENDER_ID__',
|
||||||
|
appId: '__FIREBASE_APP_ID__',
|
||||||
|
});
|
||||||
|
|
||||||
|
firebase.messaging();
|
||||||
|
|
@ -1,38 +1,35 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<!--
|
|
||||||
If you are serving your web app in a path other than the root, change the
|
|
||||||
href value below to reflect the base path you are serving from.
|
|
||||||
|
|
||||||
The path provided below has to start and end with a slash "/" in order for
|
|
||||||
it to work correctly.
|
|
||||||
|
|
||||||
For more details:
|
|
||||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
|
||||||
|
|
||||||
This is a placeholder for base href that will be replaced by the value of
|
|
||||||
the `--base-href` argument provided to `flutter build`.
|
|
||||||
-->
|
|
||||||
<base href="$FLUTTER_BASE_HREF">
|
<base href="$FLUTTER_BASE_HREF">
|
||||||
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||||
<meta name="description" content="A new Flutter project.">
|
<meta name="description" content="나를 대신해 답하는, 나만의 와카뷰 — AI 트윈 메신저 데모">
|
||||||
|
<meta name="theme-color" content="#007AFF">
|
||||||
|
|
||||||
<!-- iOS meta tags & icons -->
|
<!-- iOS / install -->
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-title" content="Ykavu">
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="와카뷰">
|
||||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
|
||||||
<title>와카뷰 (Ykavu)</title>
|
<title>와카뷰 (Ykavu)</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
<!-- FCM Web SW (N4-W4). Scope matches FlutterFire messaging defaults. -->
|
||||||
|
<script src="flutter_bootstrap.js" async>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
navigator.serviceWorker.register('firebase-messaging-sw.js', {
|
||||||
|
scope: '/firebase-cloud-messaging-push-scope',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
"short_name": "와카뷰",
|
"short_name": "와카뷰",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#7C6FF0",
|
"background_color": "#F2F2F7",
|
||||||
"theme_color": "#7C6FF0",
|
"theme_color": "#007AFF",
|
||||||
"description": "나를 대신해 답하는, 나만의 와카뷰",
|
"description": "나를 대신해 답하는, 나만의 와카뷰",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "any",
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# N4-W7 — minimal live smoke for the web edge (no browser automation).
|
||||||
|
# Usage: ./scripts/e2e_web_smoke.sh [BASE_URL]
|
||||||
|
set -euo pipefail
|
||||||
|
BASE="${1:-https://msn.iykyka.com}"
|
||||||
|
|
||||||
|
echo "==> GET $BASE/health"
|
||||||
|
curl -fsS "$BASE/health"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "==> GET $BASE/demo"
|
||||||
|
curl -fsS "$BASE/demo" | head -c 400
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "==> GET $BASE/manifest.json (PWA)"
|
||||||
|
curl -fsS "$BASE/manifest.json" | head -c 300
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "==> GET $BASE/firebase-messaging-sw.js (W4 SW)"
|
||||||
|
code=$(curl -sS -o /tmp/ykavu_fcm_sw.js -w '%{http_code}' "$BASE/firebase-messaging-sw.js")
|
||||||
|
echo "HTTP $code ($(wc -c </tmp/ykavu_fcm_sw.js) bytes)"
|
||||||
|
test "$code" = "200"
|
||||||
|
|
||||||
|
echo "==> POST /auth/login DEMO without display_name (expect 400)"
|
||||||
|
code=$(curl -sS -o /tmp/ykavu_login.json -w '%{http_code}' \
|
||||||
|
-X POST "$BASE/auth/login" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"invite_code":"DEMO-YKAVU"}')
|
||||||
|
echo "HTTP $code $(head -c 120 /tmp/ykavu_login.json)"
|
||||||
|
test "$code" = "400"
|
||||||
|
|
||||||
|
echo "OK web edge smoke ($BASE)"
|
||||||
Loading…
Reference in New Issue