From 3d00b00de7001c856b1352e80dc98e7e0737fdcc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 04:36:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20Track=20A=20messenger=20UX=20?= =?UTF-8?q?=E2=80=94=20ID=20chip,=20contacts=20chat,=20L0=20draft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show/copy numeric user ID, require peer ID for contacts, start chat in one tap, clarify empty states, and route L0 twin drafts into the human composer. Co-authored-by: okuma --- docs/deploy-checklist.md | 12 +- mobile/lib/screens/chat_screen.dart | 37 ++++- mobile/lib/screens/contacts_screen.dart | 134 ++++++++++----- .../lib/screens/conversation_list_screen.dart | 153 ++++++++++++++---- mobile/lib/widgets/my_user_id_chip.dart | 78 +++++++++ 5 files changed, 336 insertions(+), 78 deletions(-) create mode 100644 mobile/lib/widgets/my_user_id_chip.dart diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index b443dc5..4a5dfed 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -29,7 +29,7 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. ### NOW - 앱 코드는 클로즈드 베타 직전 수준 -- **`https://msn.iykyka.com` 라이브 + N3 안정화 완료**. Gemini 키 미설정(`no_key`). 다음: N4 또는 키 주입 +- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK**. 다음: N4 FCM·Android QA (선택) - 실 FCM · Android UI 수동 QA · 사람 PoC 실행은 남음 ### NEXT 순서 @@ -139,6 +139,16 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ ## N4 — 베타 품질 잔여 +### Track A — 메신저 UX (대화 열기 경로) + +| ID | 작업 | Status | 완료 조건 | +|----|------|--------|-----------| +| **N4-A1** | 내 사용자 ID 표시·복사 | done | 대화목록·연락처에 `MyUserIdChip` | +| **N4-A2** | 연락처 원탭 대화 + ID 필수 | done | 숫자 peer ID 없으면 추가/대화 차단·안내 | +| **N4-A3** | 빈 상태·에러·L0 패널 | done | L0는「입력창으로 옮기기」 | +| **N4-A4** | 대화 목록 이름·밀도 | done | 연락처 표시명 매핑 | +| **N4-A5** | 프로덕션 web 재빌드 | todo | `msn.iykyka.com` 스모크 | + ### FCM | ID | 작업 | Status | 완료 조건 | diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart index 7de7c72..02f418b 100644 --- a/mobile/lib/screens/chat_screen.dart +++ b/mobile/lib/screens/chat_screen.dart @@ -175,6 +175,18 @@ class _ChatScreenState extends State { if (draft == null || draft.isEscalate || session.user == null) return; final text = _draftEdit.text.trim(); if (text.isEmpty) return; + + // L0: twin send is forbidden server-side — move text to human composer instead. + if (session.autonomyLevel == AutonomyLevel.L0) { + setState(() { + _input.text = text; + _pendingDraft = null; + _draftEdit.clear(); + _banner = 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.'; + }); + return; + } + setState(() => _busy = true); try { final msg = await session.api.sendMessage( @@ -278,6 +290,14 @@ class _ChatScreenState extends State { ); } + final level = context.watch().autonomyLevel; + final isL0 = level == AutonomyLevel.L0; + final title = isL0 + ? '초안 (L0) — 직접 보내기' + : level == AutonomyLevel.L1 + ? 'L1 승인 — 수정 후 보내기' + : '초안 (L2) — 승인 후 보내기'; + return Container( margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), padding: const EdgeInsets.all(16), @@ -293,12 +313,21 @@ class _ChatScreenState extends State { children: [ Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer), const SizedBox(width: 6), - Text( - 'L1 승인 — 수정 후 보내기', - style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSecondaryContainer), + Expanded( + child: Text( + title, + style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSecondaryContainer), + ), ), ], ), + if (isL0) ...[ + const SizedBox(height: 6), + Text( + 'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.', + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSecondaryContainer), + ), + ], const SizedBox(height: 10), TextField( controller: _draftEdit, @@ -319,7 +348,7 @@ class _ChatScreenState extends State { const SizedBox(width: 8), FilledButton( onPressed: _busy ? null : _sendTwinApproved, - child: const Text('승인하고 보내기'), + child: Text(isL0 ? '입력창으로 옮기기' : '승인하고 보내기'), ), ], ), diff --git a/mobile/lib/screens/contacts_screen.dart b/mobile/lib/screens/contacts_screen.dart index 7bc5ffc..0bd8c8e 100644 --- a/mobile/lib/screens/contacts_screen.dart +++ b/mobile/lib/screens/contacts_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/api_client.dart'; import '../state/session_state.dart'; +import '../widgets/my_user_id_chip.dart'; import 'chat_screen.dart'; class ContactsScreen extends StatefulWidget { @@ -45,32 +46,44 @@ class _ContactsScreenState extends State { final nameCtrl = TextEditingController(); final peerCtrl = TextEditingController(); final noteCtrl = TextEditingController(); + final session = context.read(); + final myId = session.user?.id; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('연락처 추가'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameCtrl, - decoration: const InputDecoration(labelText: '표시 이름'), - ), - const SizedBox(height: 12), - TextField( - controller: peerCtrl, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: '상대 사용자 ID (선택)', - helperText: '대화 시작에 필요', + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (myId != null) ...[ + MyUserIdChip(userId: myId), + const SizedBox(height: 12), + ], + TextField( + controller: nameCtrl, + decoration: const InputDecoration( + labelText: '표시 이름', + helperText: '목록에 보일 이름 (예: 친구 닉네임)', + ), ), - ), - const SizedBox(height: 12), - TextField( - controller: noteCtrl, - decoration: const InputDecoration(labelText: '관계 메모 (선택)'), - ), - ], + const SizedBox(height: 12), + TextField( + controller: peerCtrl, + keyboardType: TextInputType.number, + 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('취소')), @@ -79,20 +92,30 @@ class _ContactsScreenState extends State { ), ); if (ok != true || !mounted) return; - final session = context.read(); final name = nameCtrl.text.trim(); + final peer = int.tryParse(peerCtrl.text.trim()); if (name.isEmpty || session.user == null) return; + if (peer == null) { + setState(() => _error = '상대 사용자 ID(숫자)를 입력해야 대화를 시작할 수 있습니다.'); + return; + } + if (peer == session.user!.id) { + setState(() => _error = '자기 자신은 연락처에 넣을 수 없습니다.'); + return; + } try { - final peer = int.tryParse(peerCtrl.text.trim()); final created = await session.api.createContact( userId: session.user!.id, displayName: name, contactUserId: peer, relationshipNote: noteCtrl.text.trim(), ); - setState(() => _contacts = [..._contacts, created]); + setState(() { + _contacts = [..._contacts, created]; + _error = null; + }); } on ApiException catch (e) { - setState(() => _error = '추가 실패 (${e.statusCode})'); + setState(() => _error = '추가 실패 (${e.statusCode}): ${e.body}'); } } @@ -100,7 +123,7 @@ class _ContactsScreenState extends State { final session = context.read(); final me = session.user; if (me == null || contact.contactUserId == null) { - setState(() => _error = '상대 사용자 ID가 있는 연락처만 대화를 시작할 수 있습니다.'); + setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 삭제 후 숫자 ID와 함께 다시 추가하세요.'); return; } try { @@ -110,7 +133,9 @@ class _ContactsScreenState extends State { ); if (!mounted) return; await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id, title: contact.displayName)), + MaterialPageRoute( + builder: (_) => ChatScreen(conversationId: conv.id, title: contact.displayName), + ), ); } on ApiException catch (e) { setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}'); @@ -140,8 +165,14 @@ class _ContactsScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final me = context.watch().user?.id; return Scaffold( - appBar: AppBar(title: const Text('연락처')), + appBar: AppBar( + title: const Text('연락처'), + actions: [ + if (me != null) MyUserIdChip(userId: me, compact: true), + ], + ), floatingActionButton: FloatingActionButton( onPressed: _showAddDialog, tooltip: '연락처 추가', @@ -154,6 +185,11 @@ class _ContactsScreenState extends State { : ListView( padding: const EdgeInsets.symmetric(vertical: 4), children: [ + if (me != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: MyUserIdChip(userId: me), + ), if (_error != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -161,17 +197,21 @@ class _ContactsScreenState extends State { ), if (_contacts.isEmpty) Padding( - padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32), + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 32), child: Column( children: [ Icon(Icons.person_add_outlined, size: 40, color: theme.colorScheme.outline), const SizedBox(height: 12), Text('연락처가 없습니다', style: theme.textTheme.titleMedium), - const SizedBox(height: 4), + const SizedBox(height: 8), Text( - '오른쪽 아래 버튼으로 첫 연락처를 추가해 보세요.', + '상대에게 내 ID를 알려 주고, 상대의 숫자 ID를 받아 추가하세요.\n' + '표시 이름만 넣고 ID를 비우면 대화를 시작할 수 없습니다.', textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.45, + ), ), ], ), @@ -192,17 +232,34 @@ class _ContactsScreenState extends State { ), title: Text(c.displayName, style: theme.textTheme.titleSmall), subtitle: Text( - [ - if (c.contactUserId != null) '사용자 #${c.contactUserId}', - if (c.relationshipNote.isNotEmpty) c.relationshipNote, - ].join(' · '), - style: theme.textTheme.bodySmall, + c.contactUserId == null + ? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)' + : [ + '사용자 #${c.contactUserId}', + if (c.relationshipNote.isNotEmpty) c.relationshipNote, + ].join(' · '), + style: theme.textTheme.bodySmall?.copyWith( + color: c.contactUserId == null + ? theme.colorScheme.error + : theme.colorScheme.onSurfaceVariant, + ), ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (c.contactUserId != null) - TextButton(onPressed: () => _startChat(c), child: const Text('대화')), + FilledButton.tonal( + onPressed: () => _startChat(c), + child: const Text('대화'), + ) + else + TextButton( + onPressed: () { + setState(() => _error = + '${c.displayName}: 숫자 ID가 없어 대화할 수 없습니다. 삭제 후 ID와 함께 다시 추가하세요.'); + }, + child: const Text('안내'), + ), IconButton( tooltip: '삭제', icon: const Icon(Icons.delete_outline, size: 20), @@ -210,6 +267,7 @@ class _ContactsScreenState extends State { ), ], ), + onTap: c.contactUserId == null ? null : () => _startChat(c), ), ), const SizedBox(height: 72), diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index 40c846b..4b3b6fb 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/api_client.dart'; import '../state/session_state.dart'; +import '../widgets/my_user_id_chip.dart'; import 'autonomy_settings_screen.dart'; import 'chat_screen.dart'; import 'contacts_screen.dart'; @@ -19,6 +20,7 @@ class ConversationListScreen extends StatefulWidget { class _ConversationListScreenState extends State { List _rooms = []; + Map _peerNames = {}; bool _loading = true; String? _error; @@ -37,7 +39,23 @@ class _ConversationListScreenState extends State { try { final list = await session.api.listConversations(); list.sort((a, b) => b.id.compareTo(a.id)); - setState(() => _rooms = list); + final names = {}; + if (session.user != null) { + try { + final contacts = await session.api.listContacts(session.user!.id); + for (final c in contacts) { + if (c.contactUserId != null) { + names[c.contactUserId!] = c.displayName; + } + } + } on ApiException { + // Names are optional enrichment. + } + } + setState(() { + _rooms = list; + _peerNames = names; + }); } on ApiException catch (e) { setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}'); } finally { @@ -45,20 +63,55 @@ class _ConversationListScreenState extends State { } } + String _titleFor(ConversationSummary room, int? me) { + if (me == null) return '대화방 #${room.id}'; + final peers = room.userIds.where((id) => id != me).toList(); + if (room.isGroup) return '그룹 #${room.id}'; + if (peers.isEmpty) return '나와의 대화'; + final peerId = peers.first; + final name = _peerNames[peerId]; + if (name != null && name.isNotEmpty) return name; + return '상대 #$peerId'; + } + + String _subtitleFor(ConversationSummary room, int? me) { + if (room.twinDisabledByPeer) return '상대가 와카뷰를 거부함'; + final peers = me == null ? const [] : room.userIds.where((id) => id != me).toList(); + final peerPart = peers.isEmpty ? '참가자 없음' : '상대 ID ${peers.first}'; + return '$peerPart · 방 #${room.id}'; + } + Future _createConversation() async { final peerCtrl = TextEditingController(); + final session = context.read(); + final myId = session.user?.id; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('새 대화'), - content: TextField( - controller: peerCtrl, - keyboardType: TextInputType.number, - autofocus: true, - decoration: const InputDecoration( - labelText: '상대 사용자 ID', - helperText: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.', - ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (myId != null) ...[ + MyUserIdChip(userId: myId), + const SizedBox(height: 12), + Text( + '상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요.', + style: Theme.of(ctx).textTheme.bodySmall, + ), + const SizedBox(height: 12), + ], + TextField( + controller: peerCtrl, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration( + labelText: '상대 사용자 ID (숫자)', + helperText: '이름/닉네임이 아니라 숫자 ID입니다. 연락처에 등록돼 있으면 연락처에서 시작하세요.', + ), + ), + ], ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), @@ -67,15 +120,31 @@ class _ConversationListScreenState extends State { ), ); if (ok != true || !mounted) return; - final session = context.read(); final me = session.user; final peer = int.tryParse(peerCtrl.text.trim()); - if (me == null || peer == null) return; + if (me == null) return; + if (peer == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('상대 사용자 ID는 숫자여야 합니다. (예: 12)')), + ); + return; + } + if (peer == me.id) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('자기 자신과는 대화를 만들 수 없습니다.')), + ); + return; + } try { final conv = await session.api.createConversation(userIds: [me.id, peer]); if (!mounted) return; await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id)), + MaterialPageRoute( + builder: (_) => ChatScreen( + conversationId: conv.id, + title: _peerNames[peer] ?? '상대 #$peer', + ), + ), ); await _load(); } on ApiException catch (e) { @@ -105,6 +174,7 @@ class _ConversationListScreenState extends State { appBar: AppBar( title: const Text('와카뷰'), actions: [ + if (me != null) MyUserIdChip(userId: me, compact: true), IconButton( tooltip: '사후 알림', onPressed: () { @@ -161,12 +231,17 @@ class _ConversationListScreenState extends State { padding: const EdgeInsets.symmetric(vertical: 4), children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), child: Text( session.user == null ? '' : '안녕하세요, ${session.user!.displayName}님', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ), + if (me != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: MyUserIdChip(userId: me), + ), if (_error != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -174,20 +249,22 @@ class _ConversationListScreenState extends State { ), if (_rooms.isEmpty) Padding( - padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32), + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 32), child: Column( children: [ Icon(Icons.chat_bubble_outline, size: 40, color: theme.colorScheme.outline), const SizedBox(height: 12), + Text('대화방이 없습니다', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), Text( - '대화방이 없습니다', - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - '연락처나 "새 대화" 버튼으로 첫 대화를 시작해 보세요.', + '1) 내 ID를 상대에게 알려 주세요\n' + '2) 연락처에 상대의 숫자 ID를 넣고 추가\n' + '3) 연락처에서 「대화」또는 행을 탭하세요', textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.45, + ), ), ], ), @@ -204,28 +281,34 @@ class _ConversationListScreenState extends State { ), ), title: Text( - me == null ? '대화방 #${room.id}' : room.titleFor(me), + _titleFor(room, me), style: theme.textTheme.titleSmall, ), - subtitle: room.twinDisabledByPeer - ? Row( - children: [ - Icon(Icons.block, size: 13, color: theme.colorScheme.error), - const SizedBox(width: 4), - Text( - '상대가 와카뷰를 거부함', - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), - ), - ], - ) - : Text('대화방 ID ${room.id}', style: theme.textTheme.bodySmall), + subtitle: Row( + children: [ + if (room.twinDisabledByPeer) ...[ + Icon(Icons.block, size: 13, color: theme.colorScheme.error), + const SizedBox(width: 4), + ], + Expanded( + child: Text( + _subtitleFor(room, me), + style: theme.textTheme.bodySmall?.copyWith( + color: room.twinDisabledByPeer + ? theme.colorScheme.error + : theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), trailing: const Icon(Icons.chevron_right, size: 20), onTap: () async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ChatScreen( conversationId: room.id, - title: me == null ? null : room.titleFor(me), + title: _titleFor(room, me), ), ), ); diff --git a/mobile/lib/widgets/my_user_id_chip.dart b/mobile/lib/widgets/my_user_id_chip.dart new file mode 100644 index 0000000..aa602d6 --- /dev/null +++ b/mobile/lib/widgets/my_user_id_chip.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Shows the signed-in numeric user id with one-tap copy. +class MyUserIdChip extends StatelessWidget { + const MyUserIdChip({super.key, required this.userId, this.compact = false}); + + final int userId; + final bool compact; + + Future _copy(BuildContext context) async { + await Clipboard.setData(ClipboardData(text: '$userId')); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('내 사용자 ID $userId 를 복사했습니다. 상대에게 알려 주세요.'), + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (compact) { + return IconButton( + tooltip: '내 ID $userId 복사', + onPressed: () => _copy(context), + icon: const Icon(Icons.badge_outlined), + ); + } + return Material( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _copy(context), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(Icons.badge_outlined, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '내 사용자 ID', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + '$userId', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + Text( + '탭하여 복사', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 4), + Icon(Icons.copy_rounded, size: 16, color: theme.colorScheme.primary), + ], + ), + ), + ), + ); + } +}