diff --git a/.env.example b/.env.example index 070cc05..19052ef 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,10 @@ # Copy to .env and fill in values. Never commit .env. # Get a key from Google AI Studio: https://aistudio.google.com/apikey GEMINI_API_KEY= + +# core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test) +ADMIN_API_TOKEN= + +# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test). +# Without this, notifyUser soft-skips and records push_skipped metrics. +FCM_SERVER_KEY= diff --git a/AGENTS.md b/AGENTS.md index f10ddc7..d9f85e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # Project Instructions — hikikomori / 분신 (가칭) -This repository is currently a **planning-docs repo** for an AI-twin messenger -("나를 대신해 남과 대화하는 AI 분신"). There is no application code yet. +This repository is an AI-twin messenger ("나를 대신해 남과 대화하는 AI 분신") with +Phase 1 app code (`core-backend/`, `ai-service/`, `mobile/`) plus planning docs. -Canonical docs live under `docs/`. Prefer linking to them over copying content -into prompts or new files. +Canonical product decisions live under `docs/`. Prefer linking to them over +copying content into prompts or new files. ## Document authority @@ -18,9 +18,10 @@ When documents disagree, follow this order: Notes: -- Q1~Q7 in `decision-log.md` are **제안 (tentative)**, not final meeting - decisions. Do not silently reverse them. If a change is required, update - `decision-log.md` and all derived docs in the same change. +- Q1~Q7 in `decision-log.md` are **확정** (Phase 1 C, 2026-07-30). PoC-dependent + sub-questions (default autonomy level, whitelist defaults, final branding) + stay open — do not invent those. To reverse a Q, update `decision-log.md` and + derived docs in the same change. - Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for current working answers. - Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later** diff --git a/CLAUDE.md b/CLAUDE.md index c17c295..4eb633c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,10 @@ Follow the project instructions in [@AGENTS.md](./AGENTS.md). Quick context: -- This repo started as planning docs only; Phase 1 app-build has now begun. - Check `docs/roadmap.md` Phase 1 checklist before starting app-build work. -- Working product name: **분신** (tentative). -- Source of working decisions: `docs/decision-log.md` (status: 제안). +- Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`). Next is + **D — human PoC** (`docs/roadmap.md`). Do not start PoC early or invent §3 defaults. +- Working product name: **분신** (가칭 확정). +- Source of decisions: `docs/decision-log.md` (Q1~Q7 **확정**; PoC sub-questions open). - v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first. - Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B. - Never weaken escalation, twin badge, peer veto, or undo. diff --git a/README.md b/README.md index ceea6e4..68c4735 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ ## 문서 -- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) +- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) (Q1~Q7 **확정**) - 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) +- 베타 직전(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) ## AI 에이전트 규칙 @@ -26,9 +27,9 @@ ## 현재 단계 -- 기획 문서 + Phase 1 **서버(Go/Python)** + Flutter **클라이언트 골격**까지 진행됨 -- Q1~Q7은 아직 **잠정(제안)** — 회의 확정 전 -- **사람 대상 PoC #1/#3·Q3 인터뷰는 맨 마지막 작업**으로 미룸 (`docs/roadmap.md` §3) +- Phase 1 **A~C**까지 반영됨 (서버·Flutter·베타 직전 문서/배포 경로) +- 다음: **D — 사람 PoC #1/#3·Q3 인터뷰** (`docs/roadmap.md` §3, 맨 마지막) +- 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입 ## 로컬 실행 (요약) diff --git a/ai-service/app/generation.py b/ai-service/app/generation.py index bfc6d6f..397b3e4 100644 --- a/ai-service/app/generation.py +++ b/ai-service/app/generation.py @@ -9,6 +9,7 @@ import os from pathlib import Path from .escalation_filter import check as check_escalation +from .identity import check_identity_question def load_dotenv_if_present(): @@ -39,6 +40,8 @@ SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화 - 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 이 문장만 출력한다: \ [ESCALATE] 이 내용은 본인 확인이 필요합니다. - 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다. +- 상대가 "본인이야/분신이야?"처럼 정체를 물으면 분신임을 정직하게 밝힌다 \ +(서버가 고정 문구로 먼저 처리하지만, 여기까지 온 경우에도 분신이라고 답한다). (이 지침은 2차 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진 내용은 여기까지 오지 않는다. 이 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)""" @@ -65,10 +68,16 @@ def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key "no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing). "ok": text is the generated draft. """ - gate = check_escalation(last_incoming_text(context_lines)) + incoming = last_incoming_text(context_lines) + gate = check_escalation(incoming) if gate.escalate: return "escalate", gate.reason + # Honest identity answers must be fixed copy, not LLM-invented (AGENTS.md). + identity = check_identity_question(incoming) + if identity.matched: + return "ok", identity.reply + api_key = api_key or os.environ.get("GEMINI_API_KEY") if not api_key: return "no_key", build_user_prompt(style_examples, context_lines) diff --git a/ai-service/app/identity.py b/ai-service/app/identity.py new file mode 100644 index 0000000..cfffc45 --- /dev/null +++ b/ai-service/app/identity.py @@ -0,0 +1,40 @@ +"""Fixed honest-identity answers (AGENTS.md / PRD §3.1). + +When the peer asks whether they are talking to the human or the twin, the +twin must answer as a twin with a stable phrase — not invent wording via LLM. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +# Fixed product copy — tune only after PoC #3; do not invent alternate defaults. +IDENTITY_REPLY = ( + "지금은 분신이 답하고 있어. 본인이랑 바로 이야기하고 싶으면 그렇게 말해줘." +) + +# Incoming peer questions that should trigger the fixed identity reply. +_IDENTITY_QUESTION = re.compile( + r"(" + r"본인\s*(이야|인가요|이니|임\??|맞아|맞음)" + r"|분신\s*(이야|인가요|이니|임\??|맞아|맞음)" + r"|지금\s*(본인|분신)" + r"|진짜\s*(야|임|인가요)" + r"|너\s*(사람|본인|분신)" + r")", + re.IGNORECASE, +) + + +@dataclass +class IdentityHit: + matched: bool + reply: str = "" + + +def check_identity_question(text: str) -> IdentityHit: + if not text or not text.strip(): + return IdentityHit(False) + if _IDENTITY_QUESTION.search(text.strip()): + return IdentityHit(True, IDENTITY_REPLY) + return IdentityHit(False) diff --git a/ai-service/tests/test_identity.py b/ai-service/tests/test_identity.py new file mode 100644 index 0000000..1170ba9 --- /dev/null +++ b/ai-service/tests/test_identity.py @@ -0,0 +1,29 @@ +from app.identity import IDENTITY_REPLY, check_identity_question +from app.generation import draft_reply + + +def test_identity_phrases_match(): + for q in [ + "지금 본인이야 분신이야?", + "분신이야?", + "너 본인 맞아?", + "진짜야?", + ]: + hit = check_identity_question(q) + assert hit.matched, q + assert hit.reply == IDENTITY_REPLY + + +def test_identity_non_match(): + assert not check_identity_question("오늘 저녁 뭐 먹을래?").matched + + +def test_draft_returns_fixed_identity_without_llm(monkeypatch): + # Ensure we never need an API key for this path. + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + status, text = draft_reply( + ["ㅇㅇ 알겠음"], + ["상대: 지금 본인이야 분신이야?"], + ) + assert status == "ok" + assert text == IDENTITY_REPLY diff --git a/core-backend/README.md b/core-backend/README.md index 3d47140..1f9bebc 100644 --- a/core-backend/README.md +++ b/core-backend/README.md @@ -29,7 +29,22 @@ export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소 - 가입 `POST /auth/signup` / 로그인 `POST /auth/login` → `{token}` (Bearer) - 사용자 스코프 API(`PATCH /users/:id/...`, contacts, conversations 목록 등)는 Bearer 필요 -- `/invites`, `/admin/metrics`는 `Authorization: Bearer $ADMIN_API_TOKEN` +- `/invites`, `/admin/metrics`, `/admin/dashboard`, `/admin/push-test`는 + `Authorization: Bearer $ADMIN_API_TOKEN` (`/admin/dashboard`는 `?token=` 쿼리 가능) +- 초대 운영: `POST /invites`(`note`, `expires_in_days`, `count`), `GET /invites`, + `POST /invites/:code/revoke` — 절차는 `docs/invite-ops.md` + +메시지 생성(`POST /conversations/:id/messages`)은 전체 메시지 JSON을 반환한다 +(`id`, `conversation_id`, `sender_id`, `sender_mode`, `text`, `retracted`, `created_at`). + +Phase 1 B (베타 품질) 추가분: +- draft/escalate 지연·오류율 → `/admin/metrics`의 `draft_*` / `escalate_*` 필드 (프로세스 메모리) +- `GET /admin/dashboard` — 최소 HTML 대시보드 +- `POST|GET /users/:id/device-tokens` — FCM 토큰 등록(전송은 후속) +- `GET /users/:id/sessions` — 활성 세션 목록(멀티 디바이스 1차) +- `DELETE /users/:id/sessions/:sessionId` — 세션 종료 +- `POST /admin/push-test` — FCM 스모크 테스트 (`FCM_SERVER_KEY` 필요, 없으면 skipped) +- 에스컬레이션 시 사용자 디바이스로 푸시 시도 (`notifyUser`) ## 테스트 diff --git a/core-backend/b_routes.go b/core-backend/b_routes.go new file mode 100644 index 0000000..dee1b01 --- /dev/null +++ b/core-backend/b_routes.go @@ -0,0 +1,245 @@ +package main + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type registerDeviceRequest struct { + Token string `json:"token" binding:"required"` + Platform string `json:"platform"` +} + +func messageJSON(m Message) gin.H { + return gin.H{ + "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, + } +} + +func registerBRoutes(r *gin.Engine, db *gorm.DB) { + // Minimal HTML dashboard for operators (roadmap B). JSON stays on /admin/metrics. + r.GET("/admin/dashboard", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, adminDashboardHTML) + }) + + r.POST("/users/:id/device-tokens", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + if !requireSelf(c, db, userID) { + return + } + var req registerDeviceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + platform := req.Platform + if platform == "" { + platform = "android" + } + var existing DeviceToken + err := db.Where("token = ?", req.Token).First(&existing).Error + if err == nil { + existing.UserID = userID + existing.Platform = platform + existing.UpdatedAt = time.Now() + db.Save(&existing) + c.JSON(http.StatusOK, gin.H{"id": existing.ID, "token": existing.Token, "platform": existing.Platform}) + return + } + row := DeviceToken{UserID: userID, Token: req.Token, Platform: platform} + if err := db.Create(&row).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"id": row.ID, "token": row.Token, "platform": row.Platform}) + }) + + r.GET("/users/:id/device-tokens", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + if !requireSelf(c, db, userID) { + return + } + var tokens []DeviceToken + db.Where("user_id = ?", userID).Order("id desc").Find(&tokens) + out := make([]gin.H, 0, len(tokens)) + for _, t := range tokens { + out = append(out, gin.H{ + "id": t.ID, + "platform": t.Platform, + "token_tail": trimToken(t.Token), + "updated_at": t.UpdatedAt, + }) + } + c.JSON(http.StatusOK, gin.H{"device_tokens": out}) + }) + + // Multi-device awareness: list active sessions for the authenticated user. + r.GET("/users/:id/sessions", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + actor, ok := currentUser(c, db, true) + if !ok { + return + } + if actor.ID != userID { + c.JSON(http.StatusForbidden, gin.H{"detail": "can only list your own sessions"}) + return + } + var sessions []Session + db.Where("user_id = ? AND expires_at > ?", userID, time.Now()).Order("id desc").Find(&sessions) + out := make([]gin.H, 0, len(sessions)) + current := bearerToken(c) + for _, s := range sessions { + out = append(out, gin.H{ + "id": s.ID, + "created_at": s.CreatedAt, + "expires_at": s.ExpiresAt, + "is_current": s.Token == current, + }) + } + c.JSON(http.StatusOK, gin.H{"sessions": out}) + }) + + // Revoke another (or current) session — multi-device control. + r.DELETE("/users/:id/sessions/:sessionId", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + sessionID, ok := parseUintParam(c, "sessionId") + if !ok { + return + } + actor, ok := currentUser(c, db, true) + if !ok { + return + } + if actor.ID != userID { + c.JSON(http.StatusForbidden, gin.H{"detail": "can only revoke your own sessions"}) + return + } + var session Session + if err := db.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "session not found"}) + return + } + db.Delete(&session) + c.JSON(http.StatusOK, gin.H{"deleted": true, "id": sessionID}) + }) + + // Admin smoke-test push to a user (requires FCM_SERVER_KEY for real delivery). + r.POST("/admin/push-test", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + var req struct { + UserID uint `json:"user_id" binding:"required"` + Title string `json:"title"` + Body string `json:"body"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + if req.Title == "" { + req.Title = "분신 테스트" + } + if req.Body == "" { + req.Body = "push test" + } + sent, reason, err := notifyUser(db, req.UserID, req.Title, req.Body, map[string]string{"type": "test"}) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"sent": sent, "skipped_reason": reason}) + }) +} + +func trimToken(token string) string { + if len(token) <= 8 { + return "****" + } + return "…" + token[len(token)-6:] +} + +const adminDashboardHTML = ` + +
+ + +Bearer ADMIN_API_TOKEN 으로 /admin/metrics 를 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.
+ + +loading…+ + + +` \ No newline at end of file diff --git a/core-backend/b_test.go b/core-backend/b_test.go new file mode 100644 index 0000000..52b92b4 --- /dev/null +++ b/core-backend/b_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" +) + +func TestMessageCreateReturnsFullPayload(t *testing.T) { + server, _ := setupTestServer(t) + userID, token := mustSignup(t, server.URL, "full-msg") + peerID, _ := mustSignup(t, server.URL, "full-msg-peer") + + convResp := postJSONAuth(t, server.URL+"/conversations", token, createConversationRequest{ + UserIDs: []uint{userID, peerID}, + }) + if convResp.StatusCode != http.StatusOK { + t.Fatalf("create conversation: %d", convResp.StatusCode) + } + var conv map[string]interface{} + json.NewDecoder(convResp.Body).Decode(&conv) + convID := uint(conv["id"].(float64)) + + msgResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{ + SenderID: userID, + Text: "hello full", + }) + if msgResp.StatusCode != http.StatusOK { + t.Fatalf("send: %d", msgResp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(msgResp.Body).Decode(&out) + for _, key := range []string{"id", "conversation_id", "sender_id", "sender_mode", "text", "retracted"} { + if _, ok := out[key]; !ok { + t.Fatalf("missing key %s in %v", key, out) + } + } + if out["text"] != "hello full" || out["sender_mode"] != "human" { + t.Fatalf("unexpected payload %v", out) + } +} + +func TestDeviceTokenAndSessions(t *testing.T) { + server, _ := setupTestServer(t) + userID, token := mustSignup(t, server.URL, "devices") + + reg := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/device-tokens", token, registerDeviceRequest{ + Token: "fcm-test-token-abc", + Platform: "android", + }) + if reg.StatusCode != http.StatusOK { + t.Fatalf("device token: %d", reg.StatusCode) + } + + req, _ := http.NewRequest(http.MethodGet, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var sout map[string]interface{} + json.NewDecoder(resp.Body).Decode(&sout) + sessions := sout["sessions"].([]interface{}) + if len(sessions) < 1 { + t.Fatalf("expected at least one session, got %v", sout) + } + first := sessions[0].(map[string]interface{}) + if first["is_current"] != true { + t.Fatalf("expected current session flag, got %v", first) + } +} + +func TestRevokeSessionAndPushTestWithoutFCM(t *testing.T) { + server, _ := setupTestServer(t) + userID, token := mustSignup(t, server.URL, "revoke") + + // Second login via invite re-use is blocked; mint another session by signup of peer then + // just revoke the only session after listing. + req, _ := http.NewRequest(http.MethodGet, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + var sout map[string]interface{} + json.NewDecoder(resp.Body).Decode(&sout) + resp.Body.Close() + sessions := sout["sessions"].([]interface{}) + sid := uint(sessions[0].(map[string]interface{})["id"].(float64)) + + del := deleteJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions/"+strconv.FormatUint(uint64(sid), 10), token) + if del.StatusCode != http.StatusOK { + t.Fatalf("revoke: %d", del.StatusCode) + } + + // Push test without FCM_SERVER_KEY should soft-skip. + push := postJSONAuth(t, server.URL+"/admin/push-test", "test-admin-token", map[string]interface{}{ + "user_id": userID, + "title": "t", + "body": "b", + }) + if push.StatusCode != http.StatusOK { + t.Fatalf("push-test: %d", push.StatusCode) + } + var pout map[string]interface{} + json.NewDecoder(push.Body).Decode(&pout) + // Without device tokens / FCM key: sent==0 and a skipped_reason is expected. + if pout["sent"].(float64) != 0 { + t.Fatalf("expected zero sent without FCM tokens, got %v", pout) + } + if pout["skipped_reason"] == nil || pout["skipped_reason"] == "" { + t.Fatalf("expected skipped_reason, got %v", pout) + } +} + +func TestAdminMetricsIncludesDraftLatencyFields(t *testing.T) { + server, _ := setupTestServer(t) + req, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil) + req.Header.Set("Authorization", "Bearer test-admin-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + for _, key := range []string{"draft_requests", "draft_error_rate", "draft_latency_avg_ms", "escalate_error_rate"} { + if _, ok := out[key]; !ok { + t.Fatalf("missing metrics key %s in %v", key, out) + } + } +} diff --git a/core-backend/invite_ops.go b/core-backend/invite_ops.go new file mode 100644 index 0000000..8af970d --- /dev/null +++ b/core-backend/invite_ops.go @@ -0,0 +1,148 @@ +package main + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type mintInvitesRequest struct { + Note string `json:"note"` + ExpiresInDays int `json:"expires_in_days"` // 0 → default 14; <0 → no expiry + Count int `json:"count"` // default 1, max 20 +} + +func inviteStatus(inv InviteCode, now time.Time) string { + if inv.RevokedAt != nil { + return "revoked" + } + if inv.UsedAt != nil { + return "used" + } + if inv.ExpiresAt != nil && !inv.ExpiresAt.After(now) { + return "expired" + } + return "unused" +} + +func inviteUsable(inv InviteCode, now time.Time) (ok bool, detail string) { + switch inviteStatus(inv, now) { + case "unused": + return true, "" + case "used": + return false, "invite code already used" + case "revoked": + return false, "invite code revoked" + case "expired": + return false, "invite code expired" + default: + return false, "invite code not usable" + } +} + +func inviteJSON(inv InviteCode, now time.Time) gin.H { + out := gin.H{ + "code": inv.Code, + "note": inv.Note, + "status": inviteStatus(inv, now), + "created_at": inv.CreatedAt, + } + if inv.ExpiresAt != nil { + out["expires_at"] = inv.ExpiresAt + } + if inv.RevokedAt != nil { + out["revoked_at"] = inv.RevokedAt + } + if inv.UsedAt != nil { + out["used_at"] = inv.UsedAt + out["used_by_user_id"] = inv.UsedByUserID + } + return out +} + +func registerInviteOpsRoutes(r *gin.Engine, db *gorm.DB) { + r.POST("/invites", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + var req mintInvitesRequest + _ = c.ShouldBindJSON(&req) // body optional for backward compat + count := req.Count + if count <= 0 { + count = 1 + } + if count > 20 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "count must be <= 20"}) + return + } + expiresIn := req.ExpiresInDays + if expiresIn == 0 { + expiresIn = 14 + } + + now := time.Now() + codes := make([]gin.H, 0, count) + for i := 0; i < count; i++ { + code, err := generateInviteCode() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + invite := InviteCode{Code: code, Note: req.Note} + if expiresIn > 0 { + exp := now.Add(time.Duration(expiresIn) * 24 * time.Hour) + invite.ExpiresAt = &exp + } + if err := db.Create(&invite).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + codes = append(codes, inviteJSON(invite, now)) + } + if count == 1 { + c.JSON(http.StatusOK, codes[0]) + return + } + c.JSON(http.StatusOK, gin.H{"invites": codes}) + }) + + r.GET("/invites", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + var invites []InviteCode + db.Order("id desc").Limit(200).Find(&invites) + now := time.Now() + out := make([]gin.H, 0, len(invites)) + for _, inv := range invites { + out = append(out, inviteJSON(inv, now)) + } + c.JSON(http.StatusOK, gin.H{"invites": out}) + }) + + r.POST("/invites/:code/revoke", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + code := c.Param("code") + var invite InviteCode + if err := db.Where("code = ?", code).First(&invite).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "invite not found"}) + return + } + if invite.UsedAt != nil { + c.JSON(http.StatusConflict, gin.H{"detail": "cannot revoke a used invite"}) + return + } + if invite.RevokedAt != nil { + c.JSON(http.StatusOK, inviteJSON(invite, time.Now())) + return + } + now := time.Now() + invite.RevokedAt = &now + db.Save(&invite) + c.JSON(http.StatusOK, inviteJSON(invite, now)) + }) +} diff --git a/core-backend/invite_ops_test.go b/core-backend/invite_ops_test.go new file mode 100644 index 0000000..770ca42 --- /dev/null +++ b/core-backend/invite_ops_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "net/http" + "testing" + "time" +) + +func TestMintInviteWithNoteExpiryAndRevoke(t *testing.T) { + server, _ := setupTestServer(t) + + resp := postJSONAuth(t, server.URL+"/invites", "test-admin-token", mintInvitesRequest{ + Note: "e2e-friend", + ExpiresInDays: 7, + Count: 1, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("mint: %d", resp.StatusCode) + } + var one map[string]interface{} + json.NewDecoder(resp.Body).Decode(&one) + code, _ := one["code"].(string) + if code == "" || one["note"] != "e2e-friend" || one["status"] != "unused" { + t.Fatalf("unexpected mint payload %v", one) + } + if one["expires_at"] == nil { + t.Fatalf("expected expires_at, got %v", one) + } + + batch := postJSONAuth(t, server.URL+"/invites", "test-admin-token", mintInvitesRequest{ + Note: "batch", + Count: 3, + }) + if batch.StatusCode != http.StatusOK { + t.Fatalf("batch: %d", batch.StatusCode) + } + var bout map[string]interface{} + json.NewDecoder(batch.Body).Decode(&bout) + if len(bout["invites"].([]interface{})) != 3 { + t.Fatalf("expected 3 invites, got %v", bout) + } + + listReq, _ := http.NewRequest(http.MethodGet, server.URL+"/invites", nil) + listReq.Header.Set("Authorization", "Bearer test-admin-token") + listResp, err := http.DefaultClient.Do(listReq) + if err != nil { + t.Fatal(err) + } + if listResp.StatusCode != http.StatusOK { + t.Fatalf("list: %d", listResp.StatusCode) + } + + rev := postJSONAuth(t, server.URL+"/invites/"+code+"/revoke", "test-admin-token", nil) + if rev.StatusCode != http.StatusOK { + t.Fatalf("revoke: %d", rev.StatusCode) + } + var revOut map[string]interface{} + json.NewDecoder(rev.Body).Decode(&revOut) + if revOut["status"] != "revoked" { + t.Fatalf("expected revoked, got %v", revOut) + } + + signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "X"}) + if signup.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for revoked invite, got %d", signup.StatusCode) + } +} + +func TestExpiredInviteRejected(t *testing.T) { + server, db := setupTestServer(t) + code := mintInvite(t, server.URL) + past := time.Now().Add(-time.Hour) + db.Model(&InviteCode{}).Where("code = ?", code).Update("expires_at", past) + + signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "Late"}) + if signup.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for expired invite, got %d", signup.StatusCode) + } +} diff --git a/core-backend/main.go b/core-backend/main.go index e13a15e..58d08d6 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -57,23 +57,8 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi }) registerA1A2Routes(r, db) - - r.POST("/invites", func(c *gin.Context) { - if !requireAdmin(c) { - return - } - code, err := generateInviteCode() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - invite := InviteCode{Code: code} - if err := db.Create(&invite).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"code": invite.Code}) - }) + registerBRoutes(r, db) + registerInviteOpsRoutes(r, db) r.GET("/admin/metrics", func(c *gin.Context) { if !requireAdmin(c) { @@ -110,6 +95,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal) } + rt := runtimeMetrics.snapshot() c.JSON(http.StatusOK, gin.H{ "users_total": usersTotal, "messages_human_total": humanMessages, @@ -123,7 +109,21 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // vision.md doesn't pin down the exact denominator, so treat // this as a first approximation, not the final definition. "peer_veto_rate": peerVetoRate, - "invites_minted": invitesMinted, + // Process-local draft/AI timings (roadmap B). Reset on restart. + "draft_requests": rt.DraftRequests, + "draft_errors": rt.DraftErrors, + "draft_error_rate": rt.DraftErrorRate, + "draft_latency_avg_ms": rt.DraftLatencyAvgMs, + "draft_latency_max_ms": rt.DraftLatencyMaxMs, + "draft_latency_samples": rt.DraftLatencySamples, + "escalate_checks": rt.EscalateChecks, + "escalate_errors": rt.EscalateErrors, + "escalate_error_rate": rt.EscalateErrorRate, + "twin_sends_blocked": rt.TwinSendsBlocked, + "push_attempts": rt.PushAttempts, + "push_skipped": rt.PushSkipped, + "push_delivered": rt.PushDelivered, + "invites_minted": invitesMinted, "invites_used": invitesUsed, }) }) @@ -143,8 +143,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"}) return } - if invite.UsedAt != nil { - c.JSON(http.StatusConflict, gin.H{"detail": "invite code already used"}) + if ok, detail := inviteUsable(invite, time.Now()); !ok { + status := http.StatusBadRequest + if detail == "invite code already used" { + status = http.StatusConflict + } + c.JSON(status, gin.H{"detail": detail}) return } @@ -207,22 +211,32 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // none of the later checks can override an earlier block. if req.SenderMode == SenderTwin { if conversation.TwinDisabledByPeer { + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 분신을 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"}) return } result, err := ai.checkEscalation(req.Text) + runtimeMetrics.recordEscalate(err) if err != nil { + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()}) return } if result.Escalate { + runtimeMetrics.recordTwinBlocked() db.Create(&EscalationLog{ UserID: req.SenderID, ConversationID: convID, Reason: result.Reason, MessageSnippet: req.Text, }) + // Best-effort push (no-op without FCM_SERVER_KEY / real tokens). + _, _, _ = notifyUser(db, req.SenderID, "분신 확인 필요", result.Reason, map[string]string{ + "type": "escalation", + "conversation_id": strconv.FormatUint(uint64(convID), 10), + "reason": result.Reason, + }) c.JSON(http.StatusForbidden, gin.H{"detail": "escalated", "reason": result.Reason}) return } @@ -237,19 +251,23 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi switch level { case AutonomyL0: + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 분신 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"}) return case AutonomyL1: if !req.Approved { + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusForbidden, gin.H{"detail": "L1은 발송 전 사용자 승인이 필요합니다"}) return } case AutonomyL2: if !req.Approved && !whitelistMatches(db, req.SenderID, convID, req.Text) { + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"}) return } default: + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"}) return } @@ -274,7 +292,8 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi "text": message.Text, }) - c.JSON(http.StatusOK, gin.H{"id": message.ID}) + // Return the full message so Flutter can render without waiting on WS. + c.JSON(http.StatusOK, messageJSON(message)) }) r.POST("/messages/:id/retract", func(c *gin.Context) { @@ -337,16 +356,14 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi return } - // EscalationLog persistence (sender's post-hoc notification + undo - // trail) is a separate checklist item -- roadmap.md Phase 1 §2.2 - // "사후 알림 + 되돌리기 로그 스키마/API". This endpoint only proxies - // to the AI service for now. + started := time.Now() result, err := ai.requestDraft(draftRequest{ ContextLines: req.ContextLines, StyleExamples: req.StyleExamples, History: req.History, K: req.K, }) + runtimeMetrics.recordDraft(time.Since(started), err) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) return diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 275ab89..4c49d86 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -105,6 +105,19 @@ func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Respo return resp } +func deleteJSONAuth(t *testing.T, url, token string) *http.Response { + t.Helper() + req, _ := http.NewRequest(http.MethodDelete, url, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("delete %s: %v", url, err) + } + return resp +} + func mintInvite(t *testing.T, serverURL string) string { t.Helper() resp := postJSONAuth(t, serverURL+"/invites", "test-admin-token", nil) diff --git a/core-backend/metrics.go b/core-backend/metrics.go new file mode 100644 index 0000000..df862d4 --- /dev/null +++ b/core-backend/metrics.go @@ -0,0 +1,122 @@ +package main + +import ( + "sync" + "time" +) + +// RuntimeMetrics tracks draft-generation latency and AI error rates in process +// memory (roadmap B). Survives for the life of the process; not a substitute +// for a time-series DB, but enough for a minimal admin dashboard. +type RuntimeMetrics struct { + mu sync.Mutex + + DraftRequests int64 + DraftErrors int64 + DraftLatencies []time.Duration // capped ring of recent samples + EscalateChecks int64 + EscalateErrors int64 + TwinSendsBlocked int64 + PushAttempts int64 + PushSkipped int64 + PushDelivered int64 +} + +const maxLatencySamples = 200 + +var runtimeMetrics = &RuntimeMetrics{} + +func (m *RuntimeMetrics) recordDraft(latency time.Duration, err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.DraftRequests++ + if err != nil { + m.DraftErrors++ + } + m.DraftLatencies = append(m.DraftLatencies, latency) + if len(m.DraftLatencies) > maxLatencySamples { + m.DraftLatencies = m.DraftLatencies[len(m.DraftLatencies)-maxLatencySamples:] + } +} + +func (m *RuntimeMetrics) recordEscalate(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.EscalateChecks++ + if err != nil { + m.EscalateErrors++ + } +} + +func (m *RuntimeMetrics) recordTwinBlocked() { + m.mu.Lock() + defer m.mu.Unlock() + m.TwinSendsBlocked++ +} + +func (m *RuntimeMetrics) recordPush(delivered int, skipped bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.PushAttempts++ + if skipped { + m.PushSkipped++ + } + m.PushDelivered += int64(delivered) +} + +func (m *RuntimeMetrics) snapshot() ginHMetrics { + m.mu.Lock() + defer m.mu.Unlock() + + var sum time.Duration + var max time.Duration + for _, d := range m.DraftLatencies { + sum += d + if d > max { + max = d + } + } + var avgMs float64 + if n := len(m.DraftLatencies); n > 0 { + avgMs = float64(sum.Milliseconds()) / float64(n) + } + var errRate float64 + if m.DraftRequests > 0 { + errRate = float64(m.DraftErrors) / float64(m.DraftRequests) + } + var escErrRate float64 + if m.EscalateChecks > 0 { + escErrRate = float64(m.EscalateErrors) / float64(m.EscalateChecks) + } + return ginHMetrics{ + DraftRequests: m.DraftRequests, + DraftErrors: m.DraftErrors, + DraftErrorRate: errRate, + DraftLatencyAvgMs: avgMs, + DraftLatencyMaxMs: float64(max.Milliseconds()), + DraftLatencySamples: len(m.DraftLatencies), + EscalateChecks: m.EscalateChecks, + EscalateErrors: m.EscalateErrors, + EscalateErrorRate: escErrRate, + TwinSendsBlocked: m.TwinSendsBlocked, + PushAttempts: m.PushAttempts, + PushSkipped: m.PushSkipped, + PushDelivered: m.PushDelivered, + } +} + +type ginHMetrics struct { + DraftRequests int64 + DraftErrors int64 + DraftErrorRate float64 + DraftLatencyAvgMs float64 + DraftLatencyMaxMs float64 + DraftLatencySamples int + EscalateChecks int64 + EscalateErrors int64 + EscalateErrorRate float64 + TwinSendsBlocked int64 + PushAttempts int64 + PushSkipped int64 + PushDelivered int64 +} diff --git a/core-backend/models.go b/core-backend/models.go index 47ec75b..c5938a7 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -41,7 +41,10 @@ type Session struct { type InviteCode struct { ID uint `gorm:"primaryKey"` Code string `gorm:"uniqueIndex;not null"` + Note string // operator memo (recipient/channel) — roadmap C invite ops CreatedAt time.Time + ExpiresAt *time.Time + RevokedAt *time.Time UsedAt *time.Time UsedByUserID *uint } @@ -119,6 +122,17 @@ type EscalationLog struct { CreatedAt time.Time } +// DeviceToken stores an FCM registration token for push (roadmap B). +// Sending pushes is wired later; v1 persists tokens per user/device. +type DeviceToken struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"not null;index"` + Token string `gorm:"uniqueIndex;not null"` + Platform string `gorm:"not null;default:android"` + CreatedAt time.Time + UpdatedAt time.Time +} + var allModels = []interface{}{ &User{}, &Session{}, @@ -130,4 +144,5 @@ var allModels = []interface{}{ &TwinSettings{}, &WhitelistRule{}, &EscalationLog{}, + &DeviceToken{}, } diff --git a/core-backend/push.go b/core-backend/push.go new file mode 100644 index 0000000..d187004 --- /dev/null +++ b/core-backend/push.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "gorm.io/gorm" +) + +// FCM legacy HTTP API (server key). When FCM_SERVER_KEY is unset, notifyUser +// records a metric and returns without error so product flows stay usable. +func fcmServerKey() string { + return strings.TrimSpace(os.Getenv("FCM_SERVER_KEY")) +} + +type fcmLegacyPayload struct { + To string `json:"to,omitempty"` + Registration []string `json:"registration_ids,omitempty"` + Priority string `json:"priority"` + Notification map[string]string `json:"notification"` + Data map[string]string `json:"data,omitempty"` +} + +func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]string) (sent int, skippedReason string, err error) { + var tokens []DeviceToken + db.Where("user_id = ?", userID).Find(&tokens) + if len(tokens) == 0 { + return 0, "no_device_tokens", nil + } + + key := fcmServerKey() + if key == "" { + runtimeMetrics.recordPush(0, true) + return 0, "fcm_not_configured", nil + } + + // Skip placeholder install:* tokens — they are not real FCM registration IDs. + regIDs := make([]string, 0, len(tokens)) + for _, t := range tokens { + if strings.HasPrefix(t.Token, "install:") { + continue + } + regIDs = append(regIDs, t.Token) + } + if len(regIDs) == 0 { + runtimeMetrics.recordPush(0, true) + return 0, "only_placeholder_tokens", nil + } + + payload := fcmLegacyPayload{ + Registration: regIDs, + Priority: "high", + Notification: map[string]string{"title": title, "body": body}, + Data: data, + } + raw, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw)) + if err != nil { + return 0, "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "key="+key) + + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Do(req) + if err != nil { + runtimeMetrics.recordPush(0, true) + return 0, "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + runtimeMetrics.recordPush(0, true) + return 0, "", fmt.Errorf("fcm returned %d", resp.StatusCode) + } + runtimeMetrics.recordPush(len(regIDs), false) + return len(regIDs), "", nil +} diff --git a/docs/PLANNING.md b/docs/PLANNING.md index cf86a00..10adf6d 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -143,8 +143,8 @@ 검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md` "말투 검색기" 참고) - [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정 - 4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포). - PoC #3 역할극 진행 시 이 프로토타입을 그대로 자극재로 사용 가능 + 4개 장면. 공유 링크 docs 앵커: [`prototype.md`](./prototype.md) (`bunsin-prototype`). + PoC #3 역할극 자극재로 사용 - [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md` (질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장) - [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) diff --git a/docs/android-release.md b/docs/android-release.md new file mode 100644 index 0000000..26307fa --- /dev/null +++ b/docs/android-release.md @@ -0,0 +1,64 @@ +# Android 릴리즈·내부 배포 경로 (Phase 1 C) + +v1은 **Android만** 대상 (`tech-design.md` §8). Play 스토어 공개가 아니라 +**지인 클로즈드 베타용 내부 APK/AAB** 경로를 먼저 고정한다. + +## 산출물 + +| 산출물 | 명령 | 용도 | +|--------|------|------| +| 내부 APK | `./scripts/build_release_apk.sh` | 직접 설치·메신저 공유 | +| AAB | `./scripts/build_release_aab.sh` | Play 내부 테스트 트랙 (선택) | + +## 서명 키 (Master 로컬 전용) + +1. 키스토어는 **절대 git에 넣지 않는다** (`mobile/android/key.properties`, `*.jks`, `*.keystore`는 gitignore). +2. Master 머신에서 한 번 생성: + +```bash +keytool -genkey -v -keystore ~/bunsin-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias bunsin +``` + +3. `mobile/android/key.properties.example`을 복사해 `mobile/android/key.properties` 작성: + +``` +storePassword=... +keyPassword=... +keyAlias=bunsin +storeFile=/absolute/path/to/bunsin-release.jks +``` + +4. `key.properties`가 없으면 release 빌드는 **디버그 서명으로 폴백**하며 경고를 낸다 + (내부 스모크용). 베타 배포본은 반드시 release keystore로 서명한다. + +## 빌드 + +```bash +cd mobile +flutter pub get +../scripts/build_release_apk.sh +# → build/app/outputs/flutter-apk/app-release.apk + +../scripts/build_release_aab.sh # Play 내부 테스트용 (선택) +``` + +API 베이스 URL은 배포 대상 서버에 맞게: + +```bash +flutter build apk --release \ + --dart-define=CORE_API_BASE=https://api.example.com +``` + +## 배포 채널 (권장 순서) + +1. **직접 APK** — 초대받은 지인에게만 파일 전달 (1차 클로즈드 베타) +2. **Play 내부 테스트** — 규모가 커지면 AAB + 테스터 이메일 목록 +3. 공개 트랙 — Phase 게이트(`vision.md`) 통과 전 금지 + +## 체크리스트 + +- [ ] `key.properties` + jks가 Master 백업에만 존재 +- [ ] `CORE_API_BASE`가 베타 서버를 가리킴 +- [ ] `ADMIN_API_TOKEN` / `GEMINI_API_KEY` / `FCM_SERVER_KEY`가 서버에만 설정 +- [ ] 초대 코드 운영 (`docs/invite-ops.md`) 숙지 +- [ ] 실기기에서 `mobile/README.md` E2E UI 체크 1회 diff --git a/docs/decision-log.md b/docs/decision-log.md index f50ce29..2c783d3 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -1,41 +1,42 @@ # 결정 로그 회의 자료(`idea-meeting-2026-06-29.html`)의 논점 Q1~Q7에 대한 결정을 추적한다. -상태가 **제안**인 항목은 실제 회의에서 확정되지 않았고, 이 문서 이후의 Vision/PRD/기술설계서를 -쓰기 위해 잠정적으로 내린 판단이다. 다음 회의에서 뒤집히면 이 로그와 그 근거로 만들어진 -하위 문서를 함께 갱신한다. (이 "결정 로그" 자체가 회의 자료 §1의 "번복 추적 결정 로그" 아이디어를 -그대로 실천하는 것이기도 하다.) + +**상태 범례** +- **제안**: 문서 작성용 잠정안 (회의 전) +- **확정**: Master가 Phase 1 C(베타 직전)에서 승인·고정한 값. 이후 번복 시 이 로그와 + 근거로 만든 Vision/PRD/기술설계를 함께 갱신한다. | # | 질문 | 결정 | 근거 | 상태 | |---|------|------|------|------| -| Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 제안 | -| Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 제안 | -| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용 | 제안 | -| Q4 | 사칭 우려 대응 충분한가? | **1차로는 충분, 실사용 검증 필요** | 분신 뱃지·거부권·확정 불가 원칙은 설계상 합리적이나, 실제 상대방이 이를 신뢰하는지는 §4 기술 PoC #3(사용자 테스트)로 검증 전까지는 가설 | 제안 | -| Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능해 안전하게 시작 가능 | 제안 | -| Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적이고 정체성을 바로 전달함. 최종 브랜딩/상표 조사는 MVP 검증 이후 진행 | 제안 (가칭) | -| Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2 성장 전략** | 근거는 아래 "Q7 상세 근거" 참고 | 제안 | +| Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 확정 | +| Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 확정 | +| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용. *기본 자율성 레벨(L1 vs L2 시작점)과 화이트리스트 기본 주제는 사람 PoC/실사용 후 확정* | 확정 | +| Q4 | 사칭 우려 대응 충분한가? | **1차 설계로 베타 진입, PoC #3로 검증** | 분신 뱃지·거부권·확정 불가·본인확인 고정 문구는 설계상 필수. 상대 신뢰 여부는 D(사람 PoC #3)에서 측정 | 확정 | +| Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능 | 확정 | +| Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적. 최종 브랜딩/상표는 MVP·베타 반응 이후 | 확정 (가칭 유지) | +| Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2** | 아래 "Q7 상세 근거" | 확정 | + +확정일: 2026-07-30 (Phase 1 C — Master 승인 하에 제안 → 확정). ## Q7 상세 근거 — 왜 자체 앱을 먼저 만드는가 `PLANNING.md` §6에서는 채택 장벽이 낮다는 이유로 OS 레이어(읽기 전용 허브)를 먼저 검증하는 안을 -제시했다. 하지만 실제 실행 순서를 정할 때는 아래 이유로 **자체 앱 클로즈드 베타를 먼저** 두는 것이 더 낫다고 판단한다. +제시했다. 하지만 실제 실행 순서를 정할 때는 아래 이유로 **자체 앱 클로즈드 베타를 먼저** 둔다. - 카카오톡·인스타그램은 자동화/크롤링을 정책상 제한하는 경우가 많아, OS 레이어의 "발송" 쪽은 - 처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설(분신이 자연스럽게 느껴지는가, - 신뢰를 얻는가)을 제3자 플랫폼 제약과 동시에 검증하면 실패 원인을 구분할 수 없다. + 처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설을 제3자 플랫폼 제약과 동시에 + 검증하면 실패 원인을 구분할 수 없다. - 자체 앱(초대 기반 클로즈드 베타)에서는 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히 구현해 통제된 소규모 그룹으로 "이게 정말 쓸만한가"만 순수하게 검증할 수 있다. -- 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능 — - 버리는 작업이 아니다. +- 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능. -**따라서 순서:** ① 자체 앱 클로즈드 베타로 핵심 가설(분신의 자연스러움·신뢰) 검증 → ② 검증되면 -OS 레이어 읽기 전용 허브(문자·이메일 우선, 안드로이드)로 채택 장벽을 낮춰 확산 → ③ 자체 앱은 -L3·L4 등 완전한 기능의 최종 목적지로 유지. +**순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 → +③ 자체 앱은 L3·L4 등 완전한 기능의 최종 목적지로 유지. -## 아직 열려 있는 하위 질문 +## 아직 열려 있는 하위 질문 (PoC/실사용 의존 — D 이후) -- 분신 응답 임계점(어디까지 자동, 어디부터 사람에게) — Q3 확정 후에도 화이트리스트 주제 목록은 - 실사용 데이터로 계속 조정 필요 -- 상표/네이밍 최종안 (Q6은 가칭) -- 베타 참가자 모집 규모와 방식 (§로드맵 참고) +- 분신 응답 임계점·화이트리스트 기본 주제 목록 (실사용 데이터) +- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰) +- 상표/네이밍 최종안 (Q6은 가칭 확정) +- 베타 참가자 모집 규모와 방식 diff --git a/docs/invite-ops.md b/docs/invite-ops.md new file mode 100644 index 0000000..8d4ab5b --- /dev/null +++ b/docs/invite-ops.md @@ -0,0 +1,57 @@ +# 초대 코드 운영 절차 (Phase 1 C) + +클로즈드 베타 가입은 **미리 발급된 1회용 초대 코드**로만 가능하다 +(`POST /auth/signup` → `InviteCode` 검증). + +## 발급자 권한 + +| 역할 | 권한 | +|------|------| +| **발급자 (Issuer)** | Master(또는 Master가 지정한 1명). `ADMIN_API_TOKEN`을 가진 주체만 발급·목록·회수 | +| 일반 사용자 | 초대 발급 불가. 받은 코드로 가입만 가능 | +| CI / 에이전트 | 프로덕션 `ADMIN_API_TOKEN`을 저장·공유하지 않음 | + +토큰은 서버 환경변수 `ADMIN_API_TOKEN`으로만 주입한다. 레포·채팅·커밋에 넣지 않는다. + +## 발급 규칙 + +1. **용도 메모 필수 권장** — `note`에 수신자/채널(예: `지인-김ㅇㅇ`, `PoC#3-참가자3`)을 남긴다. +2. **만료** — 기본 14일. 장기 코드는 만들지 않는다 (`expires_in_days`). +3. **배치** — 한 번에 최대 20개 (`count`). 대량 발급 전 Master 확인. +4. **1코드 = 1가입**. 재사용 불가. 계정 삭제 후에도 코드는 “사용됨”으로 남는다. +5. **회수** — 전달 전 유출·오배포 시 `POST /invites/:code/revoke`. 이미 사용된 코드는 회수 불가. + +## 운영 커맨드 + +```bash +export ADMIN_API_TOKEN=... # 로컬/서버에만 +export CORE=http://127.0.0.1:8080 + +# 단일 발급 (14일, 메모) +curl -sS -X POST "$CORE/invites" \ + -H "Authorization: Bearer $ADMIN_API_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"note":"지인-테스트","expires_in_days":14}' + +# 배치 5개 +curl -sS -X POST "$CORE/invites" \ + -H "Authorization: Bearer $ADMIN_API_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"note":"베타-1차","expires_in_days":14,"count":5}' + +# 목록 +curl -sS "$CORE/invites" -H "Authorization: Bearer $ADMIN_API_TOKEN" + +# 회수 +curl -sS -X POST "$CORE/invites/
/revoke" \
+ -H "Authorization: Bearer $ADMIN_API_TOKEN"
+```
+
+사용 현황은 `GET /admin/metrics`의 `invites_minted` / `invites_used`와 목록의
+`status`(`unused`|`used`|`expired`|`revoked`)로 본다.
+
+## 배포 전 체크
+
+- [ ] 프로덕션 `ADMIN_API_TOKEN` 회전·길이 충분(≥32자 권장)
+- [ ] 초대 코드를 공개 채널(SNS/이슈)에 올리지 않음
+- [ ] 만료·회수된 코드로 가입이 거절되는지 스모크 테스트
diff --git a/docs/meeting-review-summary.md b/docs/meeting-review-summary.md
index 5a387ec..895d4a7 100644
--- a/docs/meeting-review-summary.md
+++ b/docs/meeting-review-summary.md
@@ -16,7 +16,7 @@
| `roadmap.md` | Phase 0~4 게이트 기반 로드맵 | 결정 반영 완료 |
| `poc-plan.md` / `poc-materials.md` | PoC #1·#3 방법론 + 바로 쓸 모집문구·스크립트 | 계획 완료, **실행 전** |
| `user-interview-guide.md` | Q3 위임 의향 인터뷰 (분신 사용자 관점) | 계획 완료, **실행 전** |
-| 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, PoC#3 자극재로 사용 가능 |
+| 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, 앵커 [`prototype.md`](./prototype.md) |
**아직 안 된 것 (사람이 직접 해야 함):** 실제 참가자 모집, 대화 샘플 수집, 역할극/인터뷰 진행,
그 결과의 Go/No-Go 판정. 문서·계획·자극재는 다 준비돼 있어 착수만 하면 된다.
@@ -25,13 +25,13 @@
각 항목을 "확정" 또는 "재검토"로 표시하고, 재검토라면 이유와 다음 액션을 적는다.
-- [ ] **Q1** AI 분신 컨셉 확정? → 잠정: 예
-- [ ] **Q2** 타깃 대중 우선? → 잠정: 예 (B2B는 이후)
-- [ ] **Q3** 자율성 L0~L2만 출시? → 잠정: 예 (단, `user-interview-guide.md` 실행 전이라 근거는 아직 가설)
-- [ ] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 잠정: 1차 설계는 충분, 실사용 검증 필요 (PoC#3 미실행)
-- [ ] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 잠정: 예
-- [ ] **Q6** 서비스 이름 "분신"(가칭)? → 잠정: 가칭, 정식 브랜딩 조사 전
-- [ ] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 잠정: 예
+- [x] **Q1** AI 분신 컨셉 확정? → 확정: 예 (`decision-log.md`)
+- [x] **Q2** 타깃 대중 우선? → 확정: 예 (B2B는 이후)
+- [x] **Q3** 자율성 L0~L2만 출시? → 확정: 예 (기본 레벨·화이트리스트는 PoC/실사용 후)
+- [x] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 확정: 1차 설계로 베타, PoC#3로 검증
+- [x] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 확정: 예
+- [x] **Q6** 서비스 이름 "분신"(가칭)? → 확정: 가칭 유지
+- [x] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 확정: 예
## 회의에서 정할 것 (Q1~Q7 확정 외)
diff --git a/docs/poc-materials.md b/docs/poc-materials.md
index ccd7665..b8313b2 100644
--- a/docs/poc-materials.md
+++ b/docs/poc-materials.md
@@ -34,6 +34,7 @@
`bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다.
+공식 링크 앵커: [`prototype.md`](./prototype.md) (공유 URL은 그 파일의 `SHARE_URL`).
### 시나리오 E — 감정적인 대화 (에스컬레이션 케이스, 프로토타입 미포함)
diff --git a/docs/prototype.md b/docs/prototype.md
new file mode 100644
index 0000000..1609159
--- /dev/null
+++ b/docs/prototype.md
@@ -0,0 +1,36 @@
+# 클릭 프로토타입 (PoC #3 자극재) — 공유 링크 고정
+
+Phase 1 C: PoC #3 역할극에서 쓰는 **클릭 프로토타입**의 공식 참조 위치다.
+장면: 읽씹 종결 · 본인확인 · 거부권 · 에스컬레이션 (+ 자율성 설정).
+
+## 공식 링크
+
+| 항목 | 값 |
+|------|-----|
+| **식별자** | `bunsin-prototype` (`poc-materials.md` §2와 동일) |
+| **공유 URL** | **TBD — Master 기입** (Claude Artifacts 공개 permalink 또는 정적 호스팅 URL) |
+| **docs 앵커** | 이 파일. 다른 문서는 프로토타입을 말할 때 여기로 링크한다 |
+
+URL을 받으면 아래 한 줄만 채운다 (추측으로 가짜 링크를 넣지 않는다):
+
+```
+SHARE_URL=
+```
+
+현재 값:
+
+```
+SHARE_URL=
+```
+
+## 사용처
+
+- [`poc-plan.md`](./poc-plan.md) — PoC #3 자극재
+- [`poc-materials.md`](./poc-materials.md) §2 — 역할극 시연 순서
+- [`meeting-review-summary.md`](./meeting-review-summary.md) — 회의 산출물 표
+
+## 갱신 규칙
+
+1. 프로토타입 장면을 바꾸면 이 파일에 변경일·요약 한 줄을 추가한다.
+2. `SHARE_URL`이 비어 있거나 404면 PoC #3 / 외부 공유 전에 반드시 채운다.
+3. 호스팅을 옮겨도 식별자 `bunsin-prototype`과 이 앵커 문서는 유지한다.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 1a8e786..da3b70d 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -58,26 +58,27 @@
생성은 이미 있는 `/draft` 흐름을 그대로 쓰면 되므로 새로 만들지 않았고, 화이트리스트는
`ContactID`(상대별) 무시하고 전역 키워드 매칭만 지원 — 대화방↔연락처 연결 모델이 아직 없어서
(Flutter 클라이언트의 연락처 모델이 생긴 뒤 다시 설계 필요, `core-backend/README.md` 참고)
-- [ ] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현
+- [x] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 — drift+SQLCipher(`mobile/lib/db/`),
+ draft에 샘플만 전달, `DataFlowScreen`으로 원칙 노출
- [x] 사후 알림 + 되돌리기 로그 스키마/API — `escalation_logs`는 이미 쌓임(사후 알림용 로그).
되돌리기(one-tap undo, AGENTS.md 안전 불변식)는 `Message.Retracted` 필드 +
`POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400,
이미 되돌린 건 409, 성공하면 같은 대화방 WebSocket에 `{"type":"retraction", "id":...}` 브로드캐스트.
일반 메시지 브로드캐스트도 `"type":"message"`를 붙여 클라이언트가 두 이벤트를 구분하게 함.
- **되돌리기 UI는 `mobile/`에 있음** — 사후알림 전용 함/푸시는 아직.
- 에스컬레이션 로그를 "조회"하는 API는 아직 없음(필요해지면 추가)
+ **되돌리기 UI는 `mobile/`에 있음**. 사후알림 함은 A3에서 `InboxScreen` +
+ `GET /users/:id/escalation-logs`로 연결. FCM 푸시는 B.
**2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — `mobile/`
-- [x] 기본 채팅 UI (대화 목록, 대화방) — `mobile/` 골격. 대화방 목록 API는 아직 없어 ID 직접
- 입장. WebSocket 수신·인간 메시지 전송 연결됨
-- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 초대 코드 가입 화면만 있음. 말투 학습 UX 디테일은
- **사람 PoC #1 결과를 맨 마지막에 반영** (지금은 뼈대만)
+- [x] 기본 채팅 UI (대화 목록, 대화방) — `GET`/`POST /conversations`, 히스토리 로드,
+ WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작
+- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`,
+ 기기 로컬). 말투 학습 UX 디테일·카피는 **사람 PoC #1 결과를 맨 마지막에 반영**
- [x] 분신 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
서버/AI 프롬프트 측과 이어서 다듬을 것
-- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외 매칭은
- 서버가 전역 키워드만 지원하는 동안 UI도 주제 키워드 CRUD만
-- [x] 에스컬레이션 배너·되돌리기 UI — 초안 escalate 배너 + twin 메시지 되돌리기 버튼.
- 사후알림 전용 함/푸시는 아직 없음
+- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는
+ 서버 contact-scoped 매칭과 함께 화이트리스트 CRUD
+- [x] 에스컬레이션·L1 승인·되돌리기 UI — L1 수정/버리기/재초안/승인 패널 + twin 되돌리기 +
+ 사후알림 함. FCM 푸시는 B
**2.4 안전장치 통합** (전 구간 필수, 타협 불가)
- [x] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 — 실제 발견한 우회
@@ -97,7 +98,7 @@
무조건 차단되는 것까지 테스트로 확인함
- [~] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 — 서버 쪽 삭제
플로우(`DELETE /users/:id`, 유저가 걸린 모든 행을 트랜잭션으로 삭제)만 완료·테스트함. 온디바이스
- 암호화(drift+SQLCipher)와 실시간 데이터 흐름 대시보드는 Flutter(`mobile/`) 쪽 후속 작업
+ 암호화(drift+SQLCipher)와 데이터 흐름 UI는 Flutter(`mobile/`) B에서 반영
**2.5 QA/테스트**
- [x] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도
@@ -114,8 +115,8 @@
- [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라
실제로는 초대 기반이 아니었음. `InviteCode` 테이블 + `POST /invites`(발급) 추가하고
`/auth/signup`이 미리 발급된 미사용 코드인지 검증하도록 변경(모르는 코드 400, 이미 쓴 코드
- 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. **아직 없는 것**: 발급자
- 인증(`/invites`를 지금은 누구나 호출 가능 — 세션/인증 도입 시 같이 잠글 것)
+ 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. 발급자 인증은
+ `ADMIN_API_TOKEN`(A2). 운영 절차·만료/회수는 C (`docs/invite-ops.md`)
- [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은
`/admin/metrics`의 `peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움
피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기
@@ -147,14 +148,15 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
-4. [~] 2.3 Flutter 클라이언트 — `mobile/` 골격 착수 완료(가입·채팅·뱃지·거부권·되돌리기·자율성
- 설정). 남은 것: 대화방/연락처 목록 API 연동, 온보딩 말투 UX, drift+SQLCipher, 수동 QA
+4. [~] 2.3 Flutter 클라이언트 — A3 + B(drift/SQLCipher·데이터흐름·세션) 완료 + API E2E.
+ 남은 것: 실제 Firebase FCM 토큰, Android UI 탭(`mobile/README.md`), C 배포 준비
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료.
- 클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 2.3 나머지와 함께
+ 클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
§3(사람 PoC) 이후**
- 5-2. [x] 화이트리스트 규칙 CRUD API
-6. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 2.6 실제 베타 오픈 (**맨 마지막**)
+6. [x] Phase 1 C 베타 직전 — Q1~Q7 확정, 초대 운영, 프로토타입 앵커, Android 릴리즈 경로
+7. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 실제 베타 오픈 (**맨 마지막 / D**)
#### 5. 앞으로의 개발 계획 (우선순위 체크리스트)
@@ -176,27 +178,37 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
-- [ ] 대화 목록/연락처 UI를 서버 API에 연결
-- [ ] 온보딩 뼈대 확장 (말투 샘플 입력 UI)
-- [ ] L1 승인 플로우 UX 정리
-- [ ] 사후 알림 함
-- [ ] 에뮬레이터/실기기 E2E 수동 QA
+- [x] 대화 목록/연락처 UI를 서버 API에 연결
+- [x] 온보딩 뼈대 확장 (말투 샘플 입력 UI)
+- [x] L1 승인 플로우 UX 정리
+- [x] 사후 알림 함
+- [x] E2E QA — `scripts/e2e_a3.py`로 A3 HTTP 플로우 16/16 통과(가입·연락처·대화·히스토리·
+ draft/L1·에스컬레이션·알림 로그·되돌리기·거부권·화이트리스트). `go test`/`pytest`/`flutter test`
+ 동시 통과. Android 에뮬레이터 UI 탭은 이 환경에 SDK가 없어 체크리스트는 `mobile/README.md`에 유지
##### B. 그다음 — 베타 품질
-- [ ] FCM 푸시 연동
-- [ ] 멀티 디바이스 동기화
-- [ ] drift + SQLCipher 로컬 저장
-- [ ] 말투 이력 기기 내 저장 + 서버 최소 전송
-- [ ] 데이터 흐름 표시 UI
-- [ ] 생성 지연시간·오류율 계측
-- [ ] 모니터링 대시보드(최소)
-- [ ] 본인확인 응답 문구 고정/검증
+- [~] FCM 푸시 연동 — 토큰 등록 + 에스컬레이션 시 `notifyUser` + `POST /admin/push-test`.
+ `FCM_SERVER_KEY`와 실제 FCM registration token이 있으면 전송, 없으면 soft-skip.
+ Flutter는 install-id 플레이스홀더 등록(Firebase Messaging 앱 키는 배포 환경에서 교체)
+- [~] 멀티 디바이스 동기화 — 세션 목록 + 세션 종료(`DELETE /users/:id/sessions/:id`).
+ 메시지 히스토리 서버 동기화는 이미 REST/WS; 오프라인 큐는 후속
+- [x] drift + SQLCipher 로컬 저장 — `mobile/lib/db/` (말투 샘플·KV). 키는
+ `flutter_secure_storage`. Linux CI는 SQLCipher SO 없으면 메모리 폴백
+- [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — drift 암호화 저장, draft에 샘플만 전달
+- [x] 데이터 흐름 표시 UI — `DataFlowScreen`
+- [x] 생성 지연시간·오류율 계측 — process-local `RuntimeMetrics` → `/admin/metrics`
+- [x] 모니터링 대시보드(최소) — `GET /admin/dashboard`
+- [x] 본인확인 응답 문구 고정/검증 — `ai-service/app/identity.py` + draft 경로 테스트
##### C. 베타 직전
-- [ ] Android 릴리즈 빌드·서명·배포 경로
-- [ ] 초대 코드 운영 절차(발급자 권한)
-- [ ] Q1~Q7 회의 확정 (제안 → 확정)
-- [ ] 클릭 프로토타입 공유 링크 docs 고정
+- [x] Android 릴리즈 빌드·서명·배포 경로 — `docs/android-release.md`,
+ `scripts/build_release_apk.sh` / `build_release_aab.sh`, `key.properties` 서명 훅.
+ keystore는 Master 로컬 전용(내부 APK 우선)
+- [x] 초대 코드 운영 절차(발급자 권한) — `docs/invite-ops.md` + note/만료/배치/회수 API
+ (`GET|POST /invites`, `POST /invites/:code/revoke`)
+- [x] Q1~Q7 회의 확정 (제안 → 확정) — `docs/decision-log.md` (2026-07-30). PoC 의존 하위
+ 질문만 열어둠
+- [x] 클릭 프로토타입 공유 링크 docs 고정 — `docs/prototype.md` 앵커. `SHARE_URL`은 Master 기입
##### D. 맨 마지막 — 사람 PoC (지금 안 함)
- §3 항목과 동일. A~C 완료 후에만 착수.
diff --git a/docs/tech-design.md b/docs/tech-design.md
index 3e3ce4a..a193e57 100644
--- a/docs/tech-design.md
+++ b/docs/tech-design.md
@@ -130,7 +130,7 @@ Python으로 남긴다.
| 서비스 간 통신 | Go 코어 → Python AI 서비스, 내부망 HTTP(REST) | 처음부터 gRPC 등으로 과설계하지 않음 — 필요해지면 그때 전환 |
| 메시지 릴레이 | Go 코어 서비스 내 WebSocket | 자체 서버로 충분한 규모. Kafka·관리형 pub-sub은 지금 시점에 과한 인프라 |
| 데이터베이스 | PostgreSQL | users/contacts/conversations/messages/escalation_logs/whitelist_rules 관계형 스키마에 적합. Go 쪽 접근은 `pgx`나 GORM |
-| 온디바이스 저장소 | Flutter `drift`(SQLite) + `sqlcipher_flutter_libs` 암호화 | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙을 그대로 구현 |
+| 온디바이스 저장소 | Flutter `drift` + `sqlcipher_flutter_libs` (`PRAGMA key`, 패스프레이즈는 `flutter_secure_storage`) | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙. 구현: `mobile/lib/db/` |
| Gemini API 키 관리 | Python AI 서비스에서만 보관 (Go 코어는 키를 안 가짐) | `poc/tone-corpus/.env`는 PoC 전용 — 프로덕션 키·쿼터는 AI 서비스 환경에서만 분리 관리 |
이 표 밖의 결정(자율성 기본값, 화이트리스트 기본 주제, 신뢰 UX 문구)은 `roadmap.md` Phase 1 §3에
diff --git a/mobile/README.md b/mobile/README.md
index b575924..22b0501 100644
--- a/mobile/README.md
+++ b/mobile/README.md
@@ -1,16 +1,19 @@
# 분신 mobile (Flutter)
-Phase 1 클라이언트 골격 (`docs/roadmap.md` §2.3). **v1은 Android 빌드만** 대상으로 한다
+Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). **v1은 Android 빌드만** 대상으로 한다
(`docs/tech-design.md` §8).
## 지금 있는 것
-- 초대 코드 가입 (`POST /auth/signup`)
-- 대화방 입장(임시: 대화방 ID 직접 입력 — 목록 API는 아직 core-backend에 없음)
-- 채팅 전송 + WebSocket 수신
-- 분신 초안 요청 / L1 승인 발송
-- 분신 뱃지(점선 + 라벨) · 거부권 · 되돌리기
-- 자율성 L0~L2 설정 + 화이트리스트 CRUD UI
+- 초대 코드 가입 (`POST /auth/signup`) + Bearer 세션
+- 말투 샘플 온보딩(기기 로컬 저장; 최종 카피는 PoC 이후)
+- 대화방 목록/생성 (`GET`/`POST /conversations`) — 수동 ID 입력 제거
+- 연락처 CRUD + 대화 시작 (`/users/:id/contacts`)
+- 채팅 히스토리 로드 + WebSocket 수신
+- 분신 초안 / L1 승인(수정·버리기·재초안·승인 발송)
+- 분신 뱃지 · 거부권 · 되돌리기
+- 사후 알림 함 (`GET /users/:id/escalation-logs`)
+- 자율성 L0~L2 + 화이트리스트 CRUD UI
## 실행
@@ -26,10 +29,43 @@ flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
flutter run --dart-define=CORE_API_BASE=http://:8080
```
+## E2E
+
+**API 레벨 (클라우드/CI에서 실행 가능)**
+
+```bash
+# core-backend :8080 + ai-service :8001 기동 후
+export ADMIN_API_TOKEN=dev-admin-token
+python3 scripts/e2e_a3.py
+```
+
+A3 체크리스트(가입·연락처·대화·히스토리·draft/L1·에스컬레이션·되돌리기·거부권·화이트리스트)를
+HTTP로 검증한다.
+
+**에뮬레이터/실기기 UI 탭 (Android SDK 필요)**
+
+1. 초대 코드로 가입 → 말투 샘플 저장(또는 나중에)
+2. 연락처에 상대 사용자 ID 등록 → 대화 시작
+3. 메시지 전송 · 새로고침 후 히스토리 유지
+4. 초안 요청 → L1 패널에서 수정/버리기/승인 발송 → 분신 뱃지 확인
+5. 민감 문장으로 에스컬레이션 → 사후 알림 함 진입
+6. 분신 메시지 되돌리기 · 거부권
+7. 자율성 L0~L2 + 화이트리스트 추가/삭제
+
+## 로컬 DB (drift + SQLCipher)
+
+말투 샘플·온보딩 플래그는 `lib/db/` 암호화 SQLite에 저장한다. 패스프레이즈는
+`flutter_secure_storage`. Linux 개발 호스트에 `libsqlcipher.so`가 없으면 메모리 폴백으로
+기동한다(Android 릴리즈 경로에서는 SQLCipher 사용).
+
+```bash
+# Linux에서 drift 테스트/암호화 DB를 쓰려면
+sudo apt-get install -y libsqlite3-dev libsqlcipher1
+```
+
## 아직 없는 것
-- 대화방/연락처 목록 API 연동 (서버에 엔드포인트 추가 필요)
-- 온보딩 말투 학습 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
-- drift + SQLCipher 온디바이스 저장
-- 푸시 알림
+- Firebase 프로젝트의 실제 FCM registration token 연동 (`google-services.json`)
+- 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화
+- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
- iOS 빌드 (v1 범위 밖)
diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore
index be3943c..a291f3c 100644
--- a/mobile/android/.gitignore
+++ b/mobile/android/.gitignore
@@ -12,3 +12,5 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
+*.jks
+*.keystore
diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts
index 9ed1068..5617d6a 100644
--- a/mobile/android/app/build.gradle.kts
+++ b/mobile/android/app/build.gradle.kts
@@ -1,3 +1,6 @@
+import java.io.FileInputStream
+import java.util.Properties
+
plugins {
id("com.android.application")
id("kotlin-android")
@@ -5,6 +8,13 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
+val keystoreProperties = Properties()
+val keystorePropertiesFile = rootProject.file("key.properties")
+val hasReleaseKeystore = keystorePropertiesFile.exists()
+if (hasReleaseKeystore) {
+ keystoreProperties.load(FileInputStream(keystorePropertiesFile))
+}
+
android {
namespace = "com.bunsin.bunsin_mobile"
compileSdk = flutter.compileSdkVersion
@@ -20,21 +30,33 @@ android {
}
defaultConfig {
- // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.bunsin.bunsin_mobile"
- // You can update the following values to match your application needs.
- // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
+ signingConfigs {
+ if (hasReleaseKeystore) {
+ create("release") {
+ keyAlias = keystoreProperties["keyAlias"] as String
+ keyPassword = keystoreProperties["keyPassword"] as String
+ storeFile = file(keystoreProperties["storeFile"] as String)
+ storePassword = keystoreProperties["storePassword"] as String
+ }
+ }
+ }
+
buildTypes {
release {
- // TODO: Add your own signing config for the release build.
- // Signing with the debug keys for now, so `flutter run --release` works.
- signingConfig = signingConfigs.getByName("debug")
+ // Prefer release keystore when key.properties exists; else debug
+ // (smoke only — see docs/android-release.md).
+ signingConfig = if (hasReleaseKeystore) {
+ signingConfigs.getByName("release")
+ } else {
+ signingConfigs.getByName("debug")
+ }
}
}
}
diff --git a/mobile/android/key.properties.example b/mobile/android/key.properties.example
new file mode 100644
index 0000000..7565a5b
--- /dev/null
+++ b/mobile/android/key.properties.example
@@ -0,0 +1,4 @@
+storePassword=replace-me
+keyPassword=replace-me
+keyAlias=bunsin
+storeFile=/absolute/path/to/bunsin-release.jks
diff --git a/mobile/lib/db/app_database.dart b/mobile/lib/db/app_database.dart
new file mode 100644
index 0000000..d8b8a20
--- /dev/null
+++ b/mobile/lib/db/app_database.dart
@@ -0,0 +1,128 @@
+import 'dart:ffi';
+import 'dart:io';
+import 'dart:math';
+
+import 'package:drift/drift.dart';
+import 'package:drift/native.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
+import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
+import 'package:sqlite3/open.dart';
+
+import 'tables.dart';
+
+part 'app_database.g.dart';
+
+@DriftDatabase(tables: [ToneSamples, LocalKv])
+class AppDatabase extends _$AppDatabase {
+ AppDatabase(super.e);
+
+ @override
+ int get schemaVersion => 1;
+
+ /// In-memory DB for unit tests (no SQLCipher / filesystem).
+ factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory());
+
+ /// Production opener: encrypted on-device file (tech-design.md §8).
+ static Future open() async {
+ final db = AppDatabase(_openEncryptedExecutor());
+ // Force open so missing SQLCipher SO fails here (caller can fall back).
+ await db.customSelect('SELECT 1').get();
+ return db;
+ }
+
+ Future> loadToneSamples() async {
+ final rows = await (select(toneSamples)..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])).get();
+ return rows.map((r) => r.sampleText).toList();
+ }
+
+ Future replaceToneSamples(List samples) async {
+ final now = DateTime.now().millisecondsSinceEpoch;
+ await transaction(() async {
+ await delete(toneSamples).go();
+ for (var i = 0; i < samples.length; i++) {
+ await into(toneSamples).insert(
+ ToneSamplesCompanion.insert(
+ id: 'tone_${i}_$now',
+ sampleText: samples[i],
+ createdAtMs: now,
+ sortOrder: i,
+ ),
+ );
+ }
+ });
+ }
+
+ Future getKv(String key) async {
+ final row = await (select(localKv)..where((t) => t.key.equals(key))).getSingleOrNull();
+ return row?.value;
+ }
+
+ Future setKv(String key, String value) async {
+ await into(localKv).insertOnConflictUpdate(LocalKvCompanion.insert(key: key, value: value));
+ }
+
+ Future getBoolKv(String key, {bool defaultValue = false}) async {
+ final v = await getKv(key);
+ if (v == null) return defaultValue;
+ return v == '1' || v.toLowerCase() == 'true';
+ }
+
+ Future setBoolKv(String key, bool value) async {
+ await setKv(key, value ? '1' : '0');
+ }
+}
+
+const _kDbPassphrase = 'db_passphrase_v1';
+
+QueryExecutor _openEncryptedExecutor() {
+ return LazyDatabase(() async {
+ final dir = await getApplicationDocumentsDirectory();
+ final file = File(p.join(dir.path, 'bunsin_encrypted.db'));
+ final passphrase = await _loadOrCreatePassphrase();
+
+ // Background isolate does not inherit open.overrideFor — re-apply there.
+ final token = RootIsolateToken.instance;
+ return NativeDatabase.createInBackground(
+ file,
+ isolateSetup: () async {
+ if (token != null) {
+ BackgroundIsolateBinaryMessenger.ensureInitialized(token);
+ }
+ await _configureSqlCipherOpen();
+ },
+ setup: (rawDb) {
+ // SQLCipher key must be set before any other statement.
+ final escaped = passphrase.replaceAll("'", "''");
+ rawDb.execute("PRAGMA key = '$escaped'");
+ rawDb.config.doubleQuotedStringLiterals = false;
+ },
+ );
+ });
+}
+
+Future _configureSqlCipherOpen() async {
+ if (Platform.isAndroid) {
+ await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
+ open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
+ } else if (Platform.isLinux) {
+ // Desktop/dev: try SQLCipher SO; fall back is handled by open() failure upstream.
+ open.overrideFor(OperatingSystem.linux, () => DynamicLibrary.open('libsqlcipher.so'));
+ } else if (Platform.isWindows) {
+ open.overrideFor(OperatingSystem.windows, () => DynamicLibrary.open('sqlcipher.dll'));
+ }
+ // iOS/macOS: sqlcipher_flutter_libs links into the process.
+}
+
+Future _loadOrCreatePassphrase() async {
+ const storage = FlutterSecureStorage();
+ final existing = await storage.read(key: _kDbPassphrase);
+ if (existing != null && existing.isNotEmpty) return existing;
+ final rand = Random.secure();
+ final bytes = List.generate(32, (_) => rand.nextInt(256));
+ final passphrase = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
+ await storage.write(key: _kDbPassphrase, value: passphrase);
+ return passphrase;
+}
diff --git a/mobile/lib/db/app_database.g.dart b/mobile/lib/db/app_database.g.dart
new file mode 100644
index 0000000..b64050c
--- /dev/null
+++ b/mobile/lib/db/app_database.g.dart
@@ -0,0 +1,873 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'app_database.dart';
+
+// ignore_for_file: type=lint
+class $ToneSamplesTable extends ToneSamples
+ with TableInfo<$ToneSamplesTable, ToneSample> {
+ @override
+ final GeneratedDatabase attachedDatabase;
+ final String? _alias;
+ $ToneSamplesTable(this.attachedDatabase, [this._alias]);
+ static const VerificationMeta _idMeta = const VerificationMeta('id');
+ @override
+ late final GeneratedColumn id = GeneratedColumn(
+ 'id',
+ aliasedName,
+ false,
+ type: DriftSqlType.string,
+ requiredDuringInsert: true,
+ );
+ static const VerificationMeta _sampleTextMeta = const VerificationMeta(
+ 'sampleText',
+ );
+ @override
+ late final GeneratedColumn sampleText = GeneratedColumn(
+ 'sample_text',
+ aliasedName,
+ false,
+ type: DriftSqlType.string,
+ requiredDuringInsert: true,
+ );
+ static const VerificationMeta _createdAtMsMeta = const VerificationMeta(
+ 'createdAtMs',
+ );
+ @override
+ late final GeneratedColumn createdAtMs = GeneratedColumn(
+ 'created_at_ms',
+ aliasedName,
+ false,
+ type: DriftSqlType.int,
+ requiredDuringInsert: true,
+ );
+ static const VerificationMeta _sortOrderMeta = const VerificationMeta(
+ 'sortOrder',
+ );
+ @override
+ late final GeneratedColumn sortOrder = GeneratedColumn(
+ 'sort_order',
+ aliasedName,
+ false,
+ type: DriftSqlType.int,
+ requiredDuringInsert: true,
+ );
+ @override
+ List get $columns => [
+ id,
+ sampleText,
+ createdAtMs,
+ sortOrder,
+ ];
+ @override
+ String get aliasedName => _alias ?? actualTableName;
+ @override
+ String get actualTableName => $name;
+ static const String $name = 'tone_samples';
+ @override
+ VerificationContext validateIntegrity(
+ Insertable instance, {
+ bool isInserting = false,
+ }) {
+ final context = VerificationContext();
+ final data = instance.toColumns(true);
+ if (data.containsKey('id')) {
+ context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
+ } else if (isInserting) {
+ context.missing(_idMeta);
+ }
+ if (data.containsKey('sample_text')) {
+ context.handle(
+ _sampleTextMeta,
+ sampleText.isAcceptableOrUnknown(data['sample_text']!, _sampleTextMeta),
+ );
+ } else if (isInserting) {
+ context.missing(_sampleTextMeta);
+ }
+ if (data.containsKey('created_at_ms')) {
+ context.handle(
+ _createdAtMsMeta,
+ createdAtMs.isAcceptableOrUnknown(
+ data['created_at_ms']!,
+ _createdAtMsMeta,
+ ),
+ );
+ } else if (isInserting) {
+ context.missing(_createdAtMsMeta);
+ }
+ if (data.containsKey('sort_order')) {
+ context.handle(
+ _sortOrderMeta,
+ sortOrder.isAcceptableOrUnknown(data['sort_order']!, _sortOrderMeta),
+ );
+ } else if (isInserting) {
+ context.missing(_sortOrderMeta);
+ }
+ return context;
+ }
+
+ @override
+ Set get $primaryKey => {id};
+ @override
+ ToneSample map(Map data, {String? tablePrefix}) {
+ final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
+ return ToneSample(
+ id: attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}id'],
+ )!,
+ sampleText: attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}sample_text'],
+ )!,
+ createdAtMs: attachedDatabase.typeMapping.read(
+ DriftSqlType.int,
+ data['${effectivePrefix}created_at_ms'],
+ )!,
+ sortOrder: attachedDatabase.typeMapping.read(
+ DriftSqlType.int,
+ data['${effectivePrefix}sort_order'],
+ )!,
+ );
+ }
+
+ @override
+ $ToneSamplesTable createAlias(String alias) {
+ return $ToneSamplesTable(attachedDatabase, alias);
+ }
+}
+
+class ToneSample extends DataClass implements Insertable {
+ final String id;
+ final String sampleText;
+ final int createdAtMs;
+ final int sortOrder;
+ const ToneSample({
+ required this.id,
+ required this.sampleText,
+ required this.createdAtMs,
+ required this.sortOrder,
+ });
+ @override
+ Map toColumns(bool nullToAbsent) {
+ final map = {};
+ map['id'] = Variable(id);
+ map['sample_text'] = Variable(sampleText);
+ map['created_at_ms'] = Variable(createdAtMs);
+ map['sort_order'] = Variable(sortOrder);
+ return map;
+ }
+
+ ToneSamplesCompanion toCompanion(bool nullToAbsent) {
+ return ToneSamplesCompanion(
+ id: Value(id),
+ sampleText: Value(sampleText),
+ createdAtMs: Value(createdAtMs),
+ sortOrder: Value(sortOrder),
+ );
+ }
+
+ factory ToneSample.fromJson(
+ Map json, {
+ ValueSerializer? serializer,
+ }) {
+ serializer ??= driftRuntimeOptions.defaultSerializer;
+ return ToneSample(
+ id: serializer.fromJson(json['id']),
+ sampleText: serializer.fromJson(json['sampleText']),
+ createdAtMs: serializer.fromJson(json['createdAtMs']),
+ sortOrder: serializer.fromJson(json['sortOrder']),
+ );
+ }
+ @override
+ Map toJson({ValueSerializer? serializer}) {
+ serializer ??= driftRuntimeOptions.defaultSerializer;
+ return {
+ 'id': serializer.toJson(id),
+ 'sampleText': serializer.toJson(sampleText),
+ 'createdAtMs': serializer.toJson(createdAtMs),
+ 'sortOrder': serializer.toJson(sortOrder),
+ };
+ }
+
+ ToneSample copyWith({
+ String? id,
+ String? sampleText,
+ int? createdAtMs,
+ int? sortOrder,
+ }) => ToneSample(
+ id: id ?? this.id,
+ sampleText: sampleText ?? this.sampleText,
+ createdAtMs: createdAtMs ?? this.createdAtMs,
+ sortOrder: sortOrder ?? this.sortOrder,
+ );
+ ToneSample copyWithCompanion(ToneSamplesCompanion data) {
+ return ToneSample(
+ id: data.id.present ? data.id.value : this.id,
+ sampleText: data.sampleText.present
+ ? data.sampleText.value
+ : this.sampleText,
+ createdAtMs: data.createdAtMs.present
+ ? data.createdAtMs.value
+ : this.createdAtMs,
+ sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder,
+ );
+ }
+
+ @override
+ String toString() {
+ return (StringBuffer('ToneSample(')
+ ..write('id: $id, ')
+ ..write('sampleText: $sampleText, ')
+ ..write('createdAtMs: $createdAtMs, ')
+ ..write('sortOrder: $sortOrder')
+ ..write(')'))
+ .toString();
+ }
+
+ @override
+ int get hashCode => Object.hash(id, sampleText, createdAtMs, sortOrder);
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ (other is ToneSample &&
+ other.id == this.id &&
+ other.sampleText == this.sampleText &&
+ other.createdAtMs == this.createdAtMs &&
+ other.sortOrder == this.sortOrder);
+}
+
+class ToneSamplesCompanion extends UpdateCompanion {
+ final Value id;
+ final Value sampleText;
+ final Value createdAtMs;
+ final Value sortOrder;
+ final Value rowid;
+ const ToneSamplesCompanion({
+ this.id = const Value.absent(),
+ this.sampleText = const Value.absent(),
+ this.createdAtMs = const Value.absent(),
+ this.sortOrder = const Value.absent(),
+ this.rowid = const Value.absent(),
+ });
+ ToneSamplesCompanion.insert({
+ required String id,
+ required String sampleText,
+ required int createdAtMs,
+ required int sortOrder,
+ this.rowid = const Value.absent(),
+ }) : id = Value(id),
+ sampleText = Value(sampleText),
+ createdAtMs = Value(createdAtMs),
+ sortOrder = Value(sortOrder);
+ static Insertable custom({
+ Expression? id,
+ Expression? sampleText,
+ Expression? createdAtMs,
+ Expression? sortOrder,
+ Expression? rowid,
+ }) {
+ return RawValuesInsertable({
+ if (id != null) 'id': id,
+ if (sampleText != null) 'sample_text': sampleText,
+ if (createdAtMs != null) 'created_at_ms': createdAtMs,
+ if (sortOrder != null) 'sort_order': sortOrder,
+ if (rowid != null) 'rowid': rowid,
+ });
+ }
+
+ ToneSamplesCompanion copyWith({
+ Value? id,
+ Value? sampleText,
+ Value? createdAtMs,
+ Value? sortOrder,
+ Value? rowid,
+ }) {
+ return ToneSamplesCompanion(
+ id: id ?? this.id,
+ sampleText: sampleText ?? this.sampleText,
+ createdAtMs: createdAtMs ?? this.createdAtMs,
+ sortOrder: sortOrder ?? this.sortOrder,
+ rowid: rowid ?? this.rowid,
+ );
+ }
+
+ @override
+ Map toColumns(bool nullToAbsent) {
+ final map = {};
+ if (id.present) {
+ map['id'] = Variable(id.value);
+ }
+ if (sampleText.present) {
+ map['sample_text'] = Variable(sampleText.value);
+ }
+ if (createdAtMs.present) {
+ map['created_at_ms'] = Variable(createdAtMs.value);
+ }
+ if (sortOrder.present) {
+ map['sort_order'] = Variable(sortOrder.value);
+ }
+ if (rowid.present) {
+ map['rowid'] = Variable(rowid.value);
+ }
+ return map;
+ }
+
+ @override
+ String toString() {
+ return (StringBuffer('ToneSamplesCompanion(')
+ ..write('id: $id, ')
+ ..write('sampleText: $sampleText, ')
+ ..write('createdAtMs: $createdAtMs, ')
+ ..write('sortOrder: $sortOrder, ')
+ ..write('rowid: $rowid')
+ ..write(')'))
+ .toString();
+ }
+}
+
+class $LocalKvTable extends LocalKv with TableInfo<$LocalKvTable, LocalKvData> {
+ @override
+ final GeneratedDatabase attachedDatabase;
+ final String? _alias;
+ $LocalKvTable(this.attachedDatabase, [this._alias]);
+ static const VerificationMeta _keyMeta = const VerificationMeta('key');
+ @override
+ late final GeneratedColumn key = GeneratedColumn(
+ 'key',
+ aliasedName,
+ false,
+ type: DriftSqlType.string,
+ requiredDuringInsert: true,
+ );
+ static const VerificationMeta _valueMeta = const VerificationMeta('value');
+ @override
+ late final GeneratedColumn value = GeneratedColumn(
+ 'value',
+ aliasedName,
+ false,
+ type: DriftSqlType.string,
+ requiredDuringInsert: true,
+ );
+ @override
+ List get $columns => [key, value];
+ @override
+ String get aliasedName => _alias ?? actualTableName;
+ @override
+ String get actualTableName => $name;
+ static const String $name = 'local_kv';
+ @override
+ VerificationContext validateIntegrity(
+ Insertable instance, {
+ bool isInserting = false,
+ }) {
+ final context = VerificationContext();
+ final data = instance.toColumns(true);
+ if (data.containsKey('key')) {
+ context.handle(
+ _keyMeta,
+ key.isAcceptableOrUnknown(data['key']!, _keyMeta),
+ );
+ } else if (isInserting) {
+ context.missing(_keyMeta);
+ }
+ if (data.containsKey('value')) {
+ context.handle(
+ _valueMeta,
+ value.isAcceptableOrUnknown(data['value']!, _valueMeta),
+ );
+ } else if (isInserting) {
+ context.missing(_valueMeta);
+ }
+ return context;
+ }
+
+ @override
+ Set get $primaryKey => {key};
+ @override
+ LocalKvData map(Map data, {String? tablePrefix}) {
+ final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
+ return LocalKvData(
+ key: attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}key'],
+ )!,
+ value: attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}value'],
+ )!,
+ );
+ }
+
+ @override
+ $LocalKvTable createAlias(String alias) {
+ return $LocalKvTable(attachedDatabase, alias);
+ }
+}
+
+class LocalKvData extends DataClass implements Insertable {
+ final String key;
+ final String value;
+ const LocalKvData({required this.key, required this.value});
+ @override
+ Map toColumns(bool nullToAbsent) {
+ final map = {};
+ map['key'] = Variable(key);
+ map['value'] = Variable(value);
+ return map;
+ }
+
+ LocalKvCompanion toCompanion(bool nullToAbsent) {
+ return LocalKvCompanion(key: Value(key), value: Value(value));
+ }
+
+ factory LocalKvData.fromJson(
+ Map json, {
+ ValueSerializer? serializer,
+ }) {
+ serializer ??= driftRuntimeOptions.defaultSerializer;
+ return LocalKvData(
+ key: serializer.fromJson(json['key']),
+ value: serializer.fromJson(json['value']),
+ );
+ }
+ @override
+ Map toJson({ValueSerializer? serializer}) {
+ serializer ??= driftRuntimeOptions.defaultSerializer;
+ return {
+ 'key': serializer.toJson(key),
+ 'value': serializer.toJson(value),
+ };
+ }
+
+ LocalKvData copyWith({String? key, String? value}) =>
+ LocalKvData(key: key ?? this.key, value: value ?? this.value);
+ LocalKvData copyWithCompanion(LocalKvCompanion data) {
+ return LocalKvData(
+ key: data.key.present ? data.key.value : this.key,
+ value: data.value.present ? data.value.value : this.value,
+ );
+ }
+
+ @override
+ String toString() {
+ return (StringBuffer('LocalKvData(')
+ ..write('key: $key, ')
+ ..write('value: $value')
+ ..write(')'))
+ .toString();
+ }
+
+ @override
+ int get hashCode => Object.hash(key, value);
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ (other is LocalKvData &&
+ other.key == this.key &&
+ other.value == this.value);
+}
+
+class LocalKvCompanion extends UpdateCompanion {
+ final Value key;
+ final Value value;
+ final Value rowid;
+ const LocalKvCompanion({
+ this.key = const Value.absent(),
+ this.value = const Value.absent(),
+ this.rowid = const Value.absent(),
+ });
+ LocalKvCompanion.insert({
+ required String key,
+ required String value,
+ this.rowid = const Value.absent(),
+ }) : key = Value(key),
+ value = Value(value);
+ static Insertable custom({
+ Expression? key,
+ Expression? value,
+ Expression? rowid,
+ }) {
+ return RawValuesInsertable({
+ if (key != null) 'key': key,
+ if (value != null) 'value': value,
+ if (rowid != null) 'rowid': rowid,
+ });
+ }
+
+ LocalKvCompanion copyWith({
+ Value? key,
+ Value? value,
+ Value? rowid,
+ }) {
+ return LocalKvCompanion(
+ key: key ?? this.key,
+ value: value ?? this.value,
+ rowid: rowid ?? this.rowid,
+ );
+ }
+
+ @override
+ Map toColumns(bool nullToAbsent) {
+ final map = {};
+ if (key.present) {
+ map['key'] = Variable(key.value);
+ }
+ if (value.present) {
+ map['value'] = Variable(value.value);
+ }
+ if (rowid.present) {
+ map['rowid'] = Variable(rowid.value);
+ }
+ return map;
+ }
+
+ @override
+ String toString() {
+ return (StringBuffer('LocalKvCompanion(')
+ ..write('key: $key, ')
+ ..write('value: $value, ')
+ ..write('rowid: $rowid')
+ ..write(')'))
+ .toString();
+ }
+}
+
+abstract class _$AppDatabase extends GeneratedDatabase {
+ _$AppDatabase(QueryExecutor e) : super(e);
+ $AppDatabaseManager get managers => $AppDatabaseManager(this);
+ late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this);
+ late final $LocalKvTable localKv = $LocalKvTable(this);
+ @override
+ Iterable> get allTables =>
+ allSchemaEntities.whereType>();
+ @override
+ List get allSchemaEntities => [toneSamples, localKv];
+}
+
+typedef $$ToneSamplesTableCreateCompanionBuilder =
+ ToneSamplesCompanion Function({
+ required String id,
+ required String sampleText,
+ required int createdAtMs,
+ required int sortOrder,
+ Value rowid,
+ });
+typedef $$ToneSamplesTableUpdateCompanionBuilder =
+ ToneSamplesCompanion Function({
+ Value id,
+ Value sampleText,
+ Value createdAtMs,
+ Value sortOrder,
+ Value rowid,
+ });
+
+class $$ToneSamplesTableFilterComposer
+ extends Composer<_$AppDatabase, $ToneSamplesTable> {
+ $$ToneSamplesTableFilterComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ ColumnFilters get id => $composableBuilder(
+ column: $table.id,
+ builder: (column) => ColumnFilters(column),
+ );
+
+ ColumnFilters get sampleText => $composableBuilder(
+ column: $table.sampleText,
+ builder: (column) => ColumnFilters(column),
+ );
+
+ ColumnFilters get createdAtMs => $composableBuilder(
+ column: $table.createdAtMs,
+ builder: (column) => ColumnFilters(column),
+ );
+
+ ColumnFilters get sortOrder => $composableBuilder(
+ column: $table.sortOrder,
+ builder: (column) => ColumnFilters(column),
+ );
+}
+
+class $$ToneSamplesTableOrderingComposer
+ extends Composer<_$AppDatabase, $ToneSamplesTable> {
+ $$ToneSamplesTableOrderingComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ ColumnOrderings get id => $composableBuilder(
+ column: $table.id,
+ builder: (column) => ColumnOrderings(column),
+ );
+
+ ColumnOrderings get sampleText => $composableBuilder(
+ column: $table.sampleText,
+ builder: (column) => ColumnOrderings(column),
+ );
+
+ ColumnOrderings get createdAtMs => $composableBuilder(
+ column: $table.createdAtMs,
+ builder: (column) => ColumnOrderings(column),
+ );
+
+ ColumnOrderings get sortOrder => $composableBuilder(
+ column: $table.sortOrder,
+ builder: (column) => ColumnOrderings(column),
+ );
+}
+
+class $$ToneSamplesTableAnnotationComposer
+ extends Composer<_$AppDatabase, $ToneSamplesTable> {
+ $$ToneSamplesTableAnnotationComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ GeneratedColumn get id =>
+ $composableBuilder(column: $table.id, builder: (column) => column);
+
+ GeneratedColumn get sampleText => $composableBuilder(
+ column: $table.sampleText,
+ builder: (column) => column,
+ );
+
+ GeneratedColumn get createdAtMs => $composableBuilder(
+ column: $table.createdAtMs,
+ builder: (column) => column,
+ );
+
+ GeneratedColumn get sortOrder =>
+ $composableBuilder(column: $table.sortOrder, builder: (column) => column);
+}
+
+class $$ToneSamplesTableTableManager
+ extends
+ RootTableManager<
+ _$AppDatabase,
+ $ToneSamplesTable,
+ ToneSample,
+ $$ToneSamplesTableFilterComposer,
+ $$ToneSamplesTableOrderingComposer,
+ $$ToneSamplesTableAnnotationComposer,
+ $$ToneSamplesTableCreateCompanionBuilder,
+ $$ToneSamplesTableUpdateCompanionBuilder,
+ (
+ ToneSample,
+ BaseReferences<_$AppDatabase, $ToneSamplesTable, ToneSample>,
+ ),
+ ToneSample,
+ PrefetchHooks Function()
+ > {
+ $$ToneSamplesTableTableManager(_$AppDatabase db, $ToneSamplesTable table)
+ : super(
+ TableManagerState(
+ db: db,
+ table: table,
+ createFilteringComposer: () =>
+ $$ToneSamplesTableFilterComposer($db: db, $table: table),
+ createOrderingComposer: () =>
+ $$ToneSamplesTableOrderingComposer($db: db, $table: table),
+ createComputedFieldComposer: () =>
+ $$ToneSamplesTableAnnotationComposer($db: db, $table: table),
+ updateCompanionCallback:
+ ({
+ Value id = const Value.absent(),
+ Value sampleText = const Value.absent(),
+ Value createdAtMs = const Value.absent(),
+ Value sortOrder = const Value.absent(),
+ Value rowid = const Value.absent(),
+ }) => ToneSamplesCompanion(
+ id: id,
+ sampleText: sampleText,
+ createdAtMs: createdAtMs,
+ sortOrder: sortOrder,
+ rowid: rowid,
+ ),
+ createCompanionCallback:
+ ({
+ required String id,
+ required String sampleText,
+ required int createdAtMs,
+ required int sortOrder,
+ Value rowid = const Value.absent(),
+ }) => ToneSamplesCompanion.insert(
+ id: id,
+ sampleText: sampleText,
+ createdAtMs: createdAtMs,
+ sortOrder: sortOrder,
+ rowid: rowid,
+ ),
+ withReferenceMapper: (p0) => p0
+ .map((e) => (e.readTable(table), BaseReferences(db, table, e)))
+ .toList(),
+ prefetchHooksCallback: null,
+ ),
+ );
+}
+
+typedef $$ToneSamplesTableProcessedTableManager =
+ ProcessedTableManager<
+ _$AppDatabase,
+ $ToneSamplesTable,
+ ToneSample,
+ $$ToneSamplesTableFilterComposer,
+ $$ToneSamplesTableOrderingComposer,
+ $$ToneSamplesTableAnnotationComposer,
+ $$ToneSamplesTableCreateCompanionBuilder,
+ $$ToneSamplesTableUpdateCompanionBuilder,
+ (
+ ToneSample,
+ BaseReferences<_$AppDatabase, $ToneSamplesTable, ToneSample>,
+ ),
+ ToneSample,
+ PrefetchHooks Function()
+ >;
+typedef $$LocalKvTableCreateCompanionBuilder =
+ LocalKvCompanion Function({
+ required String key,
+ required String value,
+ Value rowid,
+ });
+typedef $$LocalKvTableUpdateCompanionBuilder =
+ LocalKvCompanion Function({
+ Value key,
+ Value value,
+ Value rowid,
+ });
+
+class $$LocalKvTableFilterComposer
+ extends Composer<_$AppDatabase, $LocalKvTable> {
+ $$LocalKvTableFilterComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ ColumnFilters get key => $composableBuilder(
+ column: $table.key,
+ builder: (column) => ColumnFilters(column),
+ );
+
+ ColumnFilters get value => $composableBuilder(
+ column: $table.value,
+ builder: (column) => ColumnFilters(column),
+ );
+}
+
+class $$LocalKvTableOrderingComposer
+ extends Composer<_$AppDatabase, $LocalKvTable> {
+ $$LocalKvTableOrderingComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ ColumnOrderings get key => $composableBuilder(
+ column: $table.key,
+ builder: (column) => ColumnOrderings(column),
+ );
+
+ ColumnOrderings get value => $composableBuilder(
+ column: $table.value,
+ builder: (column) => ColumnOrderings(column),
+ );
+}
+
+class $$LocalKvTableAnnotationComposer
+ extends Composer<_$AppDatabase, $LocalKvTable> {
+ $$LocalKvTableAnnotationComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ GeneratedColumn get key =>
+ $composableBuilder(column: $table.key, builder: (column) => column);
+
+ GeneratedColumn get value =>
+ $composableBuilder(column: $table.value, builder: (column) => column);
+}
+
+class $$LocalKvTableTableManager
+ extends
+ RootTableManager<
+ _$AppDatabase,
+ $LocalKvTable,
+ LocalKvData,
+ $$LocalKvTableFilterComposer,
+ $$LocalKvTableOrderingComposer,
+ $$LocalKvTableAnnotationComposer,
+ $$LocalKvTableCreateCompanionBuilder,
+ $$LocalKvTableUpdateCompanionBuilder,
+ (
+ LocalKvData,
+ BaseReferences<_$AppDatabase, $LocalKvTable, LocalKvData>,
+ ),
+ LocalKvData,
+ PrefetchHooks Function()
+ > {
+ $$LocalKvTableTableManager(_$AppDatabase db, $LocalKvTable table)
+ : super(
+ TableManagerState(
+ db: db,
+ table: table,
+ createFilteringComposer: () =>
+ $$LocalKvTableFilterComposer($db: db, $table: table),
+ createOrderingComposer: () =>
+ $$LocalKvTableOrderingComposer($db: db, $table: table),
+ createComputedFieldComposer: () =>
+ $$LocalKvTableAnnotationComposer($db: db, $table: table),
+ updateCompanionCallback:
+ ({
+ Value key = const Value.absent(),
+ Value value = const Value.absent(),
+ Value rowid = const Value.absent(),
+ }) => LocalKvCompanion(key: key, value: value, rowid: rowid),
+ createCompanionCallback:
+ ({
+ required String key,
+ required String value,
+ Value rowid = const Value.absent(),
+ }) =>
+ LocalKvCompanion.insert(key: key, value: value, rowid: rowid),
+ withReferenceMapper: (p0) => p0
+ .map((e) => (e.readTable(table), BaseReferences(db, table, e)))
+ .toList(),
+ prefetchHooksCallback: null,
+ ),
+ );
+}
+
+typedef $$LocalKvTableProcessedTableManager =
+ ProcessedTableManager<
+ _$AppDatabase,
+ $LocalKvTable,
+ LocalKvData,
+ $$LocalKvTableFilterComposer,
+ $$LocalKvTableOrderingComposer,
+ $$LocalKvTableAnnotationComposer,
+ $$LocalKvTableCreateCompanionBuilder,
+ $$LocalKvTableUpdateCompanionBuilder,
+ (LocalKvData, BaseReferences<_$AppDatabase, $LocalKvTable, LocalKvData>),
+ LocalKvData,
+ PrefetchHooks Function()
+ >;
+
+class $AppDatabaseManager {
+ final _$AppDatabase _db;
+ $AppDatabaseManager(this._db);
+ $$ToneSamplesTableTableManager get toneSamples =>
+ $$ToneSamplesTableTableManager(_db, _db.toneSamples);
+ $$LocalKvTableTableManager get localKv =>
+ $$LocalKvTableTableManager(_db, _db.localKv);
+}
diff --git a/mobile/lib/db/tables.dart b/mobile/lib/db/tables.dart
new file mode 100644
index 0000000..9ae5ada
--- /dev/null
+++ b/mobile/lib/db/tables.dart
@@ -0,0 +1,21 @@
+import 'package:drift/drift.dart';
+
+/// On-device tone / style samples (tech-design.md §2 — never upload the full corpus).
+class ToneSamples extends Table {
+ TextColumn get id => text()();
+ TextColumn get sampleText => text()();
+ IntColumn get createdAtMs => integer()();
+ IntColumn get sortOrder => integer()();
+
+ @override
+ Set> get primaryKey => {id};
+}
+
+/// Small key/value bag for onboarding flags and local prefs that must stay encrypted.
+class LocalKv extends Table {
+ TextColumn get key => text()();
+ TextColumn get value => text()();
+
+ @override
+ Set> get primaryKey => {key};
+}
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index 1e3c25e..3b35f5c 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'screens/conversation_list_screen.dart';
+import 'screens/onboarding_tone_screen.dart';
import 'screens/signup_screen.dart';
import 'state/session_state.dart';
@@ -33,6 +34,7 @@ class BunsinApp extends StatelessWidget {
home: Consumer(
builder: (context, s, _) {
if (s.user == null) return const SignupScreen();
+ if (!s.toneOnboardingDone) return const OnboardingToneScreen();
return const ConversationListScreen();
},
),
diff --git a/mobile/lib/models/models.dart b/mobile/lib/models/models.dart
index d639edc..926fe90 100644
--- a/mobile/lib/models/models.dart
+++ b/mobile/lib/models/models.dart
@@ -34,6 +34,89 @@ class TwinSettings {
}
}
+class ConversationSummary {
+ ConversationSummary({
+ required this.id,
+ required this.isGroup,
+ required this.userIds,
+ required this.twinDisabledByPeer,
+ this.createdAt,
+ });
+
+ final int id;
+ final bool isGroup;
+ final List userIds;
+ final bool twinDisabledByPeer;
+ final DateTime? createdAt;
+
+ factory ConversationSummary.fromJson(Map json) {
+ final rawIds = json['user_ids'] as List? ?? const [];
+ return ConversationSummary(
+ id: json['id'] as int,
+ isGroup: json['is_group'] as bool? ?? false,
+ userIds: rawIds.map((e) => e as int).toList(),
+ twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false,
+ createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''),
+ );
+ }
+
+ /// Title helper when peer display names are not yet loaded.
+ String titleFor(int myUserId) {
+ final peers = userIds.where((id) => id != myUserId).toList();
+ if (isGroup) return '그룹 #$id';
+ if (peers.isEmpty) return '나와의 대화 #$id';
+ return '대화 · 상대 #${peers.first}';
+ }
+}
+
+class Contact {
+ Contact({
+ required this.id,
+ required this.displayName,
+ this.contactUserId,
+ this.relationshipNote = '',
+ });
+
+ final int id;
+ final String displayName;
+ final int? contactUserId;
+ final String relationshipNote;
+
+ factory Contact.fromJson(Map json) => Contact(
+ id: json['id'] as int,
+ displayName: json['display_name'] as String? ?? '',
+ contactUserId: json['contact_user_id'] as int?,
+ relationshipNote: json['relationship_note'] as String? ?? '',
+ );
+}
+
+class EscalationLogEntry {
+ EscalationLogEntry({
+ required this.id,
+ required this.conversationId,
+ required this.reason,
+ required this.messageSnippet,
+ required this.resolved,
+ required this.createdAt,
+ });
+
+ final int id;
+ final int conversationId;
+ final String reason;
+ final String messageSnippet;
+ final bool resolved;
+ final DateTime createdAt;
+
+ factory EscalationLogEntry.fromJson(Map json) => EscalationLogEntry(
+ id: json['id'] as int,
+ conversationId: json['conversation_id'] as int? ?? 0,
+ reason: json['reason'] as String? ?? '',
+ messageSnippet: json['message_snippet'] as String? ?? '',
+ resolved: json['resolved'] as bool? ?? false,
+ createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
+ );
+}
+
class ChatMessage {
ChatMessage({
required this.id,
diff --git a/mobile/lib/screens/autonomy_settings_screen.dart b/mobile/lib/screens/autonomy_settings_screen.dart
index b718537..9954a05 100644
--- a/mobile/lib/screens/autonomy_settings_screen.dart
+++ b/mobile/lib/screens/autonomy_settings_screen.dart
@@ -4,6 +4,8 @@ import 'package:provider/provider.dart';
import '../models/models.dart';
import '../services/api_client.dart';
import '../state/session_state.dart';
+import 'data_flow_screen.dart';
+import 'sessions_screen.dart';
class AutonomySettingsScreen extends StatefulWidget {
const AutonomySettingsScreen({super.key});
@@ -56,6 +58,25 @@ class _AutonomySettingsScreenState extends State {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
+ ListTile(
+ contentPadding: EdgeInsets.zero,
+ leading: const Icon(Icons.privacy_tip_outlined),
+ title: const Text('데이터 흐름'),
+ subtitle: const Text('무엇이 기기에 남고 서버로 가는지'),
+ onTap: () {
+ Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen()));
+ },
+ ),
+ ListTile(
+ contentPadding: EdgeInsets.zero,
+ leading: const Icon(Icons.devices),
+ title: const Text('로그인 세션'),
+ subtitle: const Text('멀티 디바이스 세션 목록'),
+ onTap: () {
+ Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen()));
+ },
+ ),
+ const Divider(height: 32),
Text('전역 레벨', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SegmentedButton(
diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart
index 0c6c537..0e62e99 100644
--- a/mobile/lib/screens/chat_screen.dart
+++ b/mobile/lib/screens/chat_screen.dart
@@ -10,9 +10,10 @@ import '../state/session_state.dart';
import '../widgets/message_bubble.dart';
class ChatScreen extends StatefulWidget {
- const ChatScreen({super.key, required this.conversationId});
+ const ChatScreen({super.key, required this.conversationId, this.title});
final int conversationId;
+ final String? title;
@override
State createState() => _ChatScreenState();
@@ -20,18 +21,54 @@ class ChatScreen extends StatefulWidget {
class _ChatScreenState extends State {
final _input = TextEditingController();
+ final _draftEdit = TextEditingController();
+ final _scroll = ScrollController();
final _messages = [];
ConversationSocket? _socket;
StreamSubscription? _sub;
String? _banner;
DraftResult? _pendingDraft;
bool _busy = false;
+ bool _loadingHistory = true;
@override
void initState() {
super.initState();
_socket = ConversationSocket(widget.conversationId)..connect();
_sub = _socket!.events.listen(_onEvent);
+ _loadHistory();
+ }
+
+ Future _loadHistory() async {
+ final session = context.read();
+ try {
+ final history = await session.api.listMessages(widget.conversationId);
+ if (!mounted) return;
+ setState(() {
+ _messages
+ ..clear()
+ ..addAll(history);
+ _loadingHistory = false;
+ });
+ _scrollToEnd();
+ } on ApiException catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _loadingHistory = false;
+ _banner = '히스토리 로드 실패 (${e.statusCode})';
+ });
+ }
+ }
+
+ void _scrollToEnd() {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!_scroll.hasClients) return;
+ _scroll.animateTo(
+ _scroll.position.maxScrollExtent,
+ duration: const Duration(milliseconds: 200),
+ curve: Curves.easeOut,
+ );
+ });
}
void _onEvent(Map event) {
@@ -58,6 +95,7 @@ class _ChatScreenState extends State {
if (msg == null) return;
if (_messages.any((m) => m.id == msg.id)) return;
setState(() => _messages.add(msg));
+ _scrollToEnd();
}
@override
@@ -65,6 +103,8 @@ class _ChatScreenState extends State {
_sub?.cancel();
_socket?.dispose();
_input.dispose();
+ _draftEdit.dispose();
+ _scroll.dispose();
super.dispose();
}
@@ -83,6 +123,7 @@ class _ChatScreenState extends State {
if (!_messages.any((m) => m.id == msg.id)) {
setState(() => _messages.add(msg));
}
+ _scrollToEnd();
} on ApiException catch (e) {
setState(() => _banner = '전송 실패 (${e.statusCode})');
} finally {
@@ -105,15 +146,19 @@ class _ChatScreenState extends State {
if (_input.text.trim().isNotEmpty) {
contextLines.add('상대: ${_input.text.trim()}');
}
+ final style = session.styleExamples.isNotEmpty
+ ? session.styleExamples
+ : const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게'];
final draft = await session.api.requestDraft(
conversationId: widget.conversationId,
contextLines: contextLines.isEmpty ? ['상대: 안녕'] : contextLines,
- styleExamples: const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게'],
+ styleExamples: style,
);
setState(() {
_pendingDraft = draft;
+ _draftEdit.text = draft.isEscalate ? '' : draft.text;
if (draft.isEscalate) {
- _banner = '에스컬레이션: ${draft.text}';
+ _banner = null;
}
});
} on ApiException catch (e) {
@@ -127,19 +172,23 @@ class _ChatScreenState extends State {
final session = context.read();
final draft = _pendingDraft;
if (draft == null || draft.isEscalate || session.user == null) return;
+ final text = _draftEdit.text.trim();
+ if (text.isEmpty) return;
setState(() => _busy = true);
try {
final msg = await session.api.sendMessage(
conversationId: widget.conversationId,
senderId: session.user!.id,
- text: draft.text,
+ text: text,
senderMode: SenderMode.twin,
approved: true,
);
setState(() {
_pendingDraft = null;
+ _draftEdit.clear();
if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg);
});
+ _scrollToEnd();
} on ApiException catch (e) {
setState(() => _banner = '분신 발송 차단 (${e.statusCode}): ${e.body}');
} finally {
@@ -147,6 +196,14 @@ class _ChatScreenState extends State {
}
}
+ void _rejectDraft() {
+ setState(() {
+ _pendingDraft = null;
+ _draftEdit.clear();
+ _banner = '초안을 버렸습니다. 직접 쓰거나 다시 초안을 요청하세요.';
+ });
+ }
+
Future _veto() async {
final session = context.read();
try {
@@ -166,6 +223,84 @@ class _ChatScreenState extends State {
}
}
+ Widget _buildL1Panel(BuildContext context) {
+ final theme = Theme.of(context);
+ final draft = _pendingDraft;
+ if (draft == null) return const SizedBox.shrink();
+
+ if (draft.isEscalate) {
+ return Material(
+ color: theme.colorScheme.errorContainer,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text('직접 확인 필요', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700)),
+ const SizedBox(height: 4),
+ Text(draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text),
+ const SizedBox(height: 8),
+ Align(
+ alignment: Alignment.centerRight,
+ child: TextButton(onPressed: _rejectDraft, child: const Text('닫기')),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ return Material(
+ elevation: 2,
+ color: theme.colorScheme.secondaryContainer,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Row(
+ children: [
+ Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer),
+ const SizedBox(width: 6),
+ Text(
+ 'L1 승인 — 수정 후 보내기',
+ style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ TextField(
+ controller: _draftEdit,
+ minLines: 2,
+ maxLines: 5,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ filled: true,
+ fillColor: Colors.white70,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ TextButton(onPressed: _busy ? null : _rejectDraft, child: const Text('버리기')),
+ const Spacer(),
+ FilledButton.tonal(
+ onPressed: _busy ? null : _requestDraft,
+ child: const Text('다시 초안'),
+ ),
+ const SizedBox(width: 8),
+ FilledButton(
+ onPressed: _busy ? null : _sendTwinApproved,
+ child: const Text('승인하고 보내기'),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
@override
Widget build(BuildContext context) {
final session = context.watch();
@@ -173,7 +308,7 @@ class _ChatScreenState extends State {
return Scaffold(
appBar: AppBar(
- title: Text('대화방 #${widget.conversationId}'),
+ title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
actions: [
TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')),
],
@@ -188,31 +323,23 @@ class _ChatScreenState extends State {
],
),
Expanded(
- child: ListView.builder(
- padding: const EdgeInsets.symmetric(vertical: 8),
- itemCount: _messages.length,
- itemBuilder: (context, i) {
- final m = _messages[i];
- return MessageBubble(
- message: m,
- isMine: me != null && m.senderId == me,
- onRetract: m.isTwin ? () => _retract(m) : null,
- );
- },
- ),
+ child: _loadingHistory
+ ? const Center(child: CircularProgressIndicator())
+ : ListView.builder(
+ controller: _scroll,
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ itemCount: _messages.length,
+ itemBuilder: (context, i) {
+ final m = _messages[i];
+ return MessageBubble(
+ message: m,
+ isMine: me != null && m.senderId == me,
+ onRetract: m.isTwin ? () => _retract(m) : null,
+ );
+ },
+ ),
),
- if (_pendingDraft != null && !_pendingDraft!.isEscalate)
- Material(
- color: Theme.of(context).colorScheme.secondaryContainer,
- child: ListTile(
- title: const Text('분신 초안 (L1 승인)'),
- subtitle: Text(_pendingDraft!.text),
- trailing: FilledButton(
- onPressed: _busy ? null : _sendTwinApproved,
- child: const Text('보내기'),
- ),
- ),
- ),
+ _buildL1Panel(context),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
diff --git a/mobile/lib/screens/contacts_screen.dart b/mobile/lib/screens/contacts_screen.dart
new file mode 100644
index 0000000..ec86ac3
--- /dev/null
+++ b/mobile/lib/screens/contacts_screen.dart
@@ -0,0 +1,174 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../models/models.dart';
+import '../services/api_client.dart';
+import '../state/session_state.dart';
+import 'chat_screen.dart';
+
+class ContactsScreen extends StatefulWidget {
+ const ContactsScreen({super.key});
+
+ @override
+ State createState() => _ContactsScreenState();
+}
+
+class _ContactsScreenState extends State {
+ List _contacts = [];
+ bool _loading = true;
+ String? _error;
+
+ @override
+ void initState() {
+ super.initState();
+ _load();
+ }
+
+ Future _load() async {
+ final session = context.read();
+ if (session.user == null) return;
+ setState(() {
+ _loading = true;
+ _error = null;
+ });
+ try {
+ final list = await session.api.listContacts(session.user!.id);
+ setState(() => _contacts = list);
+ } on ApiException catch (e) {
+ setState(() => _error = '연락처 로드 실패 (${e.statusCode})');
+ } finally {
+ setState(() => _loading = false);
+ }
+ }
+
+ Future _showAddDialog() async {
+ final nameCtrl = TextEditingController();
+ final peerCtrl = TextEditingController();
+ final noteCtrl = TextEditingController();
+ final ok = await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('연락처 추가'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: nameCtrl,
+ decoration: const InputDecoration(labelText: '표시 이름', border: OutlineInputBorder()),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ controller: peerCtrl,
+ keyboardType: TextInputType.number,
+ decoration: const InputDecoration(
+ labelText: '상대 사용자 ID (선택)',
+ helperText: '대화 시작에 필요',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ controller: noteCtrl,
+ decoration: const InputDecoration(labelText: '관계 메모 (선택)', border: OutlineInputBorder()),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
+ FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('추가')),
+ ],
+ ),
+ );
+ if (ok != true || !mounted) return;
+ final session = context.read();
+ final name = nameCtrl.text.trim();
+ if (name.isEmpty || session.user == null) return;
+ try {
+ final peer = int.tryParse(peerCtrl.text.trim());
+ final created = await session.api.createContact(
+ userId: session.user!.id,
+ displayName: name,
+ contactUserId: peer,
+ relationshipNote: noteCtrl.text.trim(),
+ );
+ setState(() => _contacts = [..._contacts, created]);
+ } on ApiException catch (e) {
+ setState(() => _error = '추가 실패 (${e.statusCode})');
+ }
+ }
+
+ Future _startChat(Contact contact) async {
+ final session = context.read();
+ final me = session.user;
+ if (me == null || contact.contactUserId == null) {
+ setState(() => _error = '상대 사용자 ID가 있는 연락처만 대화를 시작할 수 있습니다.');
+ return;
+ }
+ try {
+ final conv = await session.api.createConversation(
+ userIds: [me.id, contact.contactUserId!],
+ contactId: contact.id,
+ );
+ if (!mounted) return;
+ await Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id, title: contact.displayName)),
+ );
+ } on ApiException catch (e) {
+ setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}');
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('연락처')),
+ floatingActionButton: FloatingActionButton(
+ onPressed: _showAddDialog,
+ child: const Icon(Icons.person_add_alt_1),
+ ),
+ body: RefreshIndicator(
+ onRefresh: _load,
+ child: _loading
+ ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
+ : ListView(
+ children: [
+ if (_error != null)
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
+ ),
+ if (_contacts.isEmpty)
+ const Padding(
+ padding: EdgeInsets.all(24),
+ child: Text('연락처가 없습니다. + 버튼으로 추가하세요.'),
+ ),
+ for (final c in _contacts)
+ ListTile(
+ leading: const CircleAvatar(child: Icon(Icons.person_outline)),
+ title: Text(c.displayName),
+ subtitle: Text(
+ [
+ if (c.contactUserId != null) '사용자 #${c.contactUserId}',
+ if (c.relationshipNote.isNotEmpty) c.relationshipNote,
+ ].join(' · '),
+ ),
+ trailing: c.contactUserId == null
+ ? null
+ : TextButton(onPressed: () => _startChat(c), child: const Text('대화')),
+ onLongPress: () async {
+ final session = context.read();
+ if (session.user == null) return;
+ await session.api.deleteContact(session.user!.id, c.id);
+ setState(() => _contacts = _contacts.where((x) => x.id != c.id).toList());
+ },
+ ),
+ const Padding(
+ padding: EdgeInsets.all(16),
+ child: Text('길게 누르면 삭제됩니다.', textAlign: TextAlign.center),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart
index 9b31bb1..2003b4e 100644
--- a/mobile/lib/screens/conversation_list_screen.dart
+++ b/mobile/lib/screens/conversation_list_screen.dart
@@ -1,12 +1,15 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
+import '../models/models.dart';
+import '../services/api_client.dart';
import '../state/session_state.dart';
import 'autonomy_settings_screen.dart';
import 'chat_screen.dart';
+import 'contacts_screen.dart';
+import 'inbox_screen.dart';
+import 'onboarding_tone_screen.dart';
-/// v1 scaffold: conversation list is local until list API exists on core-backend.
-/// Enter a conversation id to open a room (matches current Go API shape).
class ConversationListScreen extends StatefulWidget {
const ConversationListScreen({super.key});
@@ -15,24 +18,103 @@ class ConversationListScreen extends StatefulWidget {
}
class _ConversationListScreenState extends State {
- final _convId = TextEditingController(text: '1');
- final _rooms = {1};
+ List _rooms = [];
+ bool _loading = true;
+ String? _error;
@override
- void dispose() {
- _convId.dispose();
- super.dispose();
+ void initState() {
+ super.initState();
+ _load();
+ }
+
+ Future _load() async {
+ final session = context.read();
+ setState(() {
+ _loading = true;
+ _error = null;
+ });
+ try {
+ final list = await session.api.listConversations();
+ list.sort((a, b) => b.id.compareTo(a.id));
+ setState(() => _rooms = list);
+ } on ApiException catch (e) {
+ setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}');
+ } finally {
+ setState(() => _loading = false);
+ }
+ }
+
+ Future _createConversation() async {
+ final peerCtrl = TextEditingController();
+ final ok = await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('새 대화'),
+ content: TextField(
+ controller: peerCtrl,
+ keyboardType: TextInputType.number,
+ autofocus: true,
+ decoration: const InputDecoration(
+ labelText: '상대 사용자 ID',
+ helperText: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
+ FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('만들기')),
+ ],
+ ),
+ );
+ if (ok != true || !mounted) return;
+ final session = context.read();
+ final me = session.user;
+ final peer = int.tryParse(peerCtrl.text.trim());
+ if (me == null || peer == null) return;
+ try {
+ final conv = await session.api.createConversation(userIds: [me.id, peer]);
+ if (!mounted) return;
+ await Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id)),
+ );
+ await _load();
+ } on ApiException catch (e) {
+ setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}');
+ }
}
@override
Widget build(BuildContext context) {
final session = context.watch();
- final rooms = _rooms.toList()..sort();
+ final me = session.user?.id;
return Scaffold(
appBar: AppBar(
title: Text('분신 · ${session.user?.displayName ?? ''}'),
actions: [
+ IconButton(
+ tooltip: '사후 알림',
+ onPressed: () {
+ Navigator.of(context).push(MaterialPageRoute(builder: (_) => const InboxScreen()));
+ },
+ icon: const Icon(Icons.notifications_none),
+ ),
+ IconButton(
+ tooltip: '연락처',
+ onPressed: () async {
+ await Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ContactsScreen()));
+ await _load();
+ },
+ icon: const Icon(Icons.contacts_outlined),
+ ),
+ IconButton(
+ tooltip: '말투 샘플',
+ onPressed: () {
+ Navigator.of(context).push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen()));
+ },
+ icon: const Icon(Icons.record_voice_over_outlined),
+ ),
IconButton(
tooltip: '자율성 설정',
onPressed: () {
@@ -44,47 +126,52 @@ class _ConversationListScreenState extends State {
),
],
),
- body: ListView(
- children: [
- Padding(
- padding: const EdgeInsets.all(16),
- child: Row(
- children: [
- Expanded(
- child: TextField(
- controller: _convId,
- keyboardType: TextInputType.number,
- decoration: const InputDecoration(
- labelText: '대화방 ID',
- border: OutlineInputBorder(),
- helperText: 'core-backend에 대화방 목록 API가 생기기 전 임시 진입',
+ floatingActionButton: FloatingActionButton(
+ onPressed: _createConversation,
+ child: const Icon(Icons.chat),
+ ),
+ body: RefreshIndicator(
+ onRefresh: _load,
+ child: _loading
+ ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
+ : ListView(
+ children: [
+ if (_error != null)
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
- ),
- ),
- const SizedBox(width: 8),
- FilledButton(
- onPressed: () {
- final id = int.tryParse(_convId.text.trim());
- if (id == null) return;
- setState(() => _rooms.add(id));
- },
- child: const Text('추가'),
- ),
- ],
- ),
- ),
- for (final id in rooms)
- ListTile(
- leading: const CircleAvatar(child: Icon(Icons.chat_bubble_outline)),
- title: Text('대화방 #$id'),
- subtitle: const Text('탭해서 입장'),
- onTap: () {
- Navigator.of(context).push(
- MaterialPageRoute(builder: (_) => ChatScreen(conversationId: id)),
- );
- },
- ),
- ],
+ if (_rooms.isEmpty)
+ const Padding(
+ padding: EdgeInsets.all(24),
+ child: Text('대화방이 없습니다. + 버튼이나 연락처에서 대화를 시작하세요.'),
+ ),
+ for (final room in _rooms)
+ ListTile(
+ leading: CircleAvatar(
+ child: Icon(room.isGroup ? Icons.groups_outlined : Icons.chat_bubble_outline),
+ ),
+ title: Text(me == null ? '대화방 #${room.id}' : room.titleFor(me)),
+ subtitle: Text(
+ [
+ 'ID ${room.id}',
+ if (room.twinDisabledByPeer) '상대가 분신 거부',
+ ].join(' · '),
+ ),
+ onTap: () async {
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ChatScreen(
+ conversationId: room.id,
+ title: me == null ? null : room.titleFor(me),
+ ),
+ ),
+ );
+ await _load();
+ },
+ ),
+ ],
+ ),
),
);
}
diff --git a/mobile/lib/screens/data_flow_screen.dart b/mobile/lib/screens/data_flow_screen.dart
new file mode 100644
index 0000000..ab994a8
--- /dev/null
+++ b/mobile/lib/screens/data_flow_screen.dart
@@ -0,0 +1,63 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../state/session_state.dart';
+
+/// Shows what stays on-device vs what may leave the device (roadmap B / privacy).
+class DataFlowScreen extends StatelessWidget {
+ const DataFlowScreen({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final session = context.watch();
+ final theme = Theme.of(context);
+ final samples = session.styleExamples;
+
+ return Scaffold(
+ appBar: AppBar(title: const Text('데이터 흐름')),
+ body: ListView(
+ padding: const EdgeInsets.all(20),
+ children: [
+ Text('기기 안에만 둡니다', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
+ const SizedBox(height: 8),
+ Text(
+ '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
+ '원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
+ style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
+ ),
+ const SizedBox(height: 12),
+ Card(
+ child: ListTile(
+ title: Text('말투 샘플 ${samples.length}개'),
+ subtitle: Text(
+ [
+ samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
+ session.localDbEncrypted ? '저장: drift + SQLCipher(암호화)' : '저장: 메모리 폴백(이 환경에 SQLCipher 없음)',
+ ].join('\n'),
+ ),
+ isThreeLine: true,
+ ),
+ ),
+ const SizedBox(height: 24),
+ Text('서버로 보낼 수 있는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
+ const SizedBox(height: 8),
+ Text(
+ '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트).\n'
+ '채팅 릴레이: 보낸 메시지 본문.\n'
+ '계정: 표시 이름·초대 코드·세션 토큰.\n'
+ '푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후.',
+ style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
+ ),
+ const SizedBox(height: 24),
+ Text('보내지 않는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
+ const SizedBox(height: 8),
+ Text(
+ '전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, '
+ '관계 메모의 자동 클라우드 분석.',
+ style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/inbox_screen.dart b/mobile/lib/screens/inbox_screen.dart
new file mode 100644
index 0000000..7c1e34c
--- /dev/null
+++ b/mobile/lib/screens/inbox_screen.dart
@@ -0,0 +1,103 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../models/models.dart';
+import '../services/api_client.dart';
+import '../state/session_state.dart';
+import 'chat_screen.dart';
+
+/// Post-hoc notification inbox: escalation logs that need human attention.
+/// Twin "되돌리기" remains available inside each chat bubble (PRD).
+class InboxScreen extends StatefulWidget {
+ const InboxScreen({super.key});
+
+ @override
+ State createState() => _InboxScreenState();
+}
+
+class _InboxScreenState extends State {
+ List _logs = [];
+ bool _loading = true;
+ String? _error;
+
+ @override
+ void initState() {
+ super.initState();
+ _load();
+ }
+
+ Future _load() async {
+ final session = context.read();
+ if (session.user == null) return;
+ setState(() {
+ _loading = true;
+ _error = null;
+ });
+ try {
+ final logs = await session.api.listEscalationLogs(session.user!.id);
+ setState(() => _logs = logs);
+ } on ApiException catch (e) {
+ setState(() => _error = '알림 로드 실패 (${e.statusCode})');
+ } finally {
+ setState(() => _loading = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Scaffold(
+ appBar: AppBar(title: const Text('사후 알림')),
+ body: RefreshIndicator(
+ onRefresh: _load,
+ child: _loading
+ ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
+ : ListView(
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
+ child: Text(
+ '분신이 보류·차단한 내용과 에스컬레이션 기록입니다. '
+ '이미 보낸 분신 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
+ style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
+ ),
+ ),
+ if (_error != null)
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
+ ),
+ if (_logs.isEmpty)
+ const Padding(
+ padding: EdgeInsets.all(24),
+ child: Text('새 알림이 없습니다.'),
+ ),
+ for (final log in _logs)
+ ListTile(
+ leading: Icon(
+ log.resolved ? Icons.check_circle_outline : Icons.warning_amber_rounded,
+ color: log.resolved ? theme.colorScheme.primary : theme.colorScheme.error,
+ ),
+ title: Text(log.reason.isEmpty ? '에스컬레이션' : log.reason),
+ subtitle: Text(
+ [
+ '대화방 #${log.conversationId}',
+ if (log.messageSnippet.isNotEmpty) log.messageSnippet,
+ log.createdAt.toLocal().toString().split('.').first,
+ ].join('\n'),
+ ),
+ isThreeLine: true,
+ onTap: () {
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ChatScreen(conversationId: log.conversationId),
+ ),
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/onboarding_tone_screen.dart b/mobile/lib/screens/onboarding_tone_screen.dart
new file mode 100644
index 0000000..6983f10
--- /dev/null
+++ b/mobile/lib/screens/onboarding_tone_screen.dart
@@ -0,0 +1,93 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../state/session_state.dart';
+
+/// Phase 1 onboarding skeleton: capture a few style samples locally.
+/// Fine copy / import UX waits for human PoC (#1) — do not invent §3 defaults here.
+class OnboardingToneScreen extends StatefulWidget {
+ const OnboardingToneScreen({super.key});
+
+ @override
+ State createState() => _OnboardingToneScreenState();
+}
+
+class _OnboardingToneScreenState extends State {
+ final _samples = List.generate(4, (_) => TextEditingController());
+ var _seeded = false;
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (_seeded) return;
+ _seeded = true;
+ final existing = context.read().styleExamples;
+ for (var i = 0; i < existing.length && i < _samples.length; i++) {
+ _samples[i].text = existing[i];
+ }
+ }
+
+ @override
+ void dispose() {
+ for (final c in _samples) {
+ c.dispose();
+ }
+ super.dispose();
+ }
+
+ Future _save({required bool markDone}) async {
+ final session = context.read();
+ await session.saveToneSamples(
+ _samples.map((c) => c.text).toList(),
+ markDone: markDone,
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('말투 샘플'),
+ actions: [
+ TextButton(
+ onPressed: () => context.read().skipToneOnboarding(),
+ child: const Text('나중에'),
+ ),
+ ],
+ ),
+ body: SafeArea(
+ child: ListView(
+ padding: const EdgeInsets.all(24),
+ children: [
+ Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700)),
+ const SizedBox(height: 8),
+ Text(
+ '자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. '
+ '최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.',
+ style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
+ ),
+ const SizedBox(height: 24),
+ for (var i = 0; i < _samples.length; i++) ...[
+ TextField(
+ controller: _samples[i],
+ maxLines: 2,
+ decoration: InputDecoration(
+ labelText: '샘플 ${i + 1}',
+ hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null,
+ border: const OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 12),
+ ],
+ const SizedBox(height: 8),
+ FilledButton(
+ onPressed: () => _save(markDone: true),
+ child: const Text('저장하고 시작'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/sessions_screen.dart b/mobile/lib/screens/sessions_screen.dart
new file mode 100644
index 0000000..097a1b0
--- /dev/null
+++ b/mobile/lib/screens/sessions_screen.dart
@@ -0,0 +1,108 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../services/api_client.dart';
+import '../state/session_state.dart';
+
+/// Multi-device awareness: list / revoke active sessions (roadmap B).
+class SessionsScreen extends StatefulWidget {
+ const SessionsScreen({super.key});
+
+ @override
+ State createState() => _SessionsScreenState();
+}
+
+class _SessionsScreenState extends State {
+ List