Compare commits
No commits in common. "ee8a41de179fbbbfa0bc42eed3bdab2f47110fa0" and "0aae8fb5789966285f08605dce81e9c7ee86a906" have entirely different histories.
ee8a41de17
...
0aae8fb578
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||||
|
|
|
||||||
|
|
@ -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`만 있고 페르소나 필드 없음)
|
||||||
|
|
|
||||||
|
|
@ -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, _) {
|
||||||
|
|
|
||||||
|
|
@ -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});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -83,116 +83,67 @@ 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: Column(
|
||||||
content: SingleChildScrollView(
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
if (myId != null) ...[
|
||||||
children: [
|
MyUserIdChip(userId: myId),
|
||||||
if (myId != null) ...[
|
const SizedBox(height: 12),
|
||||||
MyUserIdChip(userId: myId),
|
Text(
|
||||||
const SizedBox(height: 12),
|
'상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요.',
|
||||||
Text(
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
'상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요. '
|
),
|
||||||
'2명 이상 넣으면 그룹 대화로 만들어집니다.',
|
const SizedBox(height: 12),
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
],
|
||||||
),
|
TextField(
|
||||||
const SizedBox(height: 12),
|
controller: peerCtrl,
|
||||||
],
|
keyboardType: TextInputType.number,
|
||||||
for (var i = 0; i < peerCtrls.length; i++)
|
autofocus: true,
|
||||||
Padding(
|
decoration: const InputDecoration(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
labelText: '상대 사용자 ID (숫자)',
|
||||||
child: Row(
|
helperText: '이름/닉네임이 아니라 숫자 ID입니다. 연락처에 등록돼 있으면 연락처에서 시작하세요.',
|
||||||
children: [
|
),
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: peerCtrls[i],
|
|
||||||
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('상대 추가 (그룹으로)'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
|
||||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('만들기')),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), 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(
|
|
||||||
const SnackBar(content: Text('상대 사용자 ID는 숫자여야 합니다. (예: 12)')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (id == 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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('상대 사용자 ID를 1명 이상 입력하세요.')),
|
const SnackBar(content: Text('상대 사용자 ID는 숫자여야 합니다. (예: 12)')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final isGroup = peers.length > 1;
|
if (peer == me.id) {
|
||||||
try {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
final conv = await session.api.createConversation(
|
const SnackBar(content: Text('자기 자신과는 대화를 만들 수 없습니다.')),
|
||||||
userIds: [me.id, ...peers],
|
|
||||||
isGroup: isGroup,
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final conv = await session.api.createConversation(userIds: [me.id, peer]);
|
||||||
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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue