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,
|
`POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400,
|
||||||
이미 되돌린 건 409, 성공하면 같은 대화방 WebSocket에 `{"type":"retraction", "id":...}` 브로드캐스트.
|
이미 되돌린 건 409, 성공하면 같은 대화방 WebSocket에 `{"type":"retraction", "id":...}` 브로드캐스트.
|
||||||
일반 메시지 브로드캐스트도 `"type":"message"`를 붙여 클라이언트가 두 이벤트를 구분하게 함.
|
일반 메시지 브로드캐스트도 `"type":"message"`를 붙여 클라이언트가 두 이벤트를 구분하게 함.
|
||||||
**되돌리기 UI는 `mobile/`에 있음** — 사후알림 전용 함/푸시는 아직.
|
**되돌리기 UI는 `mobile/`에 있음**. 사후알림 함은 A3에서 `InboxScreen` +
|
||||||
에스컬레이션 로그를 "조회"하는 API는 아직 없음(필요해지면 추가)
|
`GET /users/:id/escalation-logs`로 연결. FCM 푸시는 B.
|
||||||
|
|
||||||
**2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — `mobile/`
|
**2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — `mobile/`
|
||||||
- [x] 기본 채팅 UI (대화 목록, 대화방) — `mobile/` 골격. 대화방 목록 API는 아직 없어 ID 직접
|
- [x] 기본 채팅 UI (대화 목록, 대화방) — `GET`/`POST /conversations`, 히스토리 로드,
|
||||||
입장. WebSocket 수신·인간 메시지 전송 연결됨
|
WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작
|
||||||
- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 초대 코드 가입 화면만 있음. 말투 학습 UX 디테일은
|
- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`,
|
||||||
**사람 PoC #1 결과를 맨 마지막에 반영** (지금은 뼈대만)
|
기기 로컬). 말투 학습 UX 디테일·카피는 **사람 PoC #1 결과를 맨 마지막에 반영**
|
||||||
- [x] 분신 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
|
- [x] 분신 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
|
||||||
서버/AI 프롬프트 측과 이어서 다듬을 것
|
서버/AI 프롬프트 측과 이어서 다듬을 것
|
||||||
- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외 매칭은
|
- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는
|
||||||
서버가 전역 키워드만 지원하는 동안 UI도 주제 키워드 CRUD만
|
서버 contact-scoped 매칭과 함께 화이트리스트 CRUD
|
||||||
- [x] 에스컬레이션 배너·되돌리기 UI — 초안 escalate 배너 + twin 메시지 되돌리기 버튼.
|
- [x] 에스컬레이션·L1 승인·되돌리기 UI — L1 수정/버리기/재초안/승인 패널 + twin 되돌리기 +
|
||||||
사후알림 전용 함/푸시는 아직 없음
|
사후알림 함. FCM 푸시는 B
|
||||||
|
|
||||||
**2.4 안전장치 통합** (전 구간 필수, 타협 불가)
|
**2.4 안전장치 통합** (전 구간 필수, 타협 불가)
|
||||||
- [x] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 — 실제 발견한 우회
|
- [x] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 — 실제 발견한 우회
|
||||||
|
|
@ -147,10 +147,10 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
|
||||||
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음
|
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음
|
||||||
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
|
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
|
||||||
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
|
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
|
||||||
4. [~] 2.3 Flutter 클라이언트 — `mobile/` 골격 착수 완료(가입·채팅·뱃지·거부권·되돌리기·자율성
|
4. [~] 2.3 Flutter 클라이언트 — A3까지: 가입·말투 온보딩 뼈대·대화/연락처 API 연동·히스토리·
|
||||||
설정). 남은 것: 대화방/연락처 목록 API 연동, 온보딩 말투 UX, drift+SQLCipher, 수동 QA
|
L1 승인 UX·사후 알림 함·뱃지·거부권·되돌리기·자율성. 남은 것: 수동 E2E QA, drift+SQLCipher(B)
|
||||||
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료.
|
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료.
|
||||||
클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 2.3 나머지와 함께
|
클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께
|
||||||
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
|
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
|
||||||
§3(사람 PoC) 이후**
|
§3(사람 PoC) 이후**
|
||||||
- 5-2. [x] 화이트리스트 규칙 CRUD API
|
- 5-2. [x] 화이트리스트 규칙 CRUD API
|
||||||
|
|
@ -176,10 +176,10 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
|
||||||
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
|
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
|
||||||
|
|
||||||
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
|
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
|
||||||
- [ ] 대화 목록/연락처 UI를 서버 API에 연결
|
- [x] 대화 목록/연락처 UI를 서버 API에 연결
|
||||||
- [ ] 온보딩 뼈대 확장 (말투 샘플 입력 UI)
|
- [x] 온보딩 뼈대 확장 (말투 샘플 입력 UI)
|
||||||
- [ ] L1 승인 플로우 UX 정리
|
- [x] L1 승인 플로우 UX 정리
|
||||||
- [ ] 사후 알림 함
|
- [x] 사후 알림 함
|
||||||
- [ ] 에뮬레이터/실기기 E2E 수동 QA
|
- [ ] 에뮬레이터/실기기 E2E 수동 QA
|
||||||
|
|
||||||
##### B. 그다음 — 베타 품질
|
##### B. 그다음 — 베타 품질
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
# 분신 mobile (Flutter)
|
# 분신 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).
|
(`docs/tech-design.md` §8).
|
||||||
|
|
||||||
## 지금 있는 것
|
## 지금 있는 것
|
||||||
|
|
||||||
- 초대 코드 가입 (`POST /auth/signup`)
|
- 초대 코드 가입 (`POST /auth/signup`) + Bearer 세션
|
||||||
- 대화방 입장(임시: 대화방 ID 직접 입력 — 목록 API는 아직 core-backend에 없음)
|
- 말투 샘플 온보딩(기기 로컬 저장; 최종 카피는 PoC 이후)
|
||||||
- 채팅 전송 + WebSocket 수신
|
- 대화방 목록/생성 (`GET`/`POST /conversations`) — 수동 ID 입력 제거
|
||||||
- 분신 초안 요청 / L1 승인 발송
|
- 연락처 CRUD + 대화 시작 (`/users/:id/contacts`)
|
||||||
- 분신 뱃지(점선 + 라벨) · 거부권 · 되돌리기
|
- 채팅 히스토리 로드 + WebSocket 수신
|
||||||
- 자율성 L0~L2 설정 + 화이트리스트 CRUD UI
|
- 분신 초안 / 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
|
flutter run --dart-define=CORE_API_BASE=http://<your-lan-ip>:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
## 아직 없는 것
|
## E2E 수동 QA 체크 (에뮬레이터/실기기)
|
||||||
|
|
||||||
- 대화방/연락처 목록 API 연동 (서버에 엔드포인트 추가 필요)
|
1. 초대 코드로 가입 → 말투 샘플 저장(또는 나중에)
|
||||||
- 온보딩 말투 학습 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
|
2. 연락처에 상대 사용자 ID 등록 → 대화 시작
|
||||||
- drift + SQLCipher 온디바이스 저장
|
3. 메시지 전송 · 새로고침 후 히스토리 유지
|
||||||
- 푸시 알림
|
4. 초안 요청 → L1 패널에서 수정/버리기/승인 발송 → 분신 뱃지 확인
|
||||||
|
5. 민감 문장으로 에스컬레이션 → 사후 알림 함 진입
|
||||||
|
6. 분신 메시지 되돌리기 · 거부권
|
||||||
|
7. 자율성 L0~L2 + 화이트리스트 추가/삭제
|
||||||
|
|
||||||
|
## 아직 없는 것 (B 이후)
|
||||||
|
|
||||||
|
- FCM 푸시 · drift + SQLCipher · 멀티 디바이스
|
||||||
|
- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
|
||||||
- iOS 빌드 (v1 범위 밖)
|
- iOS 빌드 (v1 범위 밖)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'screens/conversation_list_screen.dart';
|
import 'screens/conversation_list_screen.dart';
|
||||||
|
import 'screens/onboarding_tone_screen.dart';
|
||||||
import 'screens/signup_screen.dart';
|
import 'screens/signup_screen.dart';
|
||||||
import 'state/session_state.dart';
|
import 'state/session_state.dart';
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ class BunsinApp extends StatelessWidget {
|
||||||
home: Consumer<SessionState>(
|
home: Consumer<SessionState>(
|
||||||
builder: (context, s, _) {
|
builder: (context, s, _) {
|
||||||
if (s.user == null) return const SignupScreen();
|
if (s.user == null) return const SignupScreen();
|
||||||
|
if (!s.toneOnboardingDone) return const OnboardingToneScreen();
|
||||||
return const ConversationListScreen();
|
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 {
|
class ChatMessage {
|
||||||
ChatMessage({
|
ChatMessage({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ import '../state/session_state.dart';
|
||||||
import '../widgets/message_bubble.dart';
|
import '../widgets/message_bubble.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
const ChatScreen({super.key, required this.conversationId});
|
const ChatScreen({super.key, required this.conversationId, this.title});
|
||||||
|
|
||||||
final int conversationId;
|
final int conversationId;
|
||||||
|
final String? title;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
|
|
@ -20,18 +21,54 @@ class ChatScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _ChatScreenState extends State<ChatScreen> {
|
class _ChatScreenState extends State<ChatScreen> {
|
||||||
final _input = TextEditingController();
|
final _input = TextEditingController();
|
||||||
|
final _draftEdit = TextEditingController();
|
||||||
|
final _scroll = ScrollController();
|
||||||
final _messages = <ChatMessage>[];
|
final _messages = <ChatMessage>[];
|
||||||
ConversationSocket? _socket;
|
ConversationSocket? _socket;
|
||||||
StreamSubscription? _sub;
|
StreamSubscription? _sub;
|
||||||
String? _banner;
|
String? _banner;
|
||||||
DraftResult? _pendingDraft;
|
DraftResult? _pendingDraft;
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
|
bool _loadingHistory = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_socket = ConversationSocket(widget.conversationId)..connect();
|
_socket = ConversationSocket(widget.conversationId)..connect();
|
||||||
_sub = _socket!.events.listen(_onEvent);
|
_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) {
|
void _onEvent(Map<String, dynamic> event) {
|
||||||
|
|
@ -58,6 +95,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
if (msg == null) return;
|
if (msg == null) return;
|
||||||
if (_messages.any((m) => m.id == msg.id)) return;
|
if (_messages.any((m) => m.id == msg.id)) return;
|
||||||
setState(() => _messages.add(msg));
|
setState(() => _messages.add(msg));
|
||||||
|
_scrollToEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -65,6 +103,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
_sub?.cancel();
|
_sub?.cancel();
|
||||||
_socket?.dispose();
|
_socket?.dispose();
|
||||||
_input.dispose();
|
_input.dispose();
|
||||||
|
_draftEdit.dispose();
|
||||||
|
_scroll.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,6 +123,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
if (!_messages.any((m) => m.id == msg.id)) {
|
if (!_messages.any((m) => m.id == msg.id)) {
|
||||||
setState(() => _messages.add(msg));
|
setState(() => _messages.add(msg));
|
||||||
}
|
}
|
||||||
|
_scrollToEnd();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
setState(() => _banner = '전송 실패 (${e.statusCode})');
|
setState(() => _banner = '전송 실패 (${e.statusCode})');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -105,15 +146,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
if (_input.text.trim().isNotEmpty) {
|
if (_input.text.trim().isNotEmpty) {
|
||||||
contextLines.add('상대: ${_input.text.trim()}');
|
contextLines.add('상대: ${_input.text.trim()}');
|
||||||
}
|
}
|
||||||
|
final style = session.styleExamples.isNotEmpty
|
||||||
|
? session.styleExamples
|
||||||
|
: const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게'];
|
||||||
final draft = await session.api.requestDraft(
|
final draft = await session.api.requestDraft(
|
||||||
conversationId: widget.conversationId,
|
conversationId: widget.conversationId,
|
||||||
contextLines: contextLines.isEmpty ? ['상대: 안녕'] : contextLines,
|
contextLines: contextLines.isEmpty ? ['상대: 안녕'] : contextLines,
|
||||||
styleExamples: const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게'],
|
styleExamples: style,
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_pendingDraft = draft;
|
_pendingDraft = draft;
|
||||||
|
_draftEdit.text = draft.isEscalate ? '' : draft.text;
|
||||||
if (draft.isEscalate) {
|
if (draft.isEscalate) {
|
||||||
_banner = '에스컬레이션: ${draft.text}';
|
_banner = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
|
|
@ -127,19 +172,23 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
final session = context.read<SessionState>();
|
final session = context.read<SessionState>();
|
||||||
final draft = _pendingDraft;
|
final draft = _pendingDraft;
|
||||||
if (draft == null || draft.isEscalate || session.user == null) return;
|
if (draft == null || draft.isEscalate || session.user == null) return;
|
||||||
|
final text = _draftEdit.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
try {
|
try {
|
||||||
final msg = await session.api.sendMessage(
|
final msg = await session.api.sendMessage(
|
||||||
conversationId: widget.conversationId,
|
conversationId: widget.conversationId,
|
||||||
senderId: session.user!.id,
|
senderId: session.user!.id,
|
||||||
text: draft.text,
|
text: text,
|
||||||
senderMode: SenderMode.twin,
|
senderMode: SenderMode.twin,
|
||||||
approved: true,
|
approved: true,
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_pendingDraft = null;
|
_pendingDraft = null;
|
||||||
|
_draftEdit.clear();
|
||||||
if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg);
|
if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg);
|
||||||
});
|
});
|
||||||
|
_scrollToEnd();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
setState(() => _banner = '분신 발송 차단 (${e.statusCode}): ${e.body}');
|
setState(() => _banner = '분신 발송 차단 (${e.statusCode}): ${e.body}');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -147,6 +196,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _rejectDraft() {
|
||||||
|
setState(() {
|
||||||
|
_pendingDraft = null;
|
||||||
|
_draftEdit.clear();
|
||||||
|
_banner = '초안을 버렸습니다. 직접 쓰거나 다시 초안을 요청하세요.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _veto() async {
|
Future<void> _veto() async {
|
||||||
final session = context.read<SessionState>();
|
final session = context.read<SessionState>();
|
||||||
try {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
|
|
@ -173,7 +308,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('대화방 #${widget.conversationId}'),
|
title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')),
|
TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')),
|
||||||
],
|
],
|
||||||
|
|
@ -188,31 +323,23 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: _loadingHistory
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
? const Center(child: CircularProgressIndicator())
|
||||||
itemCount: _messages.length,
|
: ListView.builder(
|
||||||
itemBuilder: (context, i) {
|
controller: _scroll,
|
||||||
final m = _messages[i];
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
return MessageBubble(
|
itemCount: _messages.length,
|
||||||
message: m,
|
itemBuilder: (context, i) {
|
||||||
isMine: me != null && m.senderId == me,
|
final m = _messages[i];
|
||||||
onRetract: m.isTwin ? () => _retract(m) : null,
|
return MessageBubble(
|
||||||
);
|
message: m,
|
||||||
},
|
isMine: me != null && m.senderId == me,
|
||||||
),
|
onRetract: m.isTwin ? () => _retract(m) : null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (_pendingDraft != null && !_pendingDraft!.isEscalate)
|
_buildL1Panel(context),
|
||||||
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('보내기'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../models/models.dart';
|
||||||
|
import '../services/api_client.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import 'autonomy_settings_screen.dart';
|
import 'autonomy_settings_screen.dart';
|
||||||
import 'chat_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 {
|
class ConversationListScreen extends StatefulWidget {
|
||||||
const ConversationListScreen({super.key});
|
const ConversationListScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -15,24 +18,103 @@ class ConversationListScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ConversationListScreenState extends State<ConversationListScreen> {
|
class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
final _convId = TextEditingController(text: '1');
|
List<ConversationSummary> _rooms = [];
|
||||||
final _rooms = <int>{1};
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void initState() {
|
||||||
_convId.dispose();
|
super.initState();
|
||||||
super.dispose();
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
final rooms = _rooms.toList()..sort();
|
final me = session.user?.id;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('분신 · ${session.user?.displayName ?? ''}'),
|
title: Text('분신 · ${session.user?.displayName ?? ''}'),
|
||||||
actions: [
|
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(
|
IconButton(
|
||||||
tooltip: '자율성 설정',
|
tooltip: '자율성 설정',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|
@ -44,47 +126,52 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListView(
|
floatingActionButton: FloatingActionButton(
|
||||||
children: [
|
onPressed: _createConversation,
|
||||||
Padding(
|
child: const Icon(Icons.chat),
|
||||||
padding: const EdgeInsets.all(16),
|
),
|
||||||
child: Row(
|
body: RefreshIndicator(
|
||||||
children: [
|
onRefresh: _load,
|
||||||
Expanded(
|
child: _loading
|
||||||
child: TextField(
|
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
|
||||||
controller: _convId,
|
: ListView(
|
||||||
keyboardType: TextInputType.number,
|
children: [
|
||||||
decoration: const InputDecoration(
|
if (_error != null)
|
||||||
labelText: '대화방 ID',
|
Padding(
|
||||||
border: OutlineInputBorder(),
|
padding: const EdgeInsets.all(16),
|
||||||
helperText: 'core-backend에 대화방 목록 API가 생기기 전 임시 진입',
|
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||||
),
|
),
|
||||||
),
|
if (_rooms.isEmpty)
|
||||||
),
|
const Padding(
|
||||||
const SizedBox(width: 8),
|
padding: EdgeInsets.all(24),
|
||||||
FilledButton(
|
child: Text('대화방이 없습니다. + 버튼이나 연락처에서 대화를 시작하세요.'),
|
||||||
onPressed: () {
|
),
|
||||||
final id = int.tryParse(_convId.text.trim());
|
for (final room in _rooms)
|
||||||
if (id == null) return;
|
ListTile(
|
||||||
setState(() => _rooms.add(id));
|
leading: CircleAvatar(
|
||||||
},
|
child: Icon(room.isGroup ? Icons.groups_outlined : Icons.chat_bubble_outline),
|
||||||
child: const Text('추가'),
|
),
|
||||||
),
|
title: Text(me == null ? '대화방 #${room.id}' : room.titleFor(me)),
|
||||||
],
|
subtitle: Text(
|
||||||
),
|
[
|
||||||
),
|
'ID ${room.id}',
|
||||||
for (final id in rooms)
|
if (room.twinDisabledByPeer) '상대가 분신 거부',
|
||||||
ListTile(
|
].join(' · '),
|
||||||
leading: const CircleAvatar(child: Icon(Icons.chat_bubble_outline)),
|
),
|
||||||
title: Text('대화방 #$id'),
|
onTap: () async {
|
||||||
subtitle: const Text('탭해서 입장'),
|
await Navigator.of(context).push(
|
||||||
onTap: () {
|
MaterialPageRoute(
|
||||||
Navigator.of(context).push(
|
builder: (_) => ChatScreen(
|
||||||
MaterialPageRoute(builder: (_) => ChatScreen(conversationId: id)),
|
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({
|
Future<ChatMessage> sendMessage({
|
||||||
required int conversationId,
|
required int conversationId,
|
||||||
required int senderId,
|
required int senderId,
|
||||||
|
|
@ -126,24 +187,6 @@ class ApiClient {
|
||||||
return list.map((e) => WhitelistRule.fromJson(e as Map<String, dynamic>)).toList();
|
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 {
|
Future<WhitelistRule> addWhitelist(int userId, String topicKeyword) async {
|
||||||
final json = await _json('POST', '/users/$userId/whitelist-rules', body: {
|
final json = await _json('POST', '/users/$userId/whitelist-rules', body: {
|
||||||
'topic_keyword': topicKeyword,
|
'topic_keyword': topicKeyword,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
|
@ -12,11 +14,15 @@ class SessionState extends ChangeNotifier {
|
||||||
AutonomyLevel autonomyLevel = AutonomyLevel.L0;
|
AutonomyLevel autonomyLevel = AutonomyLevel.L0;
|
||||||
String? error;
|
String? error;
|
||||||
bool loading = false;
|
bool loading = false;
|
||||||
|
bool toneOnboardingDone = false;
|
||||||
|
List<String> styleExamples = const [];
|
||||||
|
|
||||||
static const _kUserId = 'user_id';
|
static const _kUserId = 'user_id';
|
||||||
static const _kDisplayName = 'display_name';
|
static const _kDisplayName = 'display_name';
|
||||||
static const _kInvite = 'invite_code';
|
static const _kInvite = 'invite_code';
|
||||||
static const _kToken = 'session_token';
|
static const _kToken = 'session_token';
|
||||||
|
static const _kToneDone = 'tone_onboarding_done';
|
||||||
|
static const _kStyleExamples = 'style_examples_json';
|
||||||
|
|
||||||
Future<void> restore() async {
|
Future<void> restore() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
@ -24,6 +30,12 @@ class SessionState extends ChangeNotifier {
|
||||||
final name = prefs.getString(_kDisplayName);
|
final name = prefs.getString(_kDisplayName);
|
||||||
final invite = prefs.getString(_kInvite);
|
final invite = prefs.getString(_kInvite);
|
||||||
final token = prefs.getString(_kToken);
|
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) {
|
if (id != null && name != null && invite != null) {
|
||||||
user = User(id: id, displayName: name, inviteCode: invite);
|
user = User(id: id, displayName: name, inviteCode: invite);
|
||||||
_api.authToken = token;
|
_api.authToken = token;
|
||||||
|
|
@ -38,11 +50,13 @@ class SessionState extends ChangeNotifier {
|
||||||
try {
|
try {
|
||||||
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
||||||
user = result.user;
|
user = result.user;
|
||||||
|
toneOnboardingDone = false;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setInt(_kUserId, result.user.id);
|
await prefs.setInt(_kUserId, result.user.id);
|
||||||
await prefs.setString(_kDisplayName, result.user.displayName);
|
await prefs.setString(_kDisplayName, result.user.displayName);
|
||||||
await prefs.setString(_kInvite, result.user.inviteCode);
|
await prefs.setString(_kInvite, result.user.inviteCode);
|
||||||
await prefs.setString(_kToken, result.token);
|
await prefs.setString(_kToken, result.token);
|
||||||
|
await prefs.setBool(_kToneDone, false);
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
error = '가입 실패 (${e.statusCode}): ${e.body}';
|
error = '가입 실패 (${e.statusCode}): ${e.body}';
|
||||||
} catch (e) {
|
} 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 {
|
Future<void> setAutonomy(AutonomyLevel level) async {
|
||||||
if (user == null) return;
|
if (user == null) return;
|
||||||
try {
|
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