Compare commits

..

No commits in common. "ee8a41de179fbbbfa0bc42eed3bdab2f47110fa0" and "0aae8fb5789966285f08605dce81e9c7ee86a906" have entirely different histories.

24 changed files with 194 additions and 919 deletions

View File

@ -7,7 +7,6 @@ from pydantic import BaseModel, model_validator
from .escalation_filter import check as check_escalation
from .generation import draft_reply, last_incoming_text, load_dotenv_if_present
from .retrieve_style import retrieve as retrieve_style_examples
from .summarize import summarize_messages
@asynccontextmanager
@ -73,22 +72,3 @@ def draft(req: DraftRequest):
status, text = draft_reply(style_examples, req.context_lines, model=req.model)
return DraftResponse(status=status, text=text)
class SummarizeRequest(BaseModel):
my_display_name: str
context_lines: List[str]
model: str = "gemini-2.5-flash"
class SummarizeResponse(BaseModel):
status: str
summary: str
@app.post("/summarize", response_model=SummarizeResponse)
def summarize(req: SummarizeRequest):
"""단톡 따라잡기 (roadmap.md §2.7-A) -- read-only catch-up summary, no
escalation/identity gating since nothing generated here is ever sent."""
status, text = summarize_messages(req.my_display_name, req.context_lines, model=req.model)
return SummarizeResponse(status=status, summary=text)

View File

@ -1,46 +0,0 @@
"""Group catch-up summary -- roadmap.md §2.7-A "단톡 따라잡기". Separate from
generation.py's draft_reply: this never produces something meant to be sent,
so it carries none of the escalation/identity gating a draft needs, and it
summarizes many messages at once instead of drafting one reply.
"""
from .generation import load_dotenv_if_present # re-exported for main.py convenience
SUMMARY_SYSTEM_PROMPT = """너는 사용자가 오랫동안 못 본 단체 대화방을 대신 읽고 요약해 주는 비서다. \
아래 대화 로그를 보고, 사용자('')에게 필요한 내용만 3~5줄로 한국어로 요약해라.
지켜야 :
- 나에게 멘션/질문된 , 결정된 사항(약속·시간·장소 ) 우선으로 다룬다.
- 나와 무관한 잡담은 요약에서 뺀다.
- 답장이 필요해 보이는 항목이 있으면 마지막 줄에 "-> 답장 필요: ..." 형식으로 짧게 짚어준다.
- 요약문만 출력한다. 인사말, 설명, 따옴표를 덧붙이지 않는다.
- 요약은 자체로 전송되지 않는다 -- 답장은 항상 사용자가 직접 검토해서 보낸다."""
def build_summary_prompt(my_display_name, context_lines):
context = "\n".join(context_lines)
return f"""[내 이름] {my_display_name}\n\n[안 본 동안의 대화]\n{context}\n\n위 대화 요약:"""
def summarize_messages(my_display_name, context_lines, model="gemini-2.5-flash", api_key=None):
"""Returns (status, text). status is one of "no_key" | "ok".
"no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing).
"ok": text is the generated summary.
"""
import os
api_key = api_key or os.environ.get("GEMINI_API_KEY")
prompt = build_summary_prompt(my_display_name, context_lines)
if not api_key:
return "no_key", prompt
from google import genai # pip install google-genai
from google.genai import types
client = genai.Client(api_key=api_key)
resp = client.models.generate_content(
model=model,
contents=prompt,
config=types.GenerateContentConfig(system_instruction=SUMMARY_SYSTEM_PROMPT, max_output_tokens=300),
)
return "ok", resp.text.strip()

View File

@ -90,18 +90,3 @@ def test_draft_rejects_both_style_sources():
def test_draft_rejects_neither_style_source():
resp = client.post("/draft", json={"context_lines": ["상대: 안녕"]})
assert resp.status_code == 422
def test_summarize_no_key(monkeypatch):
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
resp = client.post(
"/summarize",
json={
"my_display_name": "민수",
"context_lines": ["철수: 토요일 모임 3시로 하자", "영희: ㅇㅋ"],
},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "no_key"
assert "토요일 모임 3시로 하자" in body["summary"]

View File

@ -1,55 +0,0 @@
"""단톡 따라잡기 (roadmap.md §2.7-A) -- the Gemini call is mocked, same pattern
as test_generation.py. Asserts the no_key fallback and the exact request
shape sent to the model; not real summary quality."""
import sys
import types
from unittest.mock import MagicMock
from app.summarize import build_summary_prompt, summarize_messages
def test_build_summary_prompt_includes_name_and_context():
prompt = build_summary_prompt("민수", ["철수: 이번 주 토요일 모임 3시로 확정", "영희: 나 못 갈 것 같아"])
assert "민수" in prompt
assert "토요일 모임 3시로 확정" in prompt
assert "나 못 갈 것 같아" in prompt
def test_summarize_messages_no_key_returns_prompt_without_calling_model(monkeypatch):
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
status, text = summarize_messages("민수", ["철수: 밥 언제 먹지"], api_key=None)
assert status == "no_key"
assert "밥 언제 먹지" in text
def test_summarize_messages_ok_calls_gemini_with_expected_args(monkeypatch):
# summarize.py does `from google import genai` / `from google.genai import
# types` *inside* summarize_messages, same stubbing approach as
# test_generation.py to avoid the real google-genai dependency chain.
fake_response = MagicMock()
fake_response.text = " 토요일 3시 모임 확정. -> 답장 필요: 참석 여부 답하기 "
fake_client = MagicMock()
fake_client.models.generate_content.return_value = fake_response
fake_genai = types.ModuleType("google.genai")
fake_genai.Client = MagicMock(return_value=fake_client)
fake_types = types.ModuleType("google.genai.types")
fake_types.GenerateContentConfig = MagicMock(side_effect=lambda **kw: kw)
fake_google = types.ModuleType("google")
fake_google.genai = fake_genai
monkeypatch.setitem(sys.modules, "google", fake_google)
monkeypatch.setitem(sys.modules, "google.genai", fake_genai)
monkeypatch.setitem(sys.modules, "google.genai.types", fake_types)
status, text = summarize_messages(
"민수", ["철수: 토요일 3시에 모이자", "영희: ㅇㅋ"], model="gemini-2.5-flash", api_key="fake-key"
)
assert status == "ok"
assert text == "토요일 3시 모임 확정. -> 답장 필요: 참석 여부 답하기"
fake_genai.Client.assert_called_once_with(api_key="fake-key")
fake_client.models.generate_content.assert_called_once()
_, kwargs = fake_client.models.generate_content.call_args
assert kwargs["model"] == "gemini-2.5-flash"
assert "토요일 3시에 모이자" in kwargs["contents"]

View File

@ -193,25 +193,12 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
for _, pp := range pps {
participantIDs = append(participantIDs, pp.UserID)
}
// Unread count (roadmap.md §2.7-A "안 본 동안" badge) -- messages
// after this participant's read marker, excluding their own sends
// (sending a message means you've obviously seen it).
unreadQuery := db.Model(&Message{}).
Where("conversation_id = ? AND retracted = ? AND sender_id != ?", conv.ID, false, userID)
if p.LastReadMessageID != nil {
unreadQuery = unreadQuery.Where("id > ?", *p.LastReadMessageID)
}
var unreadCount int64
unreadQuery.Count(&unreadCount)
out = append(out, gin.H{
"id": conv.ID,
"is_group": conv.IsGroup,
"twin_disabled_by_peer": conv.TwinDisabledByPeer,
"user_ids": participantIDs,
"created_at": conv.CreatedAt,
"unread_count": unreadCount,
})
}
c.JSON(http.StatusOK, gin.H{"conversations": out})

View File

@ -53,42 +53,6 @@ func (c *AIServiceClient) requestDraft(req draftRequest) (*draftResponse, error)
return &out, nil
}
type summaryRequest struct {
MyDisplayName string `json:"my_display_name"`
ContextLines []string `json:"context_lines"`
}
type summaryResponse struct {
Status string `json:"status"`
Summary string `json:"summary"`
}
// requestSummary calls ai-service's POST /summarize -- the "단톡 따라잡기"
// catch-up summary (roadmap.md §2.7-A), separate from /draft since it never
// produces something meant to be sent, only read.
func (c *AIServiceClient) requestSummary(req summaryRequest) (*summaryResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Post(c.BaseURL+"/summarize", "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("ai service unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ai service returned %d", resp.StatusCode)
}
var out summaryResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}
type escalationCheckRequest struct {
Text string `json:"text"`
}

View File

@ -1,135 +0,0 @@
package main
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// registerGroupSummaryRoutes implements roadmap.md §2.7-A "단톡 따라잡기":
// a read marker per participant + an AI-generated catch-up summary of
// whatever arrived since that marker. Kept separate from a1_a2_routes.go
// since it's new (2026-07-31) scope, not part of the original A1/A2 surface.
func registerGroupSummaryRoutes(r *gin.Engine, db *gorm.DB, ai *AIServiceClient) {
// POST /conversations/:id/read advances the caller's read marker. Body:
// {"message_id": <highest message id the client has seen>}. Never moves
// the marker backward, so out-of-order client calls can't lose history.
r.POST("/conversations/:id/read", func(c *gin.Context) {
actor, ok := currentUser(c, db, true)
if !ok {
return
}
convID, ok := parseUintParam(c, "id")
if !ok {
return
}
if !isParticipant(db, convID, actor.ID) {
c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"})
return
}
var req struct {
MessageID uint `json:"message_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
var participant ConversationParticipant
if err := db.Where("conversation_id = ? AND user_id = ?", convID, actor.ID).
First(&participant).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "participant record not found"})
return
}
if participant.LastReadMessageID == nil || *participant.LastReadMessageID < req.MessageID {
participant.LastReadMessageID = &req.MessageID
if err := db.Save(&participant).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"last_read_message_id": participant.LastReadMessageID})
})
// GET /conversations/:id/summary returns an AI-generated 3~5 line
// catch-up of messages since the caller's read marker, focused on
// mentions of the caller and decisions made (PRD.md §2.3-②). Read-only
// -- it never sends anything, so it carries none of the escalation/
// autonomy-level machinery that POST /messages does.
r.GET("/conversations/:id/summary", func(c *gin.Context) {
actor, ok := currentUser(c, db, true)
if !ok {
return
}
convID, ok := parseUintParam(c, "id")
if !ok {
return
}
if !isParticipant(db, convID, actor.ID) {
c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"})
return
}
var participant ConversationParticipant
if err := db.Where("conversation_id = ? AND user_id = ?", convID, actor.ID).
First(&participant).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "participant record not found"})
return
}
q := db.Where("conversation_id = ? AND retracted = ?", convID, false)
if participant.LastReadMessageID != nil {
q = q.Where("id > ?", *participant.LastReadMessageID)
}
var unread []Message
q.Order("id asc").Find(&unread)
if len(unread) == 0 {
c.JSON(http.StatusOK, gin.H{"status": "empty", "summary": "", "unread_count": 0})
return
}
names := map[uint]string{}
contextLines := make([]string, 0, len(unread))
needsReply := false
for _, m := range unread {
var label string
if m.SenderID == actor.ID {
label = "나"
} else {
name, cached := names[m.SenderID]
if !cached {
var sender User
if err := db.First(&sender, m.SenderID).Error; err == nil {
name = sender.DisplayName
}
if name == "" {
name = "상대"
}
names[m.SenderID] = name
}
label = name
needsReply = true
}
contextLines = append(contextLines, label+": "+m.Text)
}
result, err := ai.requestSummary(summaryRequest{
MyDisplayName: actor.DisplayName,
ContextLines: contextLines,
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": result.Status,
"summary": strings.TrimSpace(result.Summary),
"unread_count": len(unread),
"needs_reply": needsReply,
})
})
}

View File

@ -1,163 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
)
func createGroup(t *testing.T, serverURL, token string, userIDs []uint) uint {
t.Helper()
resp := postJSONAuth(t, serverURL+"/conversations", token, createConversationRequest{
UserIDs: userIDs,
IsGroup: true,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("create group: %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
return uint(out["id"].(float64))
}
func TestGroupConversationBlocksTwinAutoSendRegardlessOfAutonomyLevel(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
peerID, _ := mustSignup(t, server.URL, "친구")
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
// L2 with a matching whitelist rule would normally auto-send in a 1:1
// conversation (main_test.go's autonomy flow tests cover that) -- group
// conversations must block twin sends outright regardless (PRD.md
// §2.3-③, roadmap.md §2.7-A: "이 시나리오는 L0 고정, 자동 발송 없음").
setAutonomyLevel(t, server.URL, ownerID, ownerToken, AutonomyL2)
resp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", ownerToken, sendMessageRequest{
SenderID: ownerID,
Text: "ㅇㅋ 알겠음",
SenderMode: SenderTwin,
Approved: true,
})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 blocking twin send in group, got %d", resp.StatusCode)
}
// A human-authored send in the same group is unaffected.
humanResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", ownerToken, sendMessageRequest{
SenderID: ownerID,
Text: "내가 직접 씀",
})
if humanResp.StatusCode != http.StatusOK {
t.Fatalf("expected human send to succeed, got %d", humanResp.StatusCode)
}
}
func TestGroupSummaryReflectsUnreadSinceReadMarker(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
convIDStr := strconv.FormatUint(uint64(convID), 10)
// Nothing read yet -- summary should be empty (no messages at all).
emptyResp, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/summary", nil)
emptyResp.Header.Set("Authorization", "Bearer "+ownerToken)
emptyOut, err := http.DefaultClient.Do(emptyResp)
if err != nil {
t.Fatalf("get summary: %v", err)
}
var emptyBody map[string]interface{}
json.NewDecoder(emptyOut.Body).Decode(&emptyBody)
if emptyBody["status"] != "empty" {
t.Fatalf("expected empty status with no messages, got %v", emptyBody)
}
// Peer sends two messages the owner hasn't read.
postJSONAuth(t, server.URL+"/conversations/"+convIDStr+"/messages", ownerToken, sendMessageRequest{
SenderID: peerID, Text: "토요일 모임 3시 어때?",
})
postJSONAuth(t, server.URL+"/conversations/"+convIDStr+"/messages", ownerToken, sendMessageRequest{
SenderID: peerID, Text: "민수야 답장 좀",
})
sumReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/summary", nil)
sumReq.Header.Set("Authorization", "Bearer "+ownerToken)
sumResp, err := http.DefaultClient.Do(sumReq)
if err != nil {
t.Fatalf("get summary: %v", err)
}
var sumBody map[string]interface{}
json.NewDecoder(sumResp.Body).Decode(&sumBody)
if sumBody["status"] != "ok" {
t.Fatalf("expected ok status, got %v", sumBody)
}
if int(sumBody["unread_count"].(float64)) != 2 {
t.Fatalf("expected 2 unread, got %v", sumBody["unread_count"])
}
if sumBody["needs_reply"] != true {
t.Fatalf("expected needs_reply true, got %v", sumBody["needs_reply"])
}
summaryText := sumBody["summary"].(string)
if !containsAll(summaryText, "토요일 모임 3시 어때", "민수야 답장 좀") {
t.Fatalf("summary missing unread content: %v", summaryText)
}
// List conversations should report the same unread count.
listReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations", nil)
listReq.Header.Set("Authorization", "Bearer "+ownerToken)
listResp, err := http.DefaultClient.Do(listReq)
if err != nil {
t.Fatalf("list conversations: %v", err)
}
var listBody map[string]interface{}
json.NewDecoder(listResp.Body).Decode(&listBody)
convs := listBody["conversations"].([]interface{})
found := false
for _, raw := range convs {
c := raw.(map[string]interface{})
if uint(c["id"].(float64)) == convID {
found = true
if int(c["unread_count"].(float64)) != 2 {
t.Fatalf("expected list unread_count 2, got %v", c["unread_count"])
}
}
}
if !found {
t.Fatalf("conversation %d not found in list", convID)
}
// Mark read up to the latest message, then re-fetch: should be empty
// again and the list's unread_count should drop to 0.
var msgs map[string]interface{}
msgsReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/messages", nil)
msgsReq.Header.Set("Authorization", "Bearer "+ownerToken)
msgsResp, _ := http.DefaultClient.Do(msgsReq)
json.NewDecoder(msgsResp.Body).Decode(&msgs)
msgList := msgs["messages"].([]interface{})
lastID := uint(msgList[len(msgList)-1].(map[string]interface{})["id"].(float64))
readResp := postJSONAuth(t, server.URL+"/conversations/"+convIDStr+"/read", ownerToken, map[string]interface{}{"message_id": lastID})
if readResp.StatusCode != http.StatusOK {
t.Fatalf("mark read: %d", readResp.StatusCode)
}
afterReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/summary", nil)
afterReq.Header.Set("Authorization", "Bearer "+ownerToken)
afterResp, _ := http.DefaultClient.Do(afterReq)
var afterBody map[string]interface{}
json.NewDecoder(afterResp.Body).Decode(&afterBody)
if afterBody["status"] != "empty" {
t.Fatalf("expected empty summary after marking read, got %v", afterBody)
}
}
func containsAll(haystack string, needles ...string) bool {
for _, n := range needles {
if !strings.Contains(haystack, n) {
return false
}
}
return true
}

View File

@ -84,7 +84,6 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
registerBRoutes(r, db)
registerInviteOpsRoutes(r, db)
registerDemoRoutes(r)
registerGroupSummaryRoutes(r, db, ai)
r.GET("/admin/metrics", func(c *gin.Context) {
if !requireAdmin(c) {
@ -263,16 +262,6 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
return
}
// 단톡 따라잡기(PRD.md §2.3-③, roadmap.md §2.7-A): 이 시나리오는
// L0 고정, 자동 발송 없음 -- 사용자의 전역 자율성 레벨(L1/L2)과
// 무관하게 그룹 대화에서는 와카뷰 발송 자체를 막는다. 초안은
// 항상 사람이 검토해서 직접 보낸다.
if conversation.IsGroup {
runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "그룹 대화에서는 와카뷰 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
return
}
result, err := ai.checkEscalation(req.Text)
runtimeMetrics.recordEscalate(err)
if err != nil {

View File

@ -50,17 +50,6 @@ func mockAIService(t *testing.T) *httptest.Server {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
case "/summarize":
var req summaryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(summaryResponse{
Status: "ok",
Summary: "mock summary for " + req.MyDisplayName + ": " + strings.Join(req.ContextLines, " | "),
})
default:
w.WriteHeader(http.StatusNotFound)
}

View File

@ -74,10 +74,6 @@ type ConversationParticipant struct {
ID uint `gorm:"primaryKey"`
ConversationID uint `gorm:"not null;index"`
UserID uint `gorm:"not null;index"`
// LastReadMessageID is this participant's read marker (roadmap.md
// §2.7-A "단톡 따라잡기") -- nil means nothing read yet. Drives the
// unread badge and GET /conversations/:id/summary's "안 본 동안" window.
LastReadMessageID *uint
}
type Message struct {

View File

@ -38,11 +38,10 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
- 앱 코드는 클로즈드 베타 직전 수준
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
- UI: **iMessage-inspired light default** + soft charcoal dark —
기본 `ThemeMode.light`, 내 버블 `#007AFF`, 다크 캔버스 `#141418` (배포 진행)
- **Track C 콘텐츠 갭**: C1(단톡 따라잡기) **완료** (2026-08-03) — 그룹 생성 UI,
안 본 동안 요약(`GET /conversations/:id/summary`), 읽음 마커, 안 본 배지,
그룹 트윈 발송 서버측 차단까지. 다음은 C2(관계별 페르소나) → C3(스팸 감지).
- UI: **Soft Neutral + surface hierarchy** 프로덕션 반영 (`e05a0f0`, 2026-08-03) —
GitHub+Gitea `main` 듀얼 푸시 · `msn.iykyka.com` web 재빌드 완료
- **발견(2026-07-31): Track C 콘텐츠 갭**`PRD.md` P0 대비 단톡 따라잡기·관계별 페르소나·
스팸 감지 미구현. Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 지금 바로 코드 착수 가능
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
### NEXT 순서
@ -180,11 +179,11 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N4-C1a** | 그룹 대화 생성 UI(복수 상대) | **done** (2026-08-03) | "새 대화" 다이얼로그가 상대 여러 명 입력 받아 `is_group:true`로 생성 |
| **N4-C1b** | `GET /conversations/:id/summary` | **done** (2026-08-03) | `core-backend/group_summary.go` + 읽음 마커(`POST /conversations/:id/read`) |
| **N4-C1c** | 단톡 요약 생성(멘션/결정사항 3~5줄) | **done** (2026-08-03) | `ai-service/app/summarize.py` + `POST /summarize` |
| **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | **done** (2026-08-03) | `chat_screen.dart`. 서버가 그룹 대화 트윈 발송을 전역 레벨과 무관하게 항상 차단 |
| **N4-C1e** | "안 본 동안" 배지 | **done** (2026-08-03) | 대화 목록에 서버 계산 `unread_count` 표시 |
| **N4-C1a** | 그룹 대화 생성 UI(복수 상대) | todo | "새 대화" 다이얼로그가 상대 여러 명 입력 받아 `is_group:true`로 생성 |
| **N4-C1b** | `GET /conversations/:id/summary` | todo | 마지막 읽음 이후 메시지 모아 AI 서비스로 전달하는 core-backend 라우트 |
| **N4-C1c** | 단톡 요약 생성(멘션/결정사항 3~5줄) | todo | `ai-service/app/` 신규 모듈, `generate_draft.py` 패턴 재사용 |
| **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | todo | `chat_screen.dart`. 자동발송 없음 — 안전 불변식 유지 |
| **N4-C1e** | "안 본 동안" 배지 | todo | 대화 목록에 마지막 읽음 이후 새 메시지 수 표시, read-marker 필요 |
| **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | todo | `core-backend/models.go` `TwinSettings` 확장 |
| **N4-C2b** | 온보딩 관계 티어 선택 스텝 | todo | `onboarding_tone_screen.dart` |
| **N4-C2c** | 연락처별 관계 티어 오버라이드 | todo | `contacts_screen.dart`, 자율성 레벨 상대별 예외와 동일 패턴 |
@ -249,9 +248,8 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P
2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터)
3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드)
4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브)
5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03) — 다음:
**Track C2 관계별 페르소나 → C3 스팸 감지** 병행하며 **Master FCM 시크릿(N4-1/3)**
N4-4 스모크 → Android UI QA (N4-5~10)
5. ~~N3 안정화 + Track A/B~~ **done** — 다음: **Track C 콘텐츠 갭(N4-C1→C2→C3, 지금 착수 가능)**
병행하며 **Master FCM 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10)
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -133,24 +133,18 @@
단톡 따라잡기는 v1의 2개 MVP 시나리오 중 하나인데 현재 0% 구현이라 완성도 공백이 가장 큼;
페르소나는 1:1·단톡 양쪽 초안 품질에 영향; 스팸 감지는 P0지만 "최소 버전"이라 상대적으로 작음).
**2.7-A 단톡 따라잡기** (`PRD.md` §2.3 — **완료** 2026-08-03)
- [x] 그룹 대화 생성 UI(복수 상대 추가) — `conversation_list_screen.dart`의 "새 대화" 다이얼로그가
동적으로 상대 필드를 추가/삭제할 수 있게 됨. 2명 이상이면 자동으로 `is_group:true`로 생성
- [x] `GET /conversations/:id/summary``core-backend/group_summary.go`. 참가자별 읽음 마커
(`ConversationParticipant.LastReadMessageID`)를 두고, 그 이후 메시지만 모아 AI 서비스로 전달.
`POST /conversations/:id/read`로 마커 갱신. `GET /conversations`에도 `unread_count` 추가
- [x] 요약 생성 — `ai-service/app/summarize.py`(신규), "나에게 멘션된 것/결정된 사항" 위주 3~5줄
+ 답장 필요 항목 표시(`PRD.md` §2.3-②). `POST /summarize` 엔드포인트. 읽기 전용이라 escalation/
identity 게이트 없음(아무것도 발송되지 않으므로)
- [x] 요약 내 "답장 필요" 항목 → 초안 버튼 연결 — `chat_screen.dart`의 안 본 동안 요약 다이얼로그에서
바로 `_requestDraft()`로 이어감. **그룹 대화는 전역 자율성 레벨과 무관하게 서버(`main.go`)가
트윈 발송 자체를 항상 차단** — 클라이언트도 L0 취급으로 렌더링(PRD §2.3-③, 안전 불변식 유지)
- [x] "안 본 동안" 배지(마지막 읽음 이후 새 메시지 수) — `conversation_list_screen.dart`, 서버가
계산한 `unread_count`를 그대로 표시
- **주의**: 읽음 마커는 채팅방에 "들어올 때"가 아니라 "나갈 때"(`dispose()`) 찍는다 — 들어오는
순간 찍으면 그 방을 여는 즉시 안 본 게 0이 되어 "안 본 동안 요약"이 항상 빈 결과만 보여주는
버그가 생김(실제 Playwright로 스크린샷 찍어보다가 발견). 실시간 소켓 메시지는 화면이 열려
있는 동안은 계속 읽음으로 따라가도 됨
**2.7-A 단톡 따라잡기** (`PRD.md` §2.3, 현재 미구현 — `isGroup`은 아이콘 표시용 플래그뿐, 요약
로직·엔드포인트·UI 전부 없음)
- [ ] 그룹 대화 생성 UI(복수 상대 추가) — `conversation_list_screen.dart`의 "새 대화" 다이얼로그가
상대 1명만 받음. 서버(`is_group`+복수 `user_ids`)는 이미 지원하므로 클라이언트만 필요
- [ ] `GET /conversations/:id/summary` — 마지막 읽음 시점 이후 메시지를 모아 AI 서비스로 전달 —
`core-backend`(신규 라우트)
- [ ] 요약 생성 — "나에게 멘션된 것/결정된 사항" 위주 3~5줄(`PRD.md` §2.3-②) — `ai-service/app/`
신규 모듈, `generate_draft.py`의 Gemini 호출 패턴 재사용
- [ ] 요약 내 "답장 필요" 항목 → 초안 버튼 연결 — `chat_screen.dart`. **이 시나리오는 L0 고정,
자동발송 없음**(PRD §2.3-③) — 안전 불변식 그대로 유지, 우회 경로 만들지 않기
- [ ] "안 본 동안" 배지(마지막 읽음 이후 새 메시지 수) — `conversation_list_screen.dart` +
서버 read-marker 필요
**2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종: 가까운 사이/공식적인 사이 — 현재
`TwinSettings``AutonomyLevel`만 있고 페르소나 필드 없음)

View File

@ -89,8 +89,7 @@ class _BootstrapState extends State<_Bootstrap> {
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
// Default to light messenger UI; OS dark still maps to soft charcoal.
themeMode: ThemeMode.light,
themeMode: ThemeMode.system,
builder: (context, child) => GradientBackdrop(child: child),
home: const SplashScreen(),
);
@ -113,8 +112,7 @@ class YkavuApp extends StatelessWidget {
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
// Default to light messenger UI; OS dark still maps to soft charcoal.
themeMode: ThemeMode.light,
themeMode: ThemeMode.system,
builder: (context, child) => GradientBackdrop(child: child),
home: Consumer<SessionState>(
builder: (context, s, _) {

View File

@ -40,7 +40,6 @@ class ConversationSummary {
required this.isGroup,
required this.userIds,
required this.twinDisabledByPeer,
this.unreadCount = 0,
this.createdAt,
});
@ -48,7 +47,6 @@ class ConversationSummary {
final bool isGroup;
final List<int> userIds;
final bool twinDisabledByPeer;
final int unreadCount;
final DateTime? createdAt;
factory ConversationSummary.fromJson(Map<String, dynamic> json) {
@ -58,7 +56,6 @@ class ConversationSummary {
isGroup: json['is_group'] as bool? ?? false,
userIds: rawIds.map((e) => e as int).toList(),
twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false,
unreadCount: json['unread_count'] as int? ?? 0,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''),
);
}
@ -169,30 +166,6 @@ class WhitelistRule {
);
}
/// (roadmap.md §2.7-A) GET /conversations/:id/summary result.
class GroupSummaryResult {
GroupSummaryResult({
required this.status,
required this.summary,
required this.unreadCount,
required this.needsReply,
});
final String status; // empty | ok
final String summary;
final int unreadCount;
final bool needsReply;
factory GroupSummaryResult.fromJson(Map<String, dynamic> json) => GroupSummaryResult(
status: json['status'] as String? ?? 'empty',
summary: json['summary'] as String? ?? '',
unreadCount: json['unread_count'] as int? ?? 0,
needsReply: json['needs_reply'] as bool? ?? false,
);
bool get isEmpty => status == 'empty' || unreadCount == 0;
}
class DraftResult {
DraftResult({required this.status, required this.text});

View File

@ -11,11 +11,10 @@ import '../theme/app_theme.dart';
import '../widgets/message_bubble.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key, required this.conversationId, this.title, this.isGroup = false});
const ChatScreen({super.key, required this.conversationId, this.title});
final int conversationId;
final String? title;
final bool isGroup;
@override
State<ChatScreen> createState() => _ChatScreenState();
@ -32,12 +31,10 @@ class _ChatScreenState extends State<ChatScreen> {
DraftResult? _pendingDraft;
bool _busy = false;
bool _loadingHistory = true;
late final ApiClient _api;
@override
void initState() {
super.initState();
_api = context.read<SessionState>().api;
_socket = ConversationSocket(widget.conversationId)..connect();
_sub = _socket!.events.listen(_onEvent);
_loadHistory();
@ -55,11 +52,6 @@ class _ChatScreenState extends State<ChatScreen> {
_loadingHistory = false;
});
_scrollToEnd();
// "나갈 때"(dispose) --
// 0
// "안 본 동안 요약" (roadmap.md
// §2.7-A).
// (_onEvent ).
} on ApiException catch (e) {
if (!mounted) return;
setState(() {
@ -69,17 +61,6 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
/// (roadmap.md §2.7-A) .
Future<void> _markLatestRead() async {
if (_messages.isEmpty) return;
final latestId = _messages.map((m) => m.id).reduce((a, b) => a > b ? a : b);
try {
await _api.markRead(widget.conversationId, latestId);
} on ApiException {
// Best-effort an unread badge staying stale isn't worth surfacing an error for.
}
}
void _scrollToEnd() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scroll.hasClients) return;
@ -116,15 +97,10 @@ class _ChatScreenState extends State<ChatScreen> {
if (_messages.any((m) => m.id == msg.id)) return;
setState(() => _messages.add(msg));
_scrollToEnd();
_markLatestRead();
}
@override
void dispose() {
// _loadHistory() .
// Fire-and-forget: dispose는 ,
// .
_markLatestRead();
_sub?.cancel();
_socket?.dispose();
_input.dispose();
@ -200,17 +176,13 @@ class _ChatScreenState extends State<ChatScreen> {
final text = _draftEdit.text.trim();
if (text.isEmpty) return;
// L0: twin send is forbidden server-side move text to human composer
// instead. L0
// (PRD.md §2.3-, roadmap.md §2.7-A "이 시나리오는 L0 고정, 자동 발송 없음").
if (session.autonomyLevel == AutonomyLevel.L0 || widget.isGroup) {
// L0: twin send is forbidden server-side move text to human composer instead.
if (session.autonomyLevel == AutonomyLevel.L0) {
setState(() {
_input.text = text;
_pendingDraft = null;
_draftEdit.clear();
_banner = widget.isGroup
? '단톡에서는 와카뷰가 자동으로 보내지 않습니다. 초안을 검토하고 아래 입력창에서 직접 보내세요.'
: 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.';
_banner = 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.';
});
return;
}
@ -276,57 +248,6 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
/// (roadmap.md §2.7-A): 3~5
/// .
/// ( L0 ).
Future<void> _openSummary() async {
final session = context.read<SessionState>();
GroupSummaryResult? result;
String? error;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) {
if (result == null && error == null) {
session.api.getGroupSummary(widget.conversationId).then((r) {
setDialogState(() => result = r);
}).catchError((e) {
setDialogState(() => error = e is ApiException ? '요약 실패 (${e.statusCode})' : '요약 실패');
});
}
final r = result;
return AlertDialog(
title: const Text('안 본 동안 요약'),
content: SizedBox(
width: 320,
child: error != null
? Text(error!, style: TextStyle(color: Theme.of(ctx).colorScheme.error))
: r == null
? const SizedBox(
height: 60,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
)
: r.isEmpty
? const Text('안 본 메시지가 없습니다.')
: Text(r.summary.isEmpty ? '요약할 내용이 없습니다.' : r.summary),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('닫기')),
if (r != null && !r.isEmpty && r.needsReply)
FilledButton(
onPressed: () {
Navigator.pop(ctx);
_requestDraft();
},
child: const Text('초안 요청'),
),
],
);
},
),
);
}
Widget _buildL1Panel(BuildContext context) {
final theme = Theme.of(context);
final draft = _pendingDraft;
@ -370,7 +291,7 @@ class _ChatScreenState extends State<ChatScreen> {
}
final level = context.watch<SessionState>().autonomyLevel;
final isL0 = level == AutonomyLevel.L0 || widget.isGroup;
final isL0 = level == AutonomyLevel.L0;
final title = isL0
? '초안 (L0) — 직접 보내기'
: level == AutonomyLevel.L1
@ -398,9 +319,7 @@ class _ChatScreenState extends State<ChatScreen> {
if (isL0) ...[
const SizedBox(height: 6),
Text(
widget.isGroup
? '단톡에서는 와카뷰 자동 발송이 항상 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내세요.'
: 'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.',
'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
@ -443,12 +362,6 @@ class _ChatScreenState extends State<ChatScreen> {
appBar: AppBar(
title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
actions: [
if (widget.isGroup)
IconButton(
tooltip: '안 본 동안 요약',
onPressed: _openSummary,
icon: const Icon(Icons.summarize_outlined),
),
IconButton(
tooltip: '거부권 (와카뷰 자동응대 중단)',
onPressed: _busy ? null : _veto,
@ -492,43 +405,30 @@ class _ChatScreenState extends State<ChatScreen> {
),
_buildL1Panel(context),
SafeArea(
child: Container(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
decoration: BoxDecoration(
color: AppTheme.glassFill(theme.brightness),
border: Border(
top: BorderSide(color: AppTheme.glassBorder(theme.brightness).withValues(alpha: 0.7)),
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
tooltip: '초안 요청',
onPressed: _busy ? null : _requestDraft,
icon: const Icon(Icons.auto_awesome_outlined),
),
Expanded(
child: TextField(
controller: _input,
minLines: 1,
maxLines: 4,
textInputAction: TextInputAction.send,
onSubmitted: (_) {
if (!_busy) _sendHuman();
},
decoration: const InputDecoration(hintText: '메시지'),
),
),
const SizedBox(width: 6),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: '초안 요청',
onPressed: _busy ? null : _requestDraft,
icon: const Icon(Icons.auto_awesome),
),
const SizedBox(width: 4),
IconButton.filled(
tooltip: '보내기',
onPressed: _busy ? null : _sendHuman,
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
),
icon: const Icon(Icons.arrow_upward_rounded),
icon: const Icon(Icons.send),
),
],
),

View File

@ -83,18 +83,14 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
}
Future<void> _createConversation() async {
final peerCtrl = TextEditingController();
final session = context.read<SessionState>();
final myId = session.user?.id;
// (roadmap.md §2.7-A "단톡 따라잡기"):
// . 1( 1) 1:1 .
final peerCtrls = <TextEditingController>[TextEditingController()];
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
builder: (ctx) => AlertDialog(
title: const Text('새 대화'),
content: SingleChildScrollView(
child: Column(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -102,97 +98,52 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
MyUserIdChip(userId: myId),
const SizedBox(height: 12),
Text(
'상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요. '
'2명 이상 넣으면 그룹 대화로 만들어집니다.',
'상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요.',
style: Theme.of(ctx).textTheme.bodySmall,
),
const SizedBox(height: 12),
],
for (var i = 0; i < peerCtrls.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: peerCtrls[i],
TextField(
controller: peerCtrl,
keyboardType: TextInputType.number,
autofocus: i == 0,
decoration: InputDecoration(
labelText: i == 0 ? '상대 사용자 ID (숫자)' : '상대 ${i + 1} 사용자 ID',
helperText: i == 0 ? '이름/닉네임이 아니라 숫자 ID입니다.' : null,
),
),
),
if (peerCtrls.length > 1)
IconButton(
tooltip: '삭제',
onPressed: () => setDialogState(() => peerCtrls.removeAt(i)),
icon: const Icon(Icons.close, size: 18),
),
],
),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => setDialogState(() => peerCtrls.add(TextEditingController())),
icon: const Icon(Icons.add, size: 18),
label: const Text('상대 추가 (그룹으로)'),
autofocus: true,
decoration: const InputDecoration(
labelText: '상대 사용자 ID (숫자)',
helperText: '이름/닉네임이 아니라 숫자 ID입니다. 연락처에 등록돼 있으면 연락처에서 시작하세요.',
),
),
],
),
),
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 me = session.user;
final peer = int.tryParse(peerCtrl.text.trim());
if (me == null) return;
final peers = <int>[];
for (final ctrl in peerCtrls) {
final text = ctrl.text.trim();
if (text.isEmpty) continue;
final id = int.tryParse(text);
if (id == null) {
if (peer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('상대 사용자 ID는 숫자여야 합니다. (예: 12)')),
);
return;
}
if (id == me.id) {
if (peer == me.id) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('자기 자신과는 대화를 만들 수 없습니다.')),
);
return;
}
if (!peers.contains(id)) peers.add(id);
}
if (peers.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('상대 사용자 ID를 1명 이상 입력하세요.')),
);
return;
}
final isGroup = peers.length > 1;
try {
final conv = await session.api.createConversation(
userIds: [me.id, ...peers],
isGroup: isGroup,
);
final conv = await session.api.createConversation(userIds: [me.id, peer]);
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(
conversationId: conv.id,
title: isGroup ? '그룹 #${conv.id}' : (_peerNames[peers.first] ?? '상대 #${peers.first}'),
isGroup: isGroup,
title: _peerNames[peer] ?? '상대 #$peer',
),
),
);
@ -205,16 +156,16 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
Color _avatarColor(BuildContext context, int seed) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final palette = isDark
? const [Color(0xFF2C2C34), Color(0xFF243447), Color(0xFF3A3224)]
: const [Color(0xFFE9E9EB), Color(0xFFD6E8FF), Color(0xFFFFF1D6)];
? const [Color(0xFF1C1C1C), Color(0xFF22262E), Color(0xFF242018)]
: const [Color(0xFFF4F4F5), Color(0xFFE8EEF4), Color(0xFFF3F0EA)];
return palette[seed % palette.length];
}
Color _onAvatarColor(BuildContext context, int seed) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final palette = isDark
? const [Color(0xFFF2F2F7), Color(0xFF0A84FF), Color(0xFFD4B06A)]
: const [Color(0xFF1C1C1E), Color(0xFF007AFF), Color(0xFFB08A4A)];
? const [Color(0xFFA3A3A3), Color(0xFF8BB4D9), Color(0xFFC4A574)]
: const [Color(0xFF525252), Color(0xFF3B6D9B), Color(0xFF9A7B4F)];
return palette[seed % palette.length];
}
@ -341,7 +292,6 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
builder: (_) => ChatScreen(
conversationId: room.id,
title: _titleFor(room, me),
isGroup: room.isGroup,
),
),
);
@ -393,23 +343,6 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
],
),
),
if (room.unreadCount > 0) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(10),
),
child: Text(
room.unreadCount > 99 ? '99+' : '${room.unreadCount}',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 8),
],
Icon(Icons.chevron_right, size: 18, color: theme.colorScheme.onSurfaceVariant),
],
),

View File

@ -150,7 +150,7 @@ class _DemoTestPanel extends StatelessWidget {
Text(
'테스트용 (누구나)',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.15,
fontWeight: FontWeight.w600,
),

View File

@ -36,8 +36,8 @@ class SplashScreen extends StatelessWidget {
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: scheme.primary,
strokeWidth: 2,
color: scheme.onSurfaceVariant,
),
),
],

View File

@ -185,18 +185,6 @@ class ApiClient {
await _json('POST', '/conversations/$conversationId/veto');
}
/// (roadmap.md §2.7-A): advances the caller's read marker.
Future<void> markRead(int conversationId, int messageId) async {
await _json('POST', '/conversations/$conversationId/read', body: {
'message_id': messageId,
});
}
Future<GroupSummaryResult> getGroupSummary(int conversationId) async {
final json = await _getObject('/conversations/$conversationId/summary');
return GroupSummaryResult.fromJson(json);
}
Future<void> retractMessage(int messageId) async {
await _json('POST', '/messages/$messageId/retract');
}

View File

@ -1,36 +1,36 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Messenger UI for iMessage-inspired light default + soft charcoal dark.
/// Light: white stage, blue mine bubbles, gray peer bubbles.
/// Dark: elevated charcoal (not pure black), softer blue accents.
/// Soft Neutral + surface hierarchy for .
/// Neutrals dominate; accent is thin (mine bubbles / small links only).
/// Radii: input/button 12, bubble 18. No glow, almost no shadow.
class AppTheme {
AppTheme._();
// Light iOS Messages / white messenger
static const canvasLight = Color(0xFFF2F2F7);
// Light neutrals
static const canvasLight = Color(0xFFFAFAF9);
static const surfaceLight = Color(0xFFFFFFFF);
static const inkLight = Color(0xFF1C1C1E);
static const mutedLight = Color(0xFF8E8E93);
static const lineLight = Color(0xFFD1D1D6);
static const softFillLight = Color(0xFFE9E9EB);
static const inkLight = Color(0xFF171717);
static const mutedLight = Color(0xFF737373);
static const lineLight = Color(0xFFE5E5E5);
static const softFillLight = Color(0xFFF4F4F5);
// Dark soft charcoal (not #000)
static const canvasDark = Color(0xFF141418);
static const surfaceDark = Color(0xFF1C1C22);
static const inkDark = Color(0xFFF2F2F7);
static const mutedDark = Color(0xFF98989F);
static const lineDark = Color(0xFF2C2C34);
static const softFillDark = Color(0xFF2C2C34);
// Dark neutrals
static const canvasDark = Color(0xFF0A0A0A);
static const surfaceDark = Color(0xFF141414);
static const inkDark = Color(0xFFFAFAFA);
static const mutedDark = Color(0xFFA3A3A3);
static const lineDark = Color(0xFF262626);
static const softFillDark = Color(0xFF1C1C1C);
/// iMessage-like blue for mine bubbles & primary actions.
static const accentLight = Color(0xFF007AFF);
static const accentDark = Color(0xFF0A84FF);
static const accentSoftLight = Color(0xFFD6E8FF);
static const accentSoftDark = Color(0xFF163A66);
/// Thin accent used for mine bubbles & primary actions, not chrome.
static const accentLight = Color(0xFF3B6D9B);
static const accentDark = Color(0xFF8BB4D9);
static const accentSoftLight = Color(0xFFE8F0F7);
static const accentSoftDark = Color(0xFF1A2836);
static const twinAmberLight = Color(0xFFB08A4A);
static const twinAmberDark = Color(0xFFD4B06A);
static const twinAmberLight = Color(0xFF9A7B4F);
static const twinAmberDark = Color(0xFFC4A574);
/// Compat for older call sites.
static const teal = accentLight;
@ -44,26 +44,21 @@ class AppTheme {
static LinearGradient backgroundGradient(Brightness b) {
final c = b == Brightness.dark ? canvasDark : canvasLight;
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [c, c],
);
return LinearGradient(colors: [c, c]);
}
static Color glassFill(Brightness b) => b == Brightness.dark ? surfaceDark : surfaceLight;
static Color glassBorder(Brightness b) => b == Brightness.dark ? lineDark : lineLight;
static Color glassShadow(Brightness b) => Colors.transparent;
/// Mine = solid messenger blue (both modes).
static Color mineBubble(Brightness b) => b == Brightness.dark ? accentDark : accentLight;
static Color mineBubbleFg(Brightness b) => Colors.white;
static Color mineBubble(Brightness b) => b == Brightness.dark ? accentSoftDark : accentLight;
static Color mineBubbleFg(Brightness b) => b == Brightness.dark ? inkDark : Colors.white;
static Color peerBubble(Brightness b) => b == Brightness.dark ? softFillDark : softFillLight;
static const rInput = 20.0;
static const rButton = 14.0;
static const rBubble = 20.0;
static const rPanel = 14.0;
static const rInput = 12.0;
static const rButton = 12.0;
static const rBubble = 18.0;
static const rPanel = 12.0;
static ThemeData light() => _build(Brightness.light);
static ThemeData dark() => _build(Brightness.dark);
@ -71,10 +66,11 @@ class AppTheme {
static ThemeData _build(Brightness brightness) {
final isDark = brightness == Brightness.dark;
final primary = isDark ? accentDark : accentLight;
final onPrimary = Colors.white;
final onPrimary = isDark ? canvasDark : Colors.white;
final ink = isDark ? inkDark : inkLight;
final muted = isDark ? mutedDark : mutedLight;
final line = isDark ? lineDark : lineLight;
final canvas = isDark ? canvasDark : canvasLight;
final surface = isDark ? surfaceDark : surfaceLight;
final soft = isDark ? softFillDark : softFillLight;
@ -90,7 +86,7 @@ class AppTheme {
onSecondaryContainer: muted,
tertiary: primary,
onTertiary: onPrimary,
error: isDark ? const Color(0xFFFF6961) : const Color(0xFFFF3B30),
error: isDark ? const Color(0xFFF87171) : const Color(0xFFB91C1C),
onError: Colors.white,
surface: surface,
onSurface: ink,
@ -113,12 +109,12 @@ class AppTheme {
textTheme: textTheme,
primaryTextTheme: textTheme,
appBarTheme: AppBarTheme(
backgroundColor: surface.withValues(alpha: isDark ? 0.94 : 0.88),
backgroundColor: canvas.withValues(alpha: 0.92),
foregroundColor: ink,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
centerTitle: false,
titleTextStyle: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600, letterSpacing: -0.2),
),
cardTheme: CardThemeData(
@ -126,32 +122,32 @@ class AppTheme {
color: surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(rPanel),
side: BorderSide(color: line.withValues(alpha: isDark ? 0.9 : 0.65)),
side: BorderSide(color: line),
),
margin: EdgeInsets.zero,
),
listTileTheme: ListTileThemeData(
iconColor: muted,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rPanel)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
minVerticalPadding: 10,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
minVerticalPadding: 8,
),
dividerTheme: DividerThemeData(color: line, space: 1, thickness: 0.5),
dividerTheme: DividerThemeData(color: line, space: 1, thickness: 1),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: isDark ? soft : surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
fillColor: surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(rInput),
borderSide: BorderSide(color: line.withValues(alpha: 0.7)),
borderSide: BorderSide(color: line),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(rInput),
borderSide: BorderSide(color: line.withValues(alpha: 0.7)),
borderSide: BorderSide(color: line),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(rInput),
borderSide: BorderSide(color: primary, width: 1.4),
borderSide: BorderSide(color: primary, width: 1.2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(rInput),
@ -159,29 +155,29 @@ class AppTheme {
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(rInput),
borderSide: BorderSide(color: scheme.error, width: 1.4),
borderSide: BorderSide(color: scheme.error, width: 1.2),
),
hintStyle: TextStyle(color: muted.withValues(alpha: 0.85)),
hintStyle: TextStyle(color: muted.withValues(alpha: 0.8)),
labelStyle: TextStyle(color: muted, fontWeight: FontWeight.w500),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: primary,
foregroundColor: onPrimary,
backgroundColor: isDark ? inkDark : inkLight,
foregroundColor: isDark ? canvasDark : Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
minimumSize: const Size(0, 48),
minimumSize: const Size(0, 46),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rButton)),
textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
elevation: 0,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: primary,
foregroundColor: ink,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
minimumSize: const Size(0, 48),
minimumSize: const Size(0, 46),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rButton)),
side: BorderSide(color: primary.withValues(alpha: 0.45)),
side: BorderSide(color: line),
),
),
textButtonTheme: TextButtonThemeData(
@ -192,22 +188,22 @@ class AppTheme {
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
foregroundColor: muted,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: BorderSide(color: line),
backgroundColor: soft,
labelStyle: TextStyle(color: ink, fontWeight: FontWeight.w500, fontSize: 12),
backgroundColor: surface,
labelStyle: TextStyle(color: muted, fontWeight: FontWeight.w500, fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: primary,
foregroundColor: onPrimary,
backgroundColor: isDark ? inkDark : inkLight,
foregroundColor: isDark ? canvasDark : Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rButton)),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
@ -220,20 +216,20 @@ class AppTheme {
backgroundColor: surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(color: line.withValues(alpha: 0.8)),
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: line),
),
),
progressIndicatorTheme: ProgressIndicatorThemeData(color: primary),
bannerTheme: MaterialBannerThemeData(backgroundColor: surface, padding: const EdgeInsets.all(16)),
segmentedButtonTheme: SegmentedButtonThemeData(
style: SegmentedButton.styleFrom(
backgroundColor: soft,
backgroundColor: surface,
foregroundColor: muted,
selectedForegroundColor: primary,
selectedBackgroundColor: isDark ? accentSoftDark : accentSoftLight,
side: BorderSide.none,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
selectedForegroundColor: ink,
selectedBackgroundColor: soft,
side: BorderSide(color: line),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rButton)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
),
@ -245,16 +241,16 @@ class AppTheme {
(t ?? const TextStyle()).copyWith(color: ink, fontWeight: w, fontSize: size, letterSpacing: ls, height: h);
return base.copyWith(
displayLarge: s(base.displayLarge, w: FontWeight.w700, size: 34, ls: -0.8),
displaySmall: s(base.displaySmall, w: FontWeight.w700, size: 28, ls: -0.5),
headlineSmall: s(base.headlineSmall, w: FontWeight.w600, size: 22, ls: -0.3),
displayLarge: s(base.displayLarge, w: FontWeight.w600, size: 36, ls: -1.0),
displaySmall: s(base.displaySmall, w: FontWeight.w600, size: 28, ls: -0.6),
headlineSmall: s(base.headlineSmall, w: FontWeight.w600, size: 20, ls: -0.2),
titleLarge: s(base.titleLarge, w: FontWeight.w600, size: 17, ls: -0.2),
titleMedium: s(base.titleMedium, w: FontWeight.w600, size: 16),
titleSmall: s(base.titleSmall, w: FontWeight.w600, size: 15),
bodyLarge: s(base.bodyLarge, w: FontWeight.w400, size: 17, h: 1.35),
bodyMedium: s(base.bodyMedium, w: FontWeight.w400, size: 16, h: 1.35),
bodySmall: s(base.bodySmall, w: FontWeight.w400, size: 13, h: 1.35),
labelLarge: s(base.labelLarge, w: FontWeight.w500, size: 15),
titleMedium: s(base.titleMedium, w: FontWeight.w600, size: 15),
titleSmall: s(base.titleSmall, w: FontWeight.w600, size: 14),
bodyLarge: s(base.bodyLarge, w: FontWeight.w400, size: 15, h: 1.45),
bodyMedium: s(base.bodyMedium, w: FontWeight.w400, size: 14, h: 1.45),
bodySmall: s(base.bodySmall, w: FontWeight.w400, size: 12, h: 1.4),
labelLarge: s(base.labelLarge, w: FontWeight.w500, size: 13),
labelSmall: s(base.labelSmall, w: FontWeight.w500, size: 11),
);
}

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
/// Compact messenger brand mark blue tile, no glow.
import '../theme/app_theme.dart';
/// Soft Neutral brand mark ink monogram, no glow or heavy tile chrome.
class BrandMark extends StatelessWidget {
const BrandMark({super.key, this.size = 64});
@ -8,19 +10,21 @@ class BrandMark extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final fill = isDark ? AppTheme.inkDark : AppTheme.inkLight;
final fg = isDark ? AppTheme.canvasDark : Colors.white;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: scheme.primary,
borderRadius: BorderRadius.circular(size * 0.28),
color: fill,
borderRadius: BorderRadius.circular(AppTheme.rPanel),
),
alignment: Alignment.center,
child: Text(
'Y',
style: TextStyle(
color: scheme.onPrimary,
color: fg,
fontWeight: FontWeight.w700,
fontSize: size * 0.42,
height: 1,

View File

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import '../models/models.dart';
import '../theme/app_theme.dart';
/// iMessage-like bubble. Twin messages keep a warm label + soft tint (PRD §3.1).
/// Twin messages get a warm thin border + label (PRD §3.1 ).
class MessageBubble extends StatelessWidget {
const MessageBubble({
super.key,
@ -34,20 +34,23 @@ class MessageBubble extends StatelessWidget {
final Color bg;
final Color fg;
Border? border;
if (retracted) {
bg = scheme.surfaceContainerHigh;
fg = scheme.onSurfaceVariant;
} else if (twin) {
bg = brightness == Brightness.dark
? const Color(0xFF2A2418)
: const Color(0xFFFFF6E8);
? const Color(0xFF221C14)
: const Color(0xFFFAF6F0);
fg = scheme.onSurface;
border = Border.all(color: accent.withValues(alpha: 0.4), width: 1);
} else if (isMine) {
bg = AppTheme.mineBubble(brightness);
fg = AppTheme.mineBubbleFg(brightness);
} else {
bg = AppTheme.peerBubble(brightness);
fg = scheme.onSurface;
border = Border.all(color: scheme.outlineVariant.withValues(alpha: 0.9));
}
final radius = BorderRadius.only(
@ -58,12 +61,12 @@ class MessageBubble extends StatelessWidget {
);
final bubble = Container(
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.74),
padding: const EdgeInsets.fromLTRB(14, 9, 14, 7),
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.76),
padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
decoration: BoxDecoration(
color: bg,
borderRadius: radius,
border: twin ? Border.all(color: accent.withValues(alpha: 0.35)) : null,
border: border,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -71,13 +74,13 @@ class MessageBubble extends StatelessWidget {
children: [
if (twin)
Padding(
padding: const EdgeInsets.only(bottom: 3),
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'와카뷰',
style: theme.textTheme.labelSmall?.copyWith(
color: accent,
fontWeight: FontWeight.w700,
letterSpacing: 0.15,
letterSpacing: 0.2,
),
),
),
@ -96,15 +99,12 @@ class MessageBubble extends StatelessWidget {
else
Text(
message.text,
style: theme.textTheme.bodyMedium?.copyWith(color: fg, height: 1.3),
style: theme.textTheme.bodyMedium?.copyWith(color: fg, height: 1.4),
),
const SizedBox(height: 3),
const SizedBox(height: 4),
Text(
_time(message.createdAt),
style: theme.textTheme.labelSmall?.copyWith(
color: fg.withValues(alpha: isMine && !twin && !retracted ? 0.7 : 0.5),
fontSize: 10,
),
style: theme.textTheme.labelSmall?.copyWith(color: fg.withValues(alpha: 0.55), fontSize: 10),
),
],
),
@ -135,7 +135,7 @@ class MessageBubble extends StatelessWidget {
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 10),
padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 12),
child: Align(
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
child: content,

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Primary CTA messenger blue fill (API name kept for call sites).
/// Primary CTA Soft Neutral ink fill (API name kept for call sites).
class PrimaryGradientButton extends StatelessWidget {
const PrimaryGradientButton({
super.key,
@ -17,12 +17,12 @@ class PrimaryGradientButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final bg = scheme.primary;
final fg = scheme.onPrimary;
final isDark = Theme.of(context).brightness == Brightness.dark;
final bg = isDark ? AppTheme.inkDark : AppTheme.inkLight;
final fg = isDark ? AppTheme.canvasDark : Colors.white;
final disabled = onPressed == null || loading;
return SizedBox(
height: 50,
height: 48,
width: double.infinity,
child: FilledButton(
onPressed: disabled && !loading ? null : onPressed,
@ -44,7 +44,7 @@ class PrimaryGradientButton extends StatelessWidget {
)
: Text(
label,
style: TextStyle(color: fg, fontWeight: FontWeight.w600, fontSize: 16),
style: TextStyle(color: fg, fontWeight: FontWeight.w600, fontSize: 15),
),
),
);