feat: finish Track A polish and Track B demo pairing content
Add PATCH contact for missing peer IDs, contacts banner and edit UI, enrich /demo with pairing steps, and update tester/signup guidance. Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
parent
fdd02d8252
commit
c7029ab2cd
|
|
@ -20,6 +20,21 @@ type createContactRequest struct {
|
||||||
RelationshipNote string `json:"relationship_note"`
|
RelationshipNote string `json:"relationship_note"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type updateContactRequest struct {
|
||||||
|
DisplayName string `json:"display_name" binding:"required"`
|
||||||
|
ContactUserID *uint `json:"contact_user_id"`
|
||||||
|
RelationshipNote string `json:"relationship_note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactJSON(ct Contact) gin.H {
|
||||||
|
return gin.H{
|
||||||
|
"id": ct.ID,
|
||||||
|
"display_name": ct.DisplayName,
|
||||||
|
"contact_user_id": ct.ContactUserID,
|
||||||
|
"relationship_note": ct.RelationshipNote,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
InviteCode string `json:"invite_code" binding:"required"`
|
InviteCode string `json:"invite_code" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
@ -242,12 +257,7 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, contactJSON(contact))
|
||||||
"id": contact.ID,
|
|
||||||
"display_name": contact.DisplayName,
|
|
||||||
"contact_user_id": contact.ContactUserID,
|
|
||||||
"relationship_note": contact.RelationshipNote,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
r.GET("/users/:id/contacts", func(c *gin.Context) {
|
r.GET("/users/:id/contacts", func(c *gin.Context) {
|
||||||
|
|
@ -262,16 +272,47 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||||
db.Where("owner_user_id = ?", userID).Order("id").Find(&contacts)
|
db.Where("owner_user_id = ?", userID).Order("id").Find(&contacts)
|
||||||
out := make([]gin.H, 0, len(contacts))
|
out := make([]gin.H, 0, len(contacts))
|
||||||
for _, ct := range contacts {
|
for _, ct := range contacts {
|
||||||
out = append(out, gin.H{
|
out = append(out, contactJSON(ct))
|
||||||
"id": ct.ID,
|
|
||||||
"display_name": ct.DisplayName,
|
|
||||||
"contact_user_id": ct.ContactUserID,
|
|
||||||
"relationship_note": ct.RelationshipNote,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"contacts": out})
|
c.JSON(http.StatusOK, gin.H{"contacts": out})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.PATCH("/users/:id/contacts/:contactId", func(c *gin.Context) {
|
||||||
|
userID, ok := parseUintParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireSelf(c, db, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contactID, ok := parseUintParam(c, "contactId")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updateContactRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var contact Contact
|
||||||
|
if err := db.Where("id = ? AND owner_user_id = ?", contactID, userID).First(&contact).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"detail": "contact not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ContactUserID != nil && *req.ContactUserID == userID {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "cannot set contact_user_id to yourself"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contact.DisplayName = req.DisplayName
|
||||||
|
contact.ContactUserID = req.ContactUserID
|
||||||
|
contact.RelationshipNote = req.RelationshipNote
|
||||||
|
if err := db.Save(&contact).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, contactJSON(contact))
|
||||||
|
})
|
||||||
|
|
||||||
r.DELETE("/users/:id/contacts/:contactId", func(c *gin.Context) {
|
r.DELETE("/users/:id/contacts/:contactId", func(c *gin.Context) {
|
||||||
userID, ok := parseUintParam(c, "id")
|
userID, ok := parseUintParam(c, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,46 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestPatchContactSetsPeerUserID(t *testing.T) {
|
||||||
|
server, _ := setupTestServer(t)
|
||||||
|
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
|
||||||
|
peerID, _ := mustSignup(t, server.URL, "상대")
|
||||||
|
|
||||||
|
createResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
|
||||||
|
DisplayName: "이름만",
|
||||||
|
})
|
||||||
|
if createResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("create contact without peer: %d", createResp.StatusCode)
|
||||||
|
}
|
||||||
|
var created map[string]interface{}
|
||||||
|
json.NewDecoder(createResp.Body).Decode(&created)
|
||||||
|
contactID := uint(created["id"].(float64))
|
||||||
|
if created["contact_user_id"] != nil {
|
||||||
|
t.Fatalf("expected null peer id, got %v", created["contact_user_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
patchResp := patchJSONAuth(
|
||||||
|
t,
|
||||||
|
server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts/"+strconv.FormatUint(uint64(contactID), 10),
|
||||||
|
ownerToken,
|
||||||
|
updateContactRequest{
|
||||||
|
DisplayName: "상대방",
|
||||||
|
ContactUserID: &peerID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if patchResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("patch contact: %d", patchResp.StatusCode)
|
||||||
|
}
|
||||||
|
var updated map[string]interface{}
|
||||||
|
json.NewDecoder(patchResp.Body).Decode(&updated)
|
||||||
|
if uint(updated["contact_user_id"].(float64)) != peerID {
|
||||||
|
t.Fatalf("expected peer %d, got %v", peerID, updated["contact_user_id"])
|
||||||
|
}
|
||||||
|
if updated["display_name"] != "상대방" {
|
||||||
|
t.Fatalf("display_name: %v", updated["display_name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInvitesRequireAdminToken(t *testing.T) {
|
func TestInvitesRequireAdminToken(t *testing.T) {
|
||||||
server, _ := setupTestServer(t)
|
server, _ := setupTestServer(t)
|
||||||
resp := postJSON(t, server.URL+"/invites", nil)
|
resp := postJSON(t, server.URL+"/invites", nil)
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,17 @@ func registerDemoRoutes(r *gin.Engine) {
|
||||||
"demo_invite_code": demoInviteCode,
|
"demo_invite_code": demoInviteCode,
|
||||||
"demo_display_name": "테스터",
|
"demo_display_name": "테스터",
|
||||||
"hint": "회원가입 화면에 표시된 테스트 코드를 그대로 쓰면 됩니다.",
|
"hint": "회원가입 화면에 표시된 테스트 코드를 그대로 쓰면 됩니다.",
|
||||||
|
"pairing_steps": []string{
|
||||||
|
"두 명이 같은 DEMO-YKAVU 코드로 각각 가입한다 (시크릿/다른 브라우저).",
|
||||||
|
"각자 대화 목록의 내 사용자 ID를 복사해 상대에게 알려 준다.",
|
||||||
|
"연락처에 상대 표시 이름 + 숫자 ID를 넣고 추가한 뒤 「대화」를 누른다.",
|
||||||
|
"메시지를 보내고, 자율성 L1에서 와카뷰 초안을 한 번 승인·전송해 본다.",
|
||||||
|
"L0(비서)에서는 초안을 「입력창으로 옮기기」만 되며 — 직접 보낸다.",
|
||||||
|
},
|
||||||
|
"notes": []string{
|
||||||
|
"대화는 표시 이름이 아니라 숫자 사용자 ID로 연결됩니다.",
|
||||||
|
"ID 없는 옛 연락처는 연락처 화면에서 「ID 입력」으로 고치면 됩니다.",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ func TestDemoInviteReusableForMultipleSignups(t *testing.T) {
|
||||||
if demo["demo_invite_code"] != demoInviteCode {
|
if demo["demo_invite_code"] != demoInviteCode {
|
||||||
t.Fatalf("demo code: %v", demo["demo_invite_code"])
|
t.Fatalf("demo code: %v", demo["demo_invite_code"])
|
||||||
}
|
}
|
||||||
|
steps, ok := demo["pairing_steps"].([]any)
|
||||||
|
if !ok || len(steps) < 3 {
|
||||||
|
t.Fatalf("expected pairing_steps on /demo, got %#v", demo["pairing_steps"])
|
||||||
|
}
|
||||||
|
|
||||||
a := postJSON(t, server.URL+"/auth/signup", signupRequest{
|
a := postJSON(t, server.URL+"/auth/signup", signupRequest{
|
||||||
InviteCode: demoInviteCode,
|
InviteCode: demoInviteCode,
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,21 @@ func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Respo
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func patchJSONAuth(t *testing.T, url, token string, body interface{}) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
req, _ := http.NewRequest(http.MethodPatch, url, bytes.NewReader(b))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("patch %s: %v", url, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
func deleteJSONAuth(t *testing.T, url, token string) *http.Response {
|
func deleteJSONAuth(t *testing.T, url, token string) *http.Response {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,16 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
|
||||||
| **N4-A3** | 빈 상태·에러·L0 패널 | done | L0는「입력창으로 옮기기」 |
|
| **N4-A3** | 빈 상태·에러·L0 패널 | done | L0는「입력창으로 옮기기」 |
|
||||||
| **N4-A4** | 대화 목록 이름·밀도 | done | 연락처 표시명 매핑 |
|
| **N4-A4** | 대화 목록 이름·밀도 | done | 연락처 표시명 매핑 |
|
||||||
| **N4-A5** | 프로덕션 web 재빌드 | done | `msn.iykyka.com` health 200 (`3d00b00`) |
|
| **N4-A5** | 프로덕션 web 재빌드 | done | `msn.iykyka.com` health 200 (`3d00b00`) |
|
||||||
|
| **N4-A6** | ID 없는 연락처 수정(PATCH)·배너 | done | 「ID 입력」으로 peer ID 보강 |
|
||||||
|
|
||||||
|
### Track B — 데모 콘텐츠 (테스터 페어링)
|
||||||
|
|
||||||
|
| ID | 작업 | Status | 완료 조건 |
|
||||||
|
|----|------|--------|-----------|
|
||||||
|
| **N4-B1** | `/demo` pairing_steps·notes | done | GET `/demo`에 페어링 단계 |
|
||||||
|
| **N4-B2** | 테스터 가이드 페어링 문서화 | done | `docs/tester-guide.md` ID 교환 플로우 |
|
||||||
|
| **N4-B3** | 가입 화면 페어링 안내 | done | Signup 데모 패널에 한 줄 팁 |
|
||||||
|
| **N4-B4** | 프로덕션 core+web 재배포 | todo | `/demo` steps + UI 스모크 |
|
||||||
|
|
||||||
### FCM
|
### FCM
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# 와카뷰 테스터 안내 (N3-6)
|
# 와카뷰 테스터 안내 (N3-6 / Track B)
|
||||||
|
|
||||||
## 접속
|
## 접속
|
||||||
|
|
||||||
|
|
@ -8,17 +8,28 @@
|
||||||
|
|
||||||
같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`).
|
같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`).
|
||||||
|
|
||||||
## 권장 플로우 (5분)
|
API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포함.
|
||||||
|
|
||||||
1. 가입 — 초대 코드 `DEMO-YKAVU` 입력
|
## 권장 페어링 플로우 (5~10분)
|
||||||
2. 말투 샘플 온보딩 — 몇 줄 적거나 스킵
|
|
||||||
3. 연락처에 상대 유저 등록 → 대화 시작
|
두 명(또는 시크릿 창 두 개)으로 진행합니다. **대화는 닉네임이 아니라 숫자 사용자 ID로 연결됩니다.**
|
||||||
4. 메시지 전송 · 분신 초안(L1) 한 번 시도
|
|
||||||
5. (선택) 자율성 L0~L2 / 거부권 / 사후알림 함 확인
|
1. **가입** — 초대 코드 `DEMO-YKAVU` 입력 (각자 다른 표시 이름 권장)
|
||||||
|
2. **말투 샘플** — 몇 줄 적거나 스킵
|
||||||
|
3. **내 ID 복사** — 대화 목록 상단「내 사용자 ID」칩을 탭해 복사하고 상대에게 전달
|
||||||
|
4. **연락처 추가** — 상대 표시 이름 + **상대의 숫자 ID(필수)** → 추가 → 「대화」
|
||||||
|
5. **메시지** — 사람 모드로 한두 줄 주고받기
|
||||||
|
6. **와카뷰 초안** — 메뉴 → 자율성에서 **L1** → 초안이 뜨면 수정/승인하고 보내기
|
||||||
|
7. (선택) L0에서는「입력창으로 옮기기」만 됩니다. L2·거부권·사후알림 함도 눌러 보세요.
|
||||||
|
|
||||||
|
### ID 없는 옛 연락처
|
||||||
|
|
||||||
|
이름만 넣고 ID를 비운 연락처는 대화가 안 됩니다. 연락처 화면에서 **「ID 입력」**으로 숫자 ID를 채우면 됩니다 (삭제 후 재추가 불필요).
|
||||||
|
|
||||||
## 알아둘 점
|
## 알아둘 점
|
||||||
|
|
||||||
- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다. (이전에 `no_key`이던 상태는 해소됨)
|
- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다.
|
||||||
|
- **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송.
|
||||||
- 푸시(FCM)는 아직 플레이스홀더 단계일 수 있습니다.
|
- 푸시(FCM)는 아직 플레이스홀더 단계일 수 있습니다.
|
||||||
- 문제/스크린샷은 Master에게 전달해 주세요.
|
- 문제/스크린샷은 Master에게 전달해 주세요.
|
||||||
- 민감 정보·실명 대화는 베타 특성상 최소화해 주세요.
|
- 민감 정보·실명 대화는 베타 특성상 최소화해 주세요.
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
final session = context.read<SessionState>();
|
final session = context.read<SessionState>();
|
||||||
final me = session.user;
|
final me = session.user;
|
||||||
if (me == null || contact.contactUserId == null) {
|
if (me == null || contact.contactUserId == null) {
|
||||||
setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 삭제 후 숫자 ID와 함께 다시 추가하세요.');
|
setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 「ID 입력」으로 숫자 ID를 넣으세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -142,6 +142,86 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _editContact(Contact contact) async {
|
||||||
|
final nameCtrl = TextEditingController(text: contact.displayName);
|
||||||
|
final peerCtrl = TextEditingController(
|
||||||
|
text: contact.contactUserId == null ? '' : '${contact.contactUserId}',
|
||||||
|
);
|
||||||
|
final noteCtrl = TextEditingController(text: contact.relationshipNote);
|
||||||
|
final session = context.read<SessionState>();
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
contact.contactUserId == null
|
||||||
|
? '대화하려면 상대의 숫자 사용자 ID가 필요합니다. 삭제하지 말고 여기서 채워 주세요.'
|
||||||
|
: '표시 이름·상대 ID·메모를 고칠 수 있습니다.',
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: nameCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: '표시 이름'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: peerCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
autofocus: contact.contactUserId == null,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '상대 사용자 ID (숫자, 필수)',
|
||||||
|
helperText: '상대 대화 목록에 보이는 숫자 ID',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: noteCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('저장')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true || !mounted || session.user == null) return;
|
||||||
|
final name = nameCtrl.text.trim();
|
||||||
|
final peer = int.tryParse(peerCtrl.text.trim());
|
||||||
|
if (name.isEmpty) return;
|
||||||
|
if (peer == null) {
|
||||||
|
setState(() => _error = '상대 사용자 ID(숫자)를 입력해야 합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (peer == session.user!.id) {
|
||||||
|
setState(() => _error = '자기 자신은 연락처에 넣을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final updated = await session.api.updateContact(
|
||||||
|
userId: session.user!.id,
|
||||||
|
contactId: contact.id,
|
||||||
|
displayName: name,
|
||||||
|
contactUserId: peer,
|
||||||
|
relationshipNote: noteCtrl.text.trim(),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList();
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
setState(() => _error = '수정 실패 (${e.statusCode}): ${e.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _delete(Contact c) async {
|
Future<void> _delete(Contact c) async {
|
||||||
final session = context.read<SessionState>();
|
final session = context.read<SessionState>();
|
||||||
if (session.user == null) return;
|
if (session.user == null) return;
|
||||||
|
|
@ -166,6 +246,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final me = context.watch<SessionState>().user?.id;
|
final me = context.watch<SessionState>().user?.id;
|
||||||
|
final missingIdCount = _contacts.where((c) => c.contactUserId == null).length;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('연락처'),
|
title: const Text('연락처'),
|
||||||
|
|
@ -190,6 +271,24 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: MyUserIdChip(userId: me),
|
child: MyUserIdChip(userId: me),
|
||||||
),
|
),
|
||||||
|
if (missingIdCount > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
child: Material(
|
||||||
|
color: theme.colorScheme.errorContainer.withValues(alpha: 0.55),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Text(
|
||||||
|
'사용자 ID가 없는 연락처 $missingIdCount개 — 「ID 입력」으로 숫자 ID를 채우면 대화를 시작할 수 있습니다.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onErrorContainer,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
|
@ -247,18 +346,20 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (c.contactUserId != null)
|
if (c.contactUserId != null) ...[
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: () => _startChat(c),
|
onPressed: () => _startChat(c),
|
||||||
child: const Text('대화'),
|
child: const Text('대화'),
|
||||||
)
|
),
|
||||||
else
|
IconButton(
|
||||||
TextButton(
|
tooltip: '수정',
|
||||||
onPressed: () {
|
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||||
setState(() => _error =
|
onPressed: () => _editContact(c),
|
||||||
'${c.displayName}: 숫자 ID가 없어 대화할 수 없습니다. 삭제 후 ID와 함께 다시 추가하세요.');
|
),
|
||||||
},
|
] else
|
||||||
child: const Text('안내'),
|
FilledButton(
|
||||||
|
onPressed: () => _editContact(c),
|
||||||
|
child: const Text('ID 입력'),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: '삭제',
|
tooltip: '삭제',
|
||||||
|
|
@ -267,7 +368,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: c.contactUserId == null ? null : () => _startChat(c),
|
onTap: () {
|
||||||
|
if (c.contactUserId == null) {
|
||||||
|
_editContact(c);
|
||||||
|
} else {
|
||||||
|
_startChat(c);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 72),
|
const SizedBox(height: 72),
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,17 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
height: 1.45,
|
height: 1.45,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: () async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||||
|
);
|
||||||
|
await _load();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.contacts_outlined),
|
||||||
|
label: const Text('연락처 열기'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,14 @@ class _DemoTestPanel extends StatelessWidget {
|
||||||
'탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능',
|
'탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'페어링: 두 명이 각자 가입 → 내 사용자 ID를 교환 → 연락처에 상대 숫자 ID로 대화 시작',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,21 @@ class ApiClient {
|
||||||
return Contact.fromJson(json);
|
return Contact.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Contact> updateContact({
|
||||||
|
required int userId,
|
||||||
|
required int contactId,
|
||||||
|
required String displayName,
|
||||||
|
int? contactUserId,
|
||||||
|
String relationshipNote = '',
|
||||||
|
}) async {
|
||||||
|
final json = await _json('PATCH', '/users/$userId/contacts/$contactId', body: {
|
||||||
|
'display_name': displayName,
|
||||||
|
if (contactUserId != null) 'contact_user_id': contactUserId,
|
||||||
|
'relationship_note': relationshipNote,
|
||||||
|
});
|
||||||
|
return Contact.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> deleteContact(int userId, int contactId) async {
|
Future<void> deleteContact(int userId, int contactId) async {
|
||||||
await _json('DELETE', '/users/$userId/contacts/$contactId');
|
await _json('DELETE', '/users/$userId/contacts/$contactId');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue