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:
parent
c8d10facec
commit
5033cb56ea
|
|
@ -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. 그다음 — 베타 품질
|
||||
|
|
|
|||
|
|
@ -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 범위 밖)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,7 +323,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
],
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
child: _loadingHistory
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView.builder(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, i) {
|
||||
|
|
@ -201,18 +339,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
},
|
||||
),
|
||||
),
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,48 +126,53 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
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: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _convId,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '대화방 ID',
|
||||
border: OutlineInputBorder(),
|
||||
helperText: 'core-backend에 대화방 목록 API가 생기기 전 임시 진입',
|
||||
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
if (_rooms.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('대화방이 없습니다. + 버튼이나 연락처에서 대화를 시작하세요.'),
|
||||
),
|
||||
),
|
||||
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)
|
||||
for (final room 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)),
|
||||
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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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('저장하고 시작'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue