Implement A3 Flutter polish: conversations, contacts, L1, inbox

Wire conversation list and contacts to A1 APIs, add tone-sample
onboarding skeleton, polish L1 approve UX with editable draft panel,
and add post-hoc escalation inbox. Update roadmap A3 checklist.

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-07-30 04:55:22 +00:00
parent c8d10facec
commit 5033cb56ea
No known key found for this signature in database
12 changed files with 915 additions and 124 deletions

View File

@ -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. 그다음 — 베타 품질

View File

@ -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://<your-lan-ip>: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 범위 밖)

View File

@ -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<SessionState>(
builder: (context, s, _) {
if (s.user == null) return const SignupScreen();
if (!s.toneOnboardingDone) return const OnboardingToneScreen();
return const ConversationListScreen();
},
),

View File

@ -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<int> userIds;
final bool twinDisabledByPeer;
final DateTime? createdAt;
factory ConversationSummary.fromJson(Map<String, dynamic> json) {
final rawIds = json['user_ids'] as List<dynamic>? ?? 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<String, dynamic> 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<String, dynamic> 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,

View File

@ -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<ChatScreen> createState() => _ChatScreenState();
@ -20,18 +21,54 @@ class ChatScreen extends StatefulWidget {
class _ChatScreenState extends State<ChatScreen> {
final _input = TextEditingController();
final _draftEdit = TextEditingController();
final _scroll = ScrollController();
final _messages = <ChatMessage>[];
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<void> _loadHistory() async {
final session = context.read<SessionState>();
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<String, dynamic> event) {
@ -58,6 +95,7 @@ class _ChatScreenState extends State<ChatScreen> {
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<ChatScreen> {
_sub?.cancel();
_socket?.dispose();
_input.dispose();
_draftEdit.dispose();
_scroll.dispose();
super.dispose();
}
@ -83,6 +123,7 @@ class _ChatScreenState extends State<ChatScreen> {
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<ChatScreen> {
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<ChatScreen> {
final session = context.read<SessionState>();
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<ChatScreen> {
}
}
void _rejectDraft() {
setState(() {
_pendingDraft = null;
_draftEdit.clear();
_banner = '초안을 버렸습니다. 직접 쓰거나 다시 초안을 요청하세요.';
});
}
Future<void> _veto() async {
final session = context.read<SessionState>();
try {
@ -166,6 +223,84 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
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<SessionState>();
@ -173,7 +308,7 @@ class _ChatScreenState extends State<ChatScreen> {
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<ChatScreen> {
],
),
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),

View File

@ -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<ContactsScreen> createState() => _ContactsScreenState();
}
class _ContactsScreenState extends State<ContactsScreen> {
List<Contact> _contacts = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final session = context.read<SessionState>();
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<void> _showAddDialog() async {
final nameCtrl = TextEditingController();
final peerCtrl = TextEditingController();
final noteCtrl = TextEditingController();
final ok = await showDialog<bool>(
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<SessionState>();
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<void> _startChat(Contact contact) async {
final session = context.read<SessionState>();
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<SessionState>();
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),
),
],
),
),
);
}
}

View File

@ -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<ConversationListScreen> {
final _convId = TextEditingController(text: '1');
final _rooms = <int>{1};
List<ConversationSummary> _rooms = [];
bool _loading = true;
String? _error;
@override
void dispose() {
_convId.dispose();
super.dispose();
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final session = context.read<SessionState>();
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<void> _createConversation() async {
final peerCtrl = TextEditingController();
final ok = await showDialog<bool>(
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<SessionState>();
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<SessionState>();
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<ConversationListScreen> {
),
],
),
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();
},
),
],
),
),
);
}

View File

@ -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<InboxScreen> createState() => _InboxScreenState();
}
class _InboxScreenState extends State<InboxScreen> {
List<EscalationLogEntry> _logs = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final session = context.read<SessionState>();
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),
),
);
},
),
],
),
),
);
}
}

View File

@ -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<OnboardingToneScreen> createState() => _OnboardingToneScreenState();
}
class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
final _samples = List.generate(4, (_) => TextEditingController());
var _seeded = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_seeded) return;
_seeded = true;
final existing = context.read<SessionState>().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<void> _save({required bool markDone}) async {
final session = context.read<SessionState>();
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<SessionState>().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('저장하고 시작'),
),
],
),
),
);
}
}

View File

@ -76,6 +76,67 @@ class ApiClient {
);
}
Future<List<ConversationSummary>> listConversations() async {
final obj = await _getObject('/conversations');
final list = (obj['conversations'] as List<dynamic>? ?? const []);
return list.map((e) => ConversationSummary.fromJson(e as Map<String, dynamic>)).toList();
}
Future<ConversationSummary> createConversation({
required List<int> 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<dynamic>? ?? 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<List<ChatMessage>> listMessages(int conversationId) async {
final obj = await _getObject('/conversations/$conversationId/messages');
final list = (obj['messages'] as List<dynamic>? ?? const []);
return list.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)).toList();
}
Future<List<Contact>> listContacts(int userId) async {
final obj = await _getObject('/users/$userId/contacts');
final list = (obj['contacts'] as List<dynamic>? ?? const []);
return list.map((e) => Contact.fromJson(e as Map<String, dynamic>)).toList();
}
Future<Contact> 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<void> deleteContact(int userId, int contactId) async {
await _json('DELETE', '/users/$userId/contacts/$contactId');
}
Future<List<EscalationLogEntry>> listEscalationLogs(int userId) async {
final obj = await _getObject('/users/$userId/escalation-logs');
final list = (obj['escalation_logs'] as List<dynamic>? ?? const []);
return list.map((e) => EscalationLogEntry.fromJson(e as Map<String, dynamic>)).toList();
}
Future<ChatMessage> sendMessage({
required int conversationId,
required int senderId,
@ -126,24 +187,6 @@ class ApiClient {
return list.map((e) => WhitelistRule.fromJson(e as Map<String, dynamic>)).toList();
}
Future<List<Map<String, dynamic>>> listConversations() async {
final obj = await _getObject('/conversations');
final list = (obj['conversations'] as List<dynamic>? ?? const []);
return list.cast<Map<String, dynamic>>();
}
Future<Map<String, dynamic>> createConversation({
required List<int> 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<WhitelistRule> addWhitelist(int userId, String topicKeyword) async {
final json = await _json('POST', '/users/$userId/whitelist-rules', body: {
'topic_keyword': topicKeyword,

View File

@ -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<String> 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<void> 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<dynamic>;
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<void> saveToneSamples(List<String> 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<void> skipToneOnboarding() async {
toneOnboardingDone = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kToneDone, true);
notifyListeners();
}
Future<void> setAutonomy(AutonomyLevel level) async {
if (user == null) return;
try {

View File

@ -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);
});
}