diff --git a/docs/roadmap.md b/docs/roadmap.md index 1a8e786..da60372 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -64,20 +64,20 @@ `POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400, 이미 되돌린 건 409, 성공하면 같은 대화방 WebSocket에 `{"type":"retraction", "id":...}` 브로드캐스트. 일반 메시지 브로드캐스트도 `"type":"message"`를 붙여 클라이언트가 두 이벤트를 구분하게 함. - **되돌리기 UI는 `mobile/`에 있음** — 사후알림 전용 함/푸시는 아직. - 에스컬레이션 로그를 "조회"하는 API는 아직 없음(필요해지면 추가) + **되돌리기 UI는 `mobile/`에 있음**. 사후알림 함은 A3에서 `InboxScreen` + + `GET /users/:id/escalation-logs`로 연결. FCM 푸시는 B. **2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — `mobile/` -- [x] 기본 채팅 UI (대화 목록, 대화방) — `mobile/` 골격. 대화방 목록 API는 아직 없어 ID 직접 - 입장. WebSocket 수신·인간 메시지 전송 연결됨 -- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 초대 코드 가입 화면만 있음. 말투 학습 UX 디테일은 - **사람 PoC #1 결과를 맨 마지막에 반영** (지금은 뼈대만) +- [x] 기본 채팅 UI (대화 목록, 대화방) — `GET`/`POST /conversations`, 히스토리 로드, + WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작 +- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`, + 기기 로컬). 말투 학습 UX 디테일·카피는 **사람 PoC #1 결과를 맨 마지막에 반영** - [x] 분신 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는 서버/AI 프롬프트 측과 이어서 다듬을 것 -- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외 매칭은 - 서버가 전역 키워드만 지원하는 동안 UI도 주제 키워드 CRUD만 -- [x] 에스컬레이션 배너·되돌리기 UI — 초안 escalate 배너 + twin 메시지 되돌리기 버튼. - 사후알림 전용 함/푸시는 아직 없음 +- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는 + 서버 contact-scoped 매칭과 함께 화이트리스트 CRUD +- [x] 에스컬레이션·L1 승인·되돌리기 UI — L1 수정/버리기/재초안/승인 패널 + twin 되돌리기 + + 사후알림 함. FCM 푸시는 B **2.4 안전장치 통합** (전 구간 필수, 타협 불가) - [x] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 — 실제 발견한 우회 @@ -147,10 +147,10 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다. 2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음 3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션· 되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서 -4. [~] 2.3 Flutter 클라이언트 — `mobile/` 골격 착수 완료(가입·채팅·뱃지·거부권·되돌리기·자율성 - 설정). 남은 것: 대화방/연락처 목록 API 연동, 온보딩 말투 UX, drift+SQLCipher, 수동 QA +4. [~] 2.3 Flutter 클라이언트 — A3까지: 가입·말투 온보딩 뼈대·대화/연락처 API 연동·히스토리· + L1 승인 UX·사후 알림 함·뱃지·거부권·되돌리기·자율성. 남은 것: 수동 E2E QA, drift+SQLCipher(B) 5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료. - 클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 2.3 나머지와 함께 + 클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께 - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은 §3(사람 PoC) 이후** - 5-2. [x] 화이트리스트 규칙 CRUD API @@ -176,10 +176,10 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase - [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`) **A3. Flutter — 메신저답게 다듬기** (A1/A2 이후) -- [ ] 대화 목록/연락처 UI를 서버 API에 연결 -- [ ] 온보딩 뼈대 확장 (말투 샘플 입력 UI) -- [ ] L1 승인 플로우 UX 정리 -- [ ] 사후 알림 함 +- [x] 대화 목록/연락처 UI를 서버 API에 연결 +- [x] 온보딩 뼈대 확장 (말투 샘플 입력 UI) +- [x] L1 승인 플로우 UX 정리 +- [x] 사후 알림 함 - [ ] 에뮬레이터/실기기 E2E 수동 QA ##### B. 그다음 — 베타 품질 diff --git a/mobile/README.md b/mobile/README.md index b575924..bc11b3e 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,16 +1,19 @@ # 분신 mobile (Flutter) -Phase 1 클라이언트 골격 (`docs/roadmap.md` §2.3). **v1은 Android 빌드만** 대상으로 한다 +Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). **v1은 Android 빌드만** 대상으로 한다 (`docs/tech-design.md` §8). ## 지금 있는 것 -- 초대 코드 가입 (`POST /auth/signup`) -- 대화방 입장(임시: 대화방 ID 직접 입력 — 목록 API는 아직 core-backend에 없음) -- 채팅 전송 + WebSocket 수신 -- 분신 초안 요청 / L1 승인 발송 -- 분신 뱃지(점선 + 라벨) · 거부권 · 되돌리기 -- 자율성 L0~L2 설정 + 화이트리스트 CRUD UI +- 초대 코드 가입 (`POST /auth/signup`) + Bearer 세션 +- 말투 샘플 온보딩(기기 로컬 저장; 최종 카피는 PoC 이후) +- 대화방 목록/생성 (`GET`/`POST /conversations`) — 수동 ID 입력 제거 +- 연락처 CRUD + 대화 시작 (`/users/:id/contacts`) +- 채팅 히스토리 로드 + WebSocket 수신 +- 분신 초안 / L1 승인(수정·버리기·재초안·승인 발송) +- 분신 뱃지 · 거부권 · 되돌리기 +- 사후 알림 함 (`GET /users/:id/escalation-logs`) +- 자율성 L0~L2 + 화이트리스트 CRUD UI ## 실행 @@ -26,10 +29,18 @@ flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080 flutter run --dart-define=CORE_API_BASE=http://:8080 ``` -## 아직 없는 것 +## E2E 수동 QA 체크 (에뮬레이터/실기기) -- 대화방/연락처 목록 API 연동 (서버에 엔드포인트 추가 필요) -- 온보딩 말투 학습 UX 디테일 (PoC #1 결과는 맨 마지막에 반영) -- drift + SQLCipher 온디바이스 저장 -- 푸시 알림 +1. 초대 코드로 가입 → 말투 샘플 저장(또는 나중에) +2. 연락처에 상대 사용자 ID 등록 → 대화 시작 +3. 메시지 전송 · 새로고침 후 히스토리 유지 +4. 초안 요청 → L1 패널에서 수정/버리기/승인 발송 → 분신 뱃지 확인 +5. 민감 문장으로 에스컬레이션 → 사후 알림 함 진입 +6. 분신 메시지 되돌리기 · 거부권 +7. 자율성 L0~L2 + 화이트리스트 추가/삭제 + +## 아직 없는 것 (B 이후) + +- FCM 푸시 · drift + SQLCipher · 멀티 디바이스 +- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영) - iOS 빌드 (v1 범위 밖) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 1e3c25e..3b35f5c 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'screens/conversation_list_screen.dart'; +import 'screens/onboarding_tone_screen.dart'; import 'screens/signup_screen.dart'; import 'state/session_state.dart'; @@ -33,6 +34,7 @@ class BunsinApp extends StatelessWidget { home: Consumer( builder: (context, s, _) { if (s.user == null) return const SignupScreen(); + if (!s.toneOnboardingDone) return const OnboardingToneScreen(); return const ConversationListScreen(); }, ), diff --git a/mobile/lib/models/models.dart b/mobile/lib/models/models.dart index d639edc..926fe90 100644 --- a/mobile/lib/models/models.dart +++ b/mobile/lib/models/models.dart @@ -34,6 +34,89 @@ class TwinSettings { } } +class ConversationSummary { + ConversationSummary({ + required this.id, + required this.isGroup, + required this.userIds, + required this.twinDisabledByPeer, + this.createdAt, + }); + + final int id; + final bool isGroup; + final List userIds; + final bool twinDisabledByPeer; + final DateTime? createdAt; + + factory ConversationSummary.fromJson(Map json) { + final rawIds = json['user_ids'] as List? ?? const []; + return ConversationSummary( + id: json['id'] as int, + isGroup: json['is_group'] as bool? ?? false, + userIds: rawIds.map((e) => e as int).toList(), + twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false, + createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''), + ); + } + + /// Title helper when peer display names are not yet loaded. + String titleFor(int myUserId) { + final peers = userIds.where((id) => id != myUserId).toList(); + if (isGroup) return '그룹 #$id'; + if (peers.isEmpty) return '나와의 대화 #$id'; + return '대화 · 상대 #${peers.first}'; + } +} + +class Contact { + Contact({ + required this.id, + required this.displayName, + this.contactUserId, + this.relationshipNote = '', + }); + + final int id; + final String displayName; + final int? contactUserId; + final String relationshipNote; + + factory Contact.fromJson(Map json) => Contact( + id: json['id'] as int, + displayName: json['display_name'] as String? ?? '', + contactUserId: json['contact_user_id'] as int?, + relationshipNote: json['relationship_note'] as String? ?? '', + ); +} + +class EscalationLogEntry { + EscalationLogEntry({ + required this.id, + required this.conversationId, + required this.reason, + required this.messageSnippet, + required this.resolved, + required this.createdAt, + }); + + final int id; + final int conversationId; + final String reason; + final String messageSnippet; + final bool resolved; + final DateTime createdAt; + + factory EscalationLogEntry.fromJson(Map json) => EscalationLogEntry( + id: json['id'] as int, + conversationId: json['conversation_id'] as int? ?? 0, + reason: json['reason'] as String? ?? '', + messageSnippet: json['message_snippet'] as String? ?? '', + resolved: json['resolved'] as bool? ?? false, + createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(), + ); +} + class ChatMessage { ChatMessage({ required this.id, diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart index 0c6c537..0e62e99 100644 --- a/mobile/lib/screens/chat_screen.dart +++ b/mobile/lib/screens/chat_screen.dart @@ -10,9 +10,10 @@ import '../state/session_state.dart'; import '../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { - const ChatScreen({super.key, required this.conversationId}); + const ChatScreen({super.key, required this.conversationId, this.title}); final int conversationId; + final String? title; @override State createState() => _ChatScreenState(); @@ -20,18 +21,54 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State { final _input = TextEditingController(); + final _draftEdit = TextEditingController(); + final _scroll = ScrollController(); final _messages = []; ConversationSocket? _socket; StreamSubscription? _sub; String? _banner; DraftResult? _pendingDraft; bool _busy = false; + bool _loadingHistory = true; @override void initState() { super.initState(); _socket = ConversationSocket(widget.conversationId)..connect(); _sub = _socket!.events.listen(_onEvent); + _loadHistory(); + } + + Future _loadHistory() async { + final session = context.read(); + try { + final history = await session.api.listMessages(widget.conversationId); + if (!mounted) return; + setState(() { + _messages + ..clear() + ..addAll(history); + _loadingHistory = false; + }); + _scrollToEnd(); + } on ApiException catch (e) { + if (!mounted) return; + setState(() { + _loadingHistory = false; + _banner = '히스토리 로드 실패 (${e.statusCode})'; + }); + } + } + + void _scrollToEnd() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scroll.hasClients) return; + _scroll.animateTo( + _scroll.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + }); } void _onEvent(Map event) { @@ -58,6 +95,7 @@ class _ChatScreenState extends State { if (msg == null) return; if (_messages.any((m) => m.id == msg.id)) return; setState(() => _messages.add(msg)); + _scrollToEnd(); } @override @@ -65,6 +103,8 @@ class _ChatScreenState extends State { _sub?.cancel(); _socket?.dispose(); _input.dispose(); + _draftEdit.dispose(); + _scroll.dispose(); super.dispose(); } @@ -83,6 +123,7 @@ class _ChatScreenState extends State { if (!_messages.any((m) => m.id == msg.id)) { setState(() => _messages.add(msg)); } + _scrollToEnd(); } on ApiException catch (e) { setState(() => _banner = '전송 실패 (${e.statusCode})'); } finally { @@ -105,15 +146,19 @@ class _ChatScreenState extends State { if (_input.text.trim().isNotEmpty) { contextLines.add('상대: ${_input.text.trim()}'); } + final style = session.styleExamples.isNotEmpty + ? session.styleExamples + : const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게']; final draft = await session.api.requestDraft( conversationId: widget.conversationId, contextLines: contextLines.isEmpty ? ['상대: 안녕'] : contextLines, - styleExamples: const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게'], + styleExamples: style, ); setState(() { _pendingDraft = draft; + _draftEdit.text = draft.isEscalate ? '' : draft.text; if (draft.isEscalate) { - _banner = '에스컬레이션: ${draft.text}'; + _banner = null; } }); } on ApiException catch (e) { @@ -127,19 +172,23 @@ class _ChatScreenState extends State { final session = context.read(); final draft = _pendingDraft; if (draft == null || draft.isEscalate || session.user == null) return; + final text = _draftEdit.text.trim(); + if (text.isEmpty) return; setState(() => _busy = true); try { final msg = await session.api.sendMessage( conversationId: widget.conversationId, senderId: session.user!.id, - text: draft.text, + text: text, senderMode: SenderMode.twin, approved: true, ); setState(() { _pendingDraft = null; + _draftEdit.clear(); if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg); }); + _scrollToEnd(); } on ApiException catch (e) { setState(() => _banner = '분신 발송 차단 (${e.statusCode}): ${e.body}'); } finally { @@ -147,6 +196,14 @@ class _ChatScreenState extends State { } } + void _rejectDraft() { + setState(() { + _pendingDraft = null; + _draftEdit.clear(); + _banner = '초안을 버렸습니다. 직접 쓰거나 다시 초안을 요청하세요.'; + }); + } + Future _veto() async { final session = context.read(); try { @@ -166,6 +223,84 @@ class _ChatScreenState extends State { } } + Widget _buildL1Panel(BuildContext context) { + final theme = Theme.of(context); + final draft = _pendingDraft; + if (draft == null) return const SizedBox.shrink(); + + if (draft.isEscalate) { + return Material( + color: theme.colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('직접 확인 필요', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + Text(draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton(onPressed: _rejectDraft, child: const Text('닫기')), + ), + ], + ), + ), + ); + } + + return Material( + elevation: 2, + color: theme.colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer), + const SizedBox(width: 6), + Text( + 'L1 승인 — 수정 후 보내기', + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: _draftEdit, + minLines: 2, + maxLines: 5, + decoration: const InputDecoration( + border: OutlineInputBorder(), + filled: true, + fillColor: Colors.white70, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton(onPressed: _busy ? null : _rejectDraft, child: const Text('버리기')), + const Spacer(), + FilledButton.tonal( + onPressed: _busy ? null : _requestDraft, + child: const Text('다시 초안'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _busy ? null : _sendTwinApproved, + child: const Text('승인하고 보내기'), + ), + ], + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final session = context.watch(); @@ -173,7 +308,7 @@ class _ChatScreenState extends State { return Scaffold( appBar: AppBar( - title: Text('대화방 #${widget.conversationId}'), + title: Text(widget.title ?? '대화방 #${widget.conversationId}'), actions: [ TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')), ], @@ -188,31 +323,23 @@ class _ChatScreenState extends State { ], ), Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _messages.length, - itemBuilder: (context, i) { - final m = _messages[i]; - return MessageBubble( - message: m, - isMine: me != null && m.senderId == me, - onRetract: m.isTwin ? () => _retract(m) : null, - ); - }, - ), + child: _loadingHistory + ? const Center(child: CircularProgressIndicator()) + : ListView.builder( + controller: _scroll, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _messages.length, + itemBuilder: (context, i) { + final m = _messages[i]; + return MessageBubble( + message: m, + isMine: me != null && m.senderId == me, + onRetract: m.isTwin ? () => _retract(m) : null, + ); + }, + ), ), - if (_pendingDraft != null && !_pendingDraft!.isEscalate) - Material( - color: Theme.of(context).colorScheme.secondaryContainer, - child: ListTile( - title: const Text('분신 초안 (L1 승인)'), - subtitle: Text(_pendingDraft!.text), - trailing: FilledButton( - onPressed: _busy ? null : _sendTwinApproved, - child: const Text('보내기'), - ), - ), - ), + _buildL1Panel(context), SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), diff --git a/mobile/lib/screens/contacts_screen.dart b/mobile/lib/screens/contacts_screen.dart new file mode 100644 index 0000000..ec86ac3 --- /dev/null +++ b/mobile/lib/screens/contacts_screen.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/models.dart'; +import '../services/api_client.dart'; +import '../state/session_state.dart'; +import 'chat_screen.dart'; + +class ContactsScreen extends StatefulWidget { + const ContactsScreen({super.key}); + + @override + State createState() => _ContactsScreenState(); +} + +class _ContactsScreenState extends State { + List _contacts = []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final session = context.read(); + if (session.user == null) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final list = await session.api.listContacts(session.user!.id); + setState(() => _contacts = list); + } on ApiException catch (e) { + setState(() => _error = '연락처 로드 실패 (${e.statusCode})'); + } finally { + setState(() => _loading = false); + } + } + + Future _showAddDialog() async { + final nameCtrl = TextEditingController(); + final peerCtrl = TextEditingController(); + final noteCtrl = TextEditingController(); + 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: '표시 이름', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: peerCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '상대 사용자 ID (선택)', + helperText: '대화 시작에 필요', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: noteCtrl, + decoration: const InputDecoration(labelText: '관계 메모 (선택)', border: OutlineInputBorder()), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('추가')), + ], + ), + ); + if (ok != true || !mounted) return; + final session = context.read(); + final name = nameCtrl.text.trim(); + if (name.isEmpty || session.user == null) 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]); + } on ApiException catch (e) { + setState(() => _error = '추가 실패 (${e.statusCode})'); + } + } + + Future _startChat(Contact contact) async { + final session = context.read(); + final me = session.user; + if (me == null || contact.contactUserId == null) { + setState(() => _error = '상대 사용자 ID가 있는 연락처만 대화를 시작할 수 있습니다.'); + return; + } + try { + final conv = await session.api.createConversation( + userIds: [me.id, contact.contactUserId!], + contactId: contact.id, + ); + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id, title: contact.displayName)), + ); + } on ApiException catch (e) { + setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('연락처')), + floatingActionButton: FloatingActionButton( + onPressed: _showAddDialog, + child: const Icon(Icons.person_add_alt_1), + ), + body: RefreshIndicator( + onRefresh: _load, + child: _loading + ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())]) + : ListView( + children: [ + if (_error != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + if (_contacts.isEmpty) + const Padding( + padding: EdgeInsets.all(24), + child: Text('연락처가 없습니다. + 버튼으로 추가하세요.'), + ), + for (final c in _contacts) + ListTile( + leading: const CircleAvatar(child: Icon(Icons.person_outline)), + title: Text(c.displayName), + subtitle: Text( + [ + if (c.contactUserId != null) '사용자 #${c.contactUserId}', + if (c.relationshipNote.isNotEmpty) c.relationshipNote, + ].join(' · '), + ), + trailing: c.contactUserId == null + ? null + : TextButton(onPressed: () => _startChat(c), child: const Text('대화')), + onLongPress: () async { + final session = context.read(); + if (session.user == null) return; + await session.api.deleteContact(session.user!.id, c.id); + setState(() => _contacts = _contacts.where((x) => x.id != c.id).toList()); + }, + ), + const Padding( + padding: EdgeInsets.all(16), + child: Text('길게 누르면 삭제됩니다.', textAlign: TextAlign.center), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index 9b31bb1..2003b4e 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../models/models.dart'; +import '../services/api_client.dart'; import '../state/session_state.dart'; import 'autonomy_settings_screen.dart'; import 'chat_screen.dart'; +import 'contacts_screen.dart'; +import 'inbox_screen.dart'; +import 'onboarding_tone_screen.dart'; -/// v1 scaffold: conversation list is local until list API exists on core-backend. -/// Enter a conversation id to open a room (matches current Go API shape). class ConversationListScreen extends StatefulWidget { const ConversationListScreen({super.key}); @@ -15,24 +18,103 @@ class ConversationListScreen extends StatefulWidget { } class _ConversationListScreenState extends State { - final _convId = TextEditingController(text: '1'); - final _rooms = {1}; + List _rooms = []; + bool _loading = true; + String? _error; @override - void dispose() { - _convId.dispose(); - super.dispose(); + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final session = context.read(); + setState(() { + _loading = true; + _error = null; + }); + try { + final list = await session.api.listConversations(); + list.sort((a, b) => b.id.compareTo(a.id)); + setState(() => _rooms = list); + } on ApiException catch (e) { + setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}'); + } finally { + setState(() => _loading = false); + } + } + + Future _createConversation() async { + final peerCtrl = TextEditingController(); + 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: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('만들기')), + ], + ), + ); + if (ok != true || !mounted) return; + final session = context.read(); + final me = session.user; + final peer = int.tryParse(peerCtrl.text.trim()); + if (me == null || peer == null) 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)), + ); + await _load(); + } on ApiException catch (e) { + setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}'); + } } @override Widget build(BuildContext context) { final session = context.watch(); - final rooms = _rooms.toList()..sort(); + final me = session.user?.id; return Scaffold( appBar: AppBar( title: Text('분신 · ${session.user?.displayName ?? ''}'), actions: [ + IconButton( + tooltip: '사후 알림', + onPressed: () { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const InboxScreen())); + }, + icon: const Icon(Icons.notifications_none), + ), + IconButton( + tooltip: '연락처', + onPressed: () async { + await Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ContactsScreen())); + await _load(); + }, + icon: const Icon(Icons.contacts_outlined), + ), + IconButton( + tooltip: '말투 샘플', + onPressed: () { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen())); + }, + icon: const Icon(Icons.record_voice_over_outlined), + ), IconButton( tooltip: '자율성 설정', onPressed: () { @@ -44,47 +126,52 @@ class _ConversationListScreenState extends State { ), ], ), - body: ListView( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _convId, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: '대화방 ID', - border: OutlineInputBorder(), - helperText: 'core-backend에 대화방 목록 API가 생기기 전 임시 진입', + floatingActionButton: FloatingActionButton( + onPressed: _createConversation, + child: const Icon(Icons.chat), + ), + body: RefreshIndicator( + onRefresh: _load, + child: _loading + ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())]) + : ListView( + children: [ + if (_error != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), ), - ), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: () { - final id = int.tryParse(_convId.text.trim()); - if (id == null) return; - setState(() => _rooms.add(id)); - }, - child: const Text('추가'), - ), - ], - ), - ), - for (final id in rooms) - ListTile( - leading: const CircleAvatar(child: Icon(Icons.chat_bubble_outline)), - title: Text('대화방 #$id'), - subtitle: const Text('탭해서 입장'), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => ChatScreen(conversationId: id)), - ); - }, - ), - ], + if (_rooms.isEmpty) + const Padding( + padding: EdgeInsets.all(24), + child: Text('대화방이 없습니다. + 버튼이나 연락처에서 대화를 시작하세요.'), + ), + for (final room in _rooms) + ListTile( + leading: CircleAvatar( + child: Icon(room.isGroup ? Icons.groups_outlined : Icons.chat_bubble_outline), + ), + title: Text(me == null ? '대화방 #${room.id}' : room.titleFor(me)), + subtitle: Text( + [ + 'ID ${room.id}', + if (room.twinDisabledByPeer) '상대가 분신 거부', + ].join(' · '), + ), + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatScreen( + conversationId: room.id, + title: me == null ? null : room.titleFor(me), + ), + ), + ); + await _load(); + }, + ), + ], + ), ), ); } diff --git a/mobile/lib/screens/inbox_screen.dart b/mobile/lib/screens/inbox_screen.dart new file mode 100644 index 0000000..7c1e34c --- /dev/null +++ b/mobile/lib/screens/inbox_screen.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/models.dart'; +import '../services/api_client.dart'; +import '../state/session_state.dart'; +import 'chat_screen.dart'; + +/// Post-hoc notification inbox: escalation logs that need human attention. +/// Twin "되돌리기" remains available inside each chat bubble (PRD). +class InboxScreen extends StatefulWidget { + const InboxScreen({super.key}); + + @override + State createState() => _InboxScreenState(); +} + +class _InboxScreenState extends State { + List _logs = []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final session = context.read(); + if (session.user == null) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final logs = await session.api.listEscalationLogs(session.user!.id); + setState(() => _logs = logs); + } on ApiException catch (e) { + setState(() => _error = '알림 로드 실패 (${e.statusCode})'); + } finally { + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar(title: const Text('사후 알림')), + body: RefreshIndicator( + onRefresh: _load, + child: _loading + ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())]) + : ListView( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + '분신이 보류·차단한 내용과 에스컬레이션 기록입니다. ' + '이미 보낸 분신 메시지는 해당 대화방에서 되돌릴 수 있습니다.', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + ), + if (_logs.isEmpty) + const Padding( + padding: EdgeInsets.all(24), + child: Text('새 알림이 없습니다.'), + ), + for (final log in _logs) + ListTile( + leading: Icon( + log.resolved ? Icons.check_circle_outline : Icons.warning_amber_rounded, + color: log.resolved ? theme.colorScheme.primary : theme.colorScheme.error, + ), + title: Text(log.reason.isEmpty ? '에스컬레이션' : log.reason), + subtitle: Text( + [ + '대화방 #${log.conversationId}', + if (log.messageSnippet.isNotEmpty) log.messageSnippet, + log.createdAt.toLocal().toString().split('.').first, + ].join('\n'), + ), + isThreeLine: true, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatScreen(conversationId: log.conversationId), + ), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/screens/onboarding_tone_screen.dart b/mobile/lib/screens/onboarding_tone_screen.dart new file mode 100644 index 0000000..6983f10 --- /dev/null +++ b/mobile/lib/screens/onboarding_tone_screen.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/session_state.dart'; + +/// Phase 1 onboarding skeleton: capture a few style samples locally. +/// Fine copy / import UX waits for human PoC (#1) — do not invent §3 defaults here. +class OnboardingToneScreen extends StatefulWidget { + const OnboardingToneScreen({super.key}); + + @override + State createState() => _OnboardingToneScreenState(); +} + +class _OnboardingToneScreenState extends State { + final _samples = List.generate(4, (_) => TextEditingController()); + var _seeded = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_seeded) return; + _seeded = true; + final existing = context.read().styleExamples; + for (var i = 0; i < existing.length && i < _samples.length; i++) { + _samples[i].text = existing[i]; + } + } + + @override + void dispose() { + for (final c in _samples) { + c.dispose(); + } + super.dispose(); + } + + Future _save({required bool markDone}) async { + final session = context.read(); + await session.saveToneSamples( + _samples.map((c) => c.text).toList(), + markDone: markDone, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('말투 샘플'), + actions: [ + TextButton( + onPressed: () => context.read().skipToneOnboarding(), + child: const Text('나중에'), + ), + ], + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700)), + const SizedBox(height: 8), + Text( + '자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. ' + '최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 24), + for (var i = 0; i < _samples.length; i++) ...[ + TextField( + controller: _samples[i], + maxLines: 2, + decoration: InputDecoration( + labelText: '샘플 ${i + 1}', + hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + ], + const SizedBox(height: 8), + FilledButton( + onPressed: () => _save(markDone: true), + child: const Text('저장하고 시작'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart index 88afd34..33f44f3 100644 --- a/mobile/lib/services/api_client.dart +++ b/mobile/lib/services/api_client.dart @@ -76,6 +76,67 @@ class ApiClient { ); } + Future> listConversations() async { + final obj = await _getObject('/conversations'); + final list = (obj['conversations'] as List? ?? const []); + return list.map((e) => ConversationSummary.fromJson(e as Map)).toList(); + } + + Future createConversation({ + required List userIds, + int? contactId, + bool isGroup = false, + }) async { + final json = await _json('POST', '/conversations', body: { + 'user_ids': userIds, + 'is_group': isGroup, + if (contactId != null) 'contact_id': contactId, + }); + final rawIds = json['user_ids'] as List? ?? userIds; + return ConversationSummary( + id: json['id'] as int, + isGroup: json['is_group'] as bool? ?? isGroup, + userIds: rawIds.map((e) => e as int).toList(), + twinDisabledByPeer: false, + ); + } + + Future> listMessages(int conversationId) async { + final obj = await _getObject('/conversations/$conversationId/messages'); + final list = (obj['messages'] as List? ?? const []); + return list.map((e) => ChatMessage.fromJson(e as Map)).toList(); + } + + Future> listContacts(int userId) async { + final obj = await _getObject('/users/$userId/contacts'); + final list = (obj['contacts'] as List? ?? const []); + return list.map((e) => Contact.fromJson(e as Map)).toList(); + } + + Future createContact({ + required int userId, + required String displayName, + int? contactUserId, + String relationshipNote = '', + }) async { + final json = await _json('POST', '/users/$userId/contacts', body: { + 'display_name': displayName, + if (contactUserId != null) 'contact_user_id': contactUserId, + 'relationship_note': relationshipNote, + }); + return Contact.fromJson(json); + } + + Future deleteContact(int userId, int contactId) async { + await _json('DELETE', '/users/$userId/contacts/$contactId'); + } + + Future> listEscalationLogs(int userId) async { + final obj = await _getObject('/users/$userId/escalation-logs'); + final list = (obj['escalation_logs'] as List? ?? const []); + return list.map((e) => EscalationLogEntry.fromJson(e as Map)).toList(); + } + Future sendMessage({ required int conversationId, required int senderId, @@ -126,24 +187,6 @@ class ApiClient { return list.map((e) => WhitelistRule.fromJson(e as Map)).toList(); } - Future>> listConversations() async { - final obj = await _getObject('/conversations'); - final list = (obj['conversations'] as List? ?? const []); - return list.cast>(); - } - - Future> createConversation({ - required List userIds, - int? contactId, - bool isGroup = false, - }) async { - return _json('POST', '/conversations', body: { - 'user_ids': userIds, - 'is_group': isGroup, - if (contactId != null) 'contact_id': contactId, - }); - } - Future addWhitelist(int userId, String topicKeyword) async { final json = await _json('POST', '/users/$userId/whitelist-rules', body: { 'topic_keyword': topicKeyword, diff --git a/mobile/lib/state/session_state.dart b/mobile/lib/state/session_state.dart index 7ad5124..b13e120 100644 --- a/mobile/lib/state/session_state.dart +++ b/mobile/lib/state/session_state.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -12,11 +14,15 @@ class SessionState extends ChangeNotifier { AutonomyLevel autonomyLevel = AutonomyLevel.L0; String? error; bool loading = false; + bool toneOnboardingDone = false; + List styleExamples = const []; static const _kUserId = 'user_id'; static const _kDisplayName = 'display_name'; static const _kInvite = 'invite_code'; static const _kToken = 'session_token'; + static const _kToneDone = 'tone_onboarding_done'; + static const _kStyleExamples = 'style_examples_json'; Future restore() async { final prefs = await SharedPreferences.getInstance(); @@ -24,6 +30,12 @@ class SessionState extends ChangeNotifier { final name = prefs.getString(_kDisplayName); final invite = prefs.getString(_kInvite); final token = prefs.getString(_kToken); + toneOnboardingDone = prefs.getBool(_kToneDone) ?? false; + final rawExamples = prefs.getString(_kStyleExamples); + if (rawExamples != null && rawExamples.isNotEmpty) { + final decoded = jsonDecode(rawExamples) as List; + styleExamples = decoded.map((e) => e.toString()).where((s) => s.trim().isNotEmpty).toList(); + } if (id != null && name != null && invite != null) { user = User(id: id, displayName: name, inviteCode: invite); _api.authToken = token; @@ -38,11 +50,13 @@ class SessionState extends ChangeNotifier { try { final result = await _api.signup(inviteCode: inviteCode, displayName: displayName); user = result.user; + toneOnboardingDone = false; final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_kUserId, result.user.id); await prefs.setString(_kDisplayName, result.user.displayName); await prefs.setString(_kInvite, result.user.inviteCode); await prefs.setString(_kToken, result.token); + await prefs.setBool(_kToneDone, false); } on ApiException catch (e) { error = '가입 실패 (${e.statusCode}): ${e.body}'; } catch (e) { @@ -53,6 +67,23 @@ class SessionState extends ChangeNotifier { } } + Future saveToneSamples(List samples, {bool markDone = true}) async { + final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); + styleExamples = cleaned; + if (markDone) toneOnboardingDone = true; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kStyleExamples, jsonEncode(cleaned)); + if (markDone) await prefs.setBool(_kToneDone, true); + notifyListeners(); + } + + Future skipToneOnboarding() async { + toneOnboardingDone = true; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kToneDone, true); + notifyListeners(); + } + Future setAutonomy(AutonomyLevel level) async { if (user == null) return; try { diff --git a/mobile/test/models_test.dart b/mobile/test/models_test.dart new file mode 100644 index 0000000..c5b8dda --- /dev/null +++ b/mobile/test/models_test.dart @@ -0,0 +1,37 @@ +import 'package:bunsin_mobile/models/models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('ConversationSummary parses list payload', () { + final c = ConversationSummary.fromJson({ + 'id': 3, + 'is_group': false, + 'twin_disabled_by_peer': true, + 'user_ids': [1, 2], + 'created_at': '2026-07-30T00:00:00Z', + }); + expect(c.id, 3); + expect(c.userIds, [1, 2]); + expect(c.twinDisabledByPeer, isTrue); + expect(c.titleFor(1), '대화 · 상대 #2'); + }); + + test('EscalationLogEntry and Contact parse', () { + final log = EscalationLogEntry.fromJson({ + 'id': 9, + 'conversation_id': 2, + 'reason': 'money', + 'message_snippet': '보내줄게', + 'resolved': false, + 'created_at': '2026-07-30T01:00:00Z', + }); + expect(log.reason, 'money'); + final contact = Contact.fromJson({ + 'id': 1, + 'display_name': '친구', + 'contact_user_id': 7, + 'relationship_note': '대학', + }); + expect(contact.contactUserId, 7); + }); +}