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 .escalation_filter import check as check_escalation
from .generation import draft_reply, last_incoming_text, load_dotenv_if_present from .generation import draft_reply, last_incoming_text, load_dotenv_if_present
from .retrieve_style import retrieve as retrieve_style_examples from .retrieve_style import retrieve as retrieve_style_examples
from .summarize import summarize_messages
@asynccontextmanager @asynccontextmanager
@ -73,22 +72,3 @@ def draft(req: DraftRequest):
status, text = draft_reply(style_examples, req.context_lines, model=req.model) status, text = draft_reply(style_examples, req.context_lines, model=req.model)
return DraftResponse(status=status, text=text) 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(): def test_draft_rejects_neither_style_source():
resp = client.post("/draft", json={"context_lines": ["상대: 안녕"]}) resp = client.post("/draft", json={"context_lines": ["상대: 안녕"]})
assert resp.status_code == 422 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 { for _, pp := range pps {
participantIDs = append(participantIDs, pp.UserID) 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{ out = append(out, gin.H{
"id": conv.ID, "id": conv.ID,
"is_group": conv.IsGroup, "is_group": conv.IsGroup,
"twin_disabled_by_peer": conv.TwinDisabledByPeer, "twin_disabled_by_peer": conv.TwinDisabledByPeer,
"user_ids": participantIDs, "user_ids": participantIDs,
"created_at": conv.CreatedAt, "created_at": conv.CreatedAt,
"unread_count": unreadCount,
}) })
} }
c.JSON(http.StatusOK, gin.H{"conversations": out}) 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 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 { type escalationCheckRequest struct {
Text string `json:"text"` 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) registerBRoutes(r, db)
registerInviteOpsRoutes(r, db) registerInviteOpsRoutes(r, db)
registerDemoRoutes(r) registerDemoRoutes(r)
registerGroupSummaryRoutes(r, db, ai)
r.GET("/admin/metrics", func(c *gin.Context) { r.GET("/admin/metrics", func(c *gin.Context) {
if !requireAdmin(c) { if !requireAdmin(c) {
@ -263,16 +262,6 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
return 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) result, err := ai.checkEscalation(req.Text)
runtimeMetrics.recordEscalate(err) runtimeMetrics.recordEscalate(err)
if err != nil { if err != nil {

View File

@ -50,17 +50,6 @@ func mockAIService(t *testing.T) *httptest.Server {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out) 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: default:
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }

View File

@ -74,10 +74,6 @@ type ConversationParticipant struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
ConversationID uint `gorm:"not null;index"` ConversationID uint `gorm:"not null;index"`
UserID 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 { type Message struct {

View File

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

View File

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

View File

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

View File

@ -40,7 +40,6 @@ class ConversationSummary {
required this.isGroup, required this.isGroup,
required this.userIds, required this.userIds,
required this.twinDisabledByPeer, required this.twinDisabledByPeer,
this.unreadCount = 0,
this.createdAt, this.createdAt,
}); });
@ -48,7 +47,6 @@ class ConversationSummary {
final bool isGroup; final bool isGroup;
final List<int> userIds; final List<int> userIds;
final bool twinDisabledByPeer; final bool twinDisabledByPeer;
final int unreadCount;
final DateTime? createdAt; final DateTime? createdAt;
factory ConversationSummary.fromJson(Map<String, dynamic> json) { factory ConversationSummary.fromJson(Map<String, dynamic> json) {
@ -58,7 +56,6 @@ class ConversationSummary {
isGroup: json['is_group'] as bool? ?? false, isGroup: json['is_group'] as bool? ?? false,
userIds: rawIds.map((e) => e as int).toList(), userIds: rawIds.map((e) => e as int).toList(),
twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false, twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false,
unreadCount: json['unread_count'] as int? ?? 0,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''), 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 { class DraftResult {
DraftResult({required this.status, required this.text}); DraftResult({required this.status, required this.text});

View File

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

View File

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

View File

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

View File

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

View File

@ -185,18 +185,6 @@ class ApiClient {
await _json('POST', '/conversations/$conversationId/veto'); 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 { Future<void> retractMessage(int messageId) async {
await _json('POST', '/messages/$messageId/retract'); await _json('POST', '/messages/$messageId/retract');
} }

View File

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

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; 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 { class BrandMark extends StatelessWidget {
const BrandMark({super.key, this.size = 64}); const BrandMark({super.key, this.size = 64});
@ -8,19 +10,21 @@ class BrandMark extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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( return Container(
width: size, width: size,
height: size, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: scheme.primary, color: fill,
borderRadius: BorderRadius.circular(size * 0.28), borderRadius: BorderRadius.circular(AppTheme.rPanel),
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
'Y', 'Y',
style: TextStyle( style: TextStyle(
color: scheme.onPrimary, color: fg,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontSize: size * 0.42, fontSize: size * 0.42,
height: 1, height: 1,

View File

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

View File

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