diff --git a/AGENTS.md b/AGENTS.md index 8445c4a..f10ddc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,10 +75,9 @@ These apply at every autonomy level and must not be weakened for convenience: Follow `docs/PLANNING.md`: decide → narrow → validate → specify. PoC execution (real participant recruiting for PoC #1/#3, Q3 interviews) is -currently on hold — that data collection is deferred, not cancelled. In the -meantime, work has moved into Phase 1 app-build groundwork that doesn't -depend on PoC results (see `docs/roadmap.md` Phase 1 §2 for which workstreams -those are). +deferred to the **very last** Phase 1 step — after Flutter client + remaining +server infra are done. Do not start human PoC early and do not invent defaults +for Phase 1 §3. See `docs/roadmap.md` Phase 1 §3/§4. The tech stack for Phase 1 is decided — see `docs/tech-design.md` §8 (Flutter/Dart client, Go core backend + Python AI service, PostgreSQL, diff --git a/README.md b/README.md index a8b6837..4acd409 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,46 @@ -# hikikomori +# hikikomori / 분신 (가칭) -카카오톡 대안 메신저 아이디어 — "나를 대신해 남과 대화하는 AI 분신"이 있는 메신저 프로젝트. +카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 분신". + +## 구조 + +| 경로 | 역할 | +|---|---| +| `docs/` | 기획·PRD·기술설계·로드맵·PoC 계획 | +| `core-backend/` | Go 코어 (가입·메시지·WebSocket·자율성·안전장치) | +| `ai-service/` | Python AI 내부 API (`/draft`, `/escalate/check`) | +| `backend/` | 초기 Python 프로토타입 (참고용) | +| `poc/tone-corpus/` | PoC 실험 스크립트 | +| `mobile/` | Flutter 클라이언트 (Android 우선) | ## 문서 -- 원본 회의 자료: [`docs/idea-meeting-2026-06-29.html`](./docs/idea-meeting-2026-06-29.html) -- 기획 진행 방법: [`docs/PLANNING.md`](./docs/PLANNING.md) -- 결정 로그 (Q1~Q7): [`docs/decision-log.md`](./docs/decision-log.md) -- Vision Doc: [`docs/vision.md`](./docs/vision.md) -- PRD (v1 클로즈드 베타): [`docs/PRD.md`](./docs/PRD.md) -- 기술 설계서: [`docs/tech-design.md`](./docs/tech-design.md) -- 리스크 로그: [`docs/risk-log.md`](./docs/risk-log.md) -- 로드맵: [`docs/roadmap.md`](./docs/roadmap.md) -- PoC #1·#3 실행 계획: [`docs/poc-plan.md`](./docs/poc-plan.md) / 실행 준비물: [`docs/poc-materials.md`](./docs/poc-materials.md) -- Q3 자율성 수용성 인터뷰 가이드: [`docs/user-interview-guide.md`](./docs/user-interview-guide.md) -- 회의 리뷰 1페이지 요약: [`docs/meeting-review-summary.md`](./docs/meeting-review-summary.md) +- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) +- Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md) +- 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md) +- PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md) ## AI 에이전트 규칙 -- Cursor / 공통: [`AGENTS.md`](./AGENTS.md) -- Claude Code 진입점: [`CLAUDE.md`](./CLAUDE.md) +- [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md) ## 현재 단계 -아이디어 발산 → 컨셉 심화 → 기획 문서 세트 작성 → PoC 실행 계획·준비물·클릭 프로토타입 완료. -`docs/decision-log.md`의 결정은 아직 **잠정안**이므로, 실제 회의에서 확정/수정이 필요합니다. -다음 단계는 PoC #1·#3과 Q3 인터뷰를 실제로 진행하는 것입니다 — 계획과 자극재는 이미 준비돼 -있으니 참가자 모집부터 시작하면 됩니다 (`docs/meeting-review-summary.md` 참고). +- 기획 문서 + Phase 1 **서버(Go/Python)** + Flutter **클라이언트 골격**까지 진행됨 +- Q1~Q7은 아직 **잠정(제안)** — 회의 확정 전 +- **사람 대상 PoC #1/#3·Q3 인터뷰는 맨 마지막 작업**으로 미룸 (`docs/roadmap.md` §3) + +## 로컬 실행 (요약) + +```bash +# 터미널 1 — AI 서비스 +cd ai-service && pip install -r requirements.txt +export GEMINI_API_KEY=... # or repo-root .env +uvicorn app.main:app --port 8001 + +# 터미널 2 — 코어 백엔드 +cd core-backend && go run . + +# 터미널 3 — Flutter (Android) +cd mobile && flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080 +``` diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 66f15bf..cf86a00 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -138,7 +138,7 @@ - [x] 말투 검색기(retrieval) 구현 — `poc/tone-corpus/retrieve_style.py` (키워드 자카드 유사도 + 최근성 가중치, 임베딩 없이 v1 설계 그대로). `generate_draft.py --history`로 연결해 스타일 예시를 손으로 안 골라도 자동 검색되게 함 -- [ ] PoC #1·#3 실제 실행 (참가자 모집, 대화 샘플 수집, 역할극 인터뷰) 및 Go/No-Go 판정 +- [ ] PoC #1·#3 실제 실행 (참가자 모집, 대화 샘플 수집, 역할극 인터뷰) 및 Go/No-Go 판정 — **맨 마지막 작업으로 미룸** (`roadmap.md` §3) - [x] `blind_eval.py`, `retrieve_style.py`, `generate_draft.py --history` 연동 실제 실행 검증 — 검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md` "말투 검색기" 참고) diff --git a/docs/roadmap.md b/docs/roadmap.md index 45ce129..f611381 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -64,15 +64,20 @@ `POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400, 이미 되돌린 건 409, 성공하면 같은 대화방 WebSocket에 `{"type":"retraction", "id":...}` 브로드캐스트. 일반 메시지 브로드캐스트도 `"type":"message"`를 붙여 클라이언트가 두 이벤트를 구분하게 함. - **UI는 아직** — 사용자에게 사후 알림을 띄우고 되돌리기 버튼을 보여주는 건 Flutter 쪽. + **되돌리기 UI는 `mobile/`에 있음** — 사후알림 전용 함/푸시는 아직. 에스컬레이션 로그를 "조회"하는 API는 아직 없음(필요해지면 추가) -**2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — **Flutter/Dart SDK가 있는 환경(본인 로컬)에서 착수** -- [ ] 기본 채팅 UI (대화 목록, 대화방) — 무관, 착수 가능 -- [ ] 온보딩 플로우(5분 온보딩) 뼈대 — 무관, 착수 가능. 말투 학습 UX 디테일만 PoC#1 결과로 조정 -- [ ] 분신 뱃지·실시간 본인확인·거부권 UX — 클릭 프로토타입 디자인 그대로 구현 가능 -- [ ] 자율성 설정 화면(L0~L2, 화이트리스트, 상대별 예외) — 무관, 착수 가능 -- [ ] 에스컬레이션 배너·사후알림·되돌리기 UI — 무관, 착수 가능 +**2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — `mobile/` +- [x] 기본 채팅 UI (대화 목록, 대화방) — `mobile/` 골격. 대화방 목록 API는 아직 없어 ID 직접 + 입장. WebSocket 수신·인간 메시지 전송 연결됨 +- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 초대 코드 가입 화면만 있음. 말투 학습 UX 디테일은 + **사람 PoC #1 결과를 맨 마지막에 반영** (지금은 뼈대만) +- [x] 분신 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는 + 서버/AI 프롬프트 측과 이어서 다듬을 것 +- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외 매칭은 + 서버가 전역 키워드만 지원하는 동안 UI도 주제 키워드 CRUD만 +- [x] 에스컬레이션 배너·되돌리기 UI — 초안 escalate 배너 + twin 메시지 되돌리기 버튼. + 사후알림 전용 함/푸시는 아직 없음 **2.4 안전장치 통합** (전 구간 필수, 타협 불가) - [x] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 — 실제 발견한 우회 @@ -92,8 +97,7 @@ 무조건 차단되는 것까지 테스트로 확인함 - [~] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 — 서버 쪽 삭제 플로우(`DELETE /users/:id`, 유저가 걸린 모든 행을 트랜잭션으로 삭제)만 완료·테스트함. 온디바이스 - 암호화(drift+SQLCipher)와 실시간 데이터 흐름 대시보드는 Flutter 클라이언트 책임이라 이 환경(SDK - 없음)에서는 진행 불가 — 2.3과 함께 로컬 환경 대기 + 암호화(drift+SQLCipher)와 실시간 데이터 흐름 대시보드는 Flutter(`mobile/`) 쪽 후속 작업 **2.5 QA/테스트** - [x] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도 @@ -104,8 +108,7 @@ 구현과 함께: L0 차단, L1 미승인 차단/승인 시 발송, L2 화이트리스트 매칭 자동발송/비매칭 시 승인 필요, 에스컬레이션이 레벨·화이트리스트·승인 여부와 무관하게 항상 우선한다는 것까지 6개 케이스 전부 실제 테스트로 확인함 -- [ ] 온보딩·채팅·설정 수동 QA — Flutter 클라이언트가 없어 이 환경에서는 불가, 2.3과 함께 로컬 - 환경 대기 +- [ ] 온보딩·채팅·설정 수동 QA — `mobile/` 골격 위에 실기기/에뮬레이터로 진행 (후속) **2.6 베타 배포 준비** - [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라 @@ -123,12 +126,13 @@ 지연시간·오류율은 요청 타이밍/로깅 계측 계층이 따로 필요해서 이번엔 만들지 않음(허위로 채우지 않고 명시적으로 비워둠) -#### 3. PoC 결과가 있어야 정할 수 있는 것 (맨 마지막 — 그 전엔 손대지 않는다) +#### 3. PoC 결과가 있어야 정할 수 있는 것 — **전체 빌드가 끝난 뒤 맨 마지막** -**§2의 워크스트림(2.1~2.6)이 전부 끝나기 전까지는 이 섹션에 손대지 않는다.** 순서를 앞당겨서 -미리 추측값을 넣지 않는다 — PoC 데이터 없이 정하면 나중에 다시 뒤집어야 하고, 그게 더 큰 낭비다. -§2가 다 끝난 뒤에만 이 섹션으로 돌아온다. +**사람 대상 PoC #1/#3·Q3 인터뷰는 지금 실행할 수 없으므로 맨 마지막 작업으로 미룬다.** +§2 워크스트림(서버+Flutter)과 푸시/세션 등 남은 인프라가 끝난 뒤에만 이 섹션으로 돌아온다. +PoC 데이터 없이 기본값을 추측해 채우지 않는다. +- [ ] 사람 PoC #1/#3 실제 실행 + Q3 인터뷰 (참가자 모집 포함) — **맨 마지막** - [ ] 자율성 기본값(L1 vs L2 어디서 시작할지) — Q3 인터뷰 필요 - [ ] 화이트리스트 기본 주제 목록 — 실사용 데이터 필요 - [ ] 신뢰 UX 문구/노출 위치 최종 확정 — PoC#3 결과 필요 @@ -136,34 +140,22 @@ #### 4. 권장 착수 순서 (진행 상황) -순서대로 하나씩 완료하고 다음으로 넘어간다. **6번(§3)은 1~5번이 전부 끝난 뒤에만 시작한다** — -중간에 끼워 넣지 않는다. +순서대로 하나씩 완료하고 다음으로 넘어간다. **사람 PoC(§3)는 1~5번이 전부 끝난 뒤 맨 마지막.** 1. [x] §1 기술 스택 결정 (Go 코어 + Python AI 서비스로 재확정, `backend/`는 Python 프로토타입 — 설계 참고용으로 남기고 Go로 포팅 필요) -2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket 릴레이 완료, 푸시 알림만 남음). - 병행하려던 2.3 Flutter 채팅 UI 뼈대는 **이 작업 환경에 Flutter/Dart SDK가 없어 빌드 검증이 - 불가능**해서 보류 — Flutter는 Windows에 SDK가 설치된 환경(본인 로컬)에서 시작 -3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동(`core-backend/aiservice.go`, - `POST /conversations/:id/draft`)도 완료·테스트 통과. 자율성 엔진 오케스트레이션도 최소 버전으로 - 완료(아래 5번과 함께 구현). 되돌리기 API도 완료(아래 5번 참고). 남은 건 온디바이스 말투 이력 - 저장 — 클라이언트 의존이라 2.3(Flutter) 대기 -4. [~] 2.3 나머지 UX(온보딩·설정·뱃지) — Flutter SDK가 없는 이 환경에서는 여전히 착수 불가. - 대신 2.4(안전장치 통합) 중 서버 쪽 몫(에스컬레이션 하드게이트 우회 차단, 삭제 API)을 먼저 진행함 -5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 2.4: 하드게이트 우회 차단, 거부권 구현, 삭제 플로우. 2.5: - `ai-service` pytest 스위트 승격, 자율성 플로우(L0→L1→L2) 통합 테스트 — 이걸 쓰려면 실제 분기 - 로직이 필요해서 2.2의 자율성 오케스트레이션도 이때 같이 구현함. 남은 건 전부 클라이언트 쪽 - (온디바이스 암호화·데이터 흐름 대시보드·수동 QA) — Flutter 대기 - - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 실제 초대 코드 발급/검증(`InviteCode`, `POST - /invites`), `GET /admin/metrics`(메시지·에스컬레이션·거부율 카운트). **실제 베타 오픈 - 시점 자체는 여전히 6번(§3) 이후** — 서버 인프라만 먼저 준비해둔 것 - - 5-2. [x] 화이트리스트 규칙 CRUD API (`POST`/`GET /users/:id/whitelist-rules`, - `DELETE /users/:id/whitelist-rules/:ruleId`) — Flutter의 자율성 설정 화면(2.3)이 붙여 쓸 수 - 있게 미리 준비. `contact_id`는 저장되지만 매칭 로직은 여전히 전역 키워드만 봄(위 2.2 참고) -6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 실제 베타 오픈 +2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket 릴레이 완료, 푸시 알림만 남음) +3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션· + 되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서 +4. [~] 2.3 Flutter 클라이언트 — `mobile/` 골격 착수 완료(가입·채팅·뱃지·거부권·되돌리기·자율성 + 설정). 남은 것: 대화방/연락처 목록 API 연동, 온보딩 말투 UX, drift+SQLCipher, 수동 QA +5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료. + 클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 2.3 나머지와 함께 + - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은 + §3(사람 PoC) 이후** + - 5-2. [x] 화이트리스트 규칙 CRUD API +6. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 2.6 실제 베타 오픈 (**맨 마지막**) -1~5는 PoC 실제 실행(A트랙)과 병행 가능 — PoC가 늦어져도 인프라 작업은 막히지 않는다. 다만 -§3 항목과 최종 베타 오픈은 PoC 결과 없이 확정하지 않는다. ## Phase 2 — L3 확장 + 베타 확대 diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/mobile/.metadata b/mobile/.metadata new file mode 100644 index 0000000..48c07ef --- /dev/null +++ b/mobile/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d7b523b356d15fb81e7d340bbe52b47f93937323" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + - platform: android + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..b575924 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,35 @@ +# 분신 mobile (Flutter) + +Phase 1 클라이언트 골격 (`docs/roadmap.md` §2.3). **v1은 Android 빌드만** 대상으로 한다 +(`docs/tech-design.md` §8). + +## 지금 있는 것 + +- 초대 코드 가입 (`POST /auth/signup`) +- 대화방 입장(임시: 대화방 ID 직접 입력 — 목록 API는 아직 core-backend에 없음) +- 채팅 전송 + WebSocket 수신 +- 분신 초안 요청 / L1 승인 발송 +- 분신 뱃지(점선 + 라벨) · 거부권 · 되돌리기 +- 자율성 L0~L2 설정 + 화이트리스트 CRUD UI + +## 실행 + +```bash +# core-backend + ai-service 가 떠 있어야 실제 호출이 된다 +cd mobile +flutter pub get + +# Android 에뮬레이터 → 호스트의 core-backend(기본 8080) +flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080 + +# 실기기/로컬 네트워크 +flutter run --dart-define=CORE_API_BASE=http://:8080 +``` + +## 아직 없는 것 + +- 대화방/연락처 목록 API 연동 (서버에 엔드포인트 추가 필요) +- 온보딩 말투 학습 UX 디테일 (PoC #1 결과는 맨 마지막에 반영) +- drift + SQLCipher 온디바이스 저장 +- 푸시 알림 +- iOS 빌드 (v1 범위 밖) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/mobile/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/mobile/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts new file mode 100644 index 0000000..9ed1068 --- /dev/null +++ b/mobile/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.bunsin.bunsin_mobile" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.bunsin.bunsin_mobile" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/mobile/android/app/src/debug/AndroidManifest.xml b/mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cf3320b --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/com/bunsin/bunsin_mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/bunsin/bunsin_mobile/MainActivity.kt new file mode 100644 index 0000000..2470c58 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/bunsin/bunsin_mobile/MainActivity.kt @@ -0,0 +1,5 @@ +package com.bunsin.bunsin_mobile + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/profile/AndroidManifest.xml b/mobile/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/mobile/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/mobile/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts new file mode 100644 index 0000000..ab39a10 --- /dev/null +++ b/mobile/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/mobile/lib/config.dart b/mobile/lib/config.dart new file mode 100644 index 0000000..45c2aa5 --- /dev/null +++ b/mobile/lib/config.dart @@ -0,0 +1,20 @@ +/// Runtime config for talking to `core-backend/`. +/// +/// Override at run time with: +/// `flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080` +class AppConfig { + static const coreApiBase = String.fromEnvironment( + 'CORE_API_BASE', + defaultValue: 'http://10.0.2.2:8080', // Android emulator → host localhost + ); + + static String wsBase() { + final uri = Uri.parse(coreApiBase); + final scheme = uri.scheme == 'https' ? 'wss' : 'ws'; + return Uri( + scheme: scheme, + host: uri.host, + port: uri.hasPort ? uri.port : null, + ).toString(); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart new file mode 100644 index 0000000..1e3c25e --- /dev/null +++ b/mobile/lib/main.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'screens/conversation_list_screen.dart'; +import 'screens/signup_screen.dart'; +import 'state/session_state.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final session = SessionState(); + await session.restore(); + runApp(BunsinApp(session: session)); +} + +class BunsinApp extends StatelessWidget { + const BunsinApp({super.key, required this.session}); + + final SessionState session; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: session, + child: MaterialApp( + title: '분신', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF1F6F5B), + brightness: Brightness.light, + ), + useMaterial3: true, + ), + home: Consumer( + builder: (context, s, _) { + if (s.user == null) return const SignupScreen(); + return const ConversationListScreen(); + }, + ), + ), + ); + } +} diff --git a/mobile/lib/models/models.dart b/mobile/lib/models/models.dart new file mode 100644 index 0000000..d639edc --- /dev/null +++ b/mobile/lib/models/models.dart @@ -0,0 +1,98 @@ +enum SenderMode { human, twin } + +// Product autonomy ladder names (PRD) — keep L0/L1/L2 as identifiers. +// ignore: constant_identifier_names +enum AutonomyLevel { L0, L1, L2 } + +class User { + User({required this.id, required this.displayName, required this.inviteCode}); + + final int id; + final String displayName; + final String inviteCode; + + factory User.fromJson(Map json) => User( + id: json['id'] as int, + displayName: json['display_name'] as String? ?? json['displayName'] as String? ?? '', + inviteCode: json['invite_code'] as String? ?? json['inviteCode'] as String? ?? '', + ); +} + +class TwinSettings { + TwinSettings({required this.autonomyLevel}); + + final AutonomyLevel autonomyLevel; + + factory TwinSettings.fromJson(Map json) { + final raw = (json['autonomy_level'] as String? ?? 'L0').toUpperCase(); + return TwinSettings( + autonomyLevel: AutonomyLevel.values.firstWhere( + (e) => e.name == raw, + orElse: () => AutonomyLevel.L0, + ), + ); + } +} + +class ChatMessage { + ChatMessage({ + required this.id, + required this.conversationId, + required this.senderId, + required this.senderMode, + required this.text, + required this.retracted, + required this.createdAt, + }); + + final int id; + final int conversationId; + final int senderId; + final SenderMode senderMode; + final String text; + final bool retracted; + final DateTime createdAt; + + bool get isTwin => senderMode == SenderMode.twin; + + factory ChatMessage.fromJson(Map json) { + final mode = (json['sender_mode'] as String? ?? 'human').toLowerCase(); + return ChatMessage( + id: json['id'] as int, + conversationId: json['conversation_id'] as int? ?? json['conversationId'] as int? ?? 0, + senderId: json['sender_id'] as int? ?? json['senderId'] as int? ?? 0, + senderMode: mode == 'twin' ? SenderMode.twin : SenderMode.human, + text: json['text'] as String? ?? '', + retracted: json['retracted'] as bool? ?? false, + createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(), + ); + } +} + +class WhitelistRule { + WhitelistRule({required this.id, required this.topicKeyword, this.contactId}); + + final int id; + final String topicKeyword; + final int? contactId; + + factory WhitelistRule.fromJson(Map json) => WhitelistRule( + id: json['id'] as int, + topicKeyword: json['topic_keyword'] as String? ?? '', + contactId: json['contact_id'] as int?, + ); +} + +class DraftResult { + DraftResult({required this.status, required this.text}); + + final String status; // ok | escalate | no_key + final String text; + + factory DraftResult.fromJson(Map json) => DraftResult( + status: json['status'] as String? ?? 'ok', + text: json['text'] as String? ?? '', + ); + + bool get isEscalate => status == 'escalate'; +} diff --git a/mobile/lib/screens/autonomy_settings_screen.dart b/mobile/lib/screens/autonomy_settings_screen.dart new file mode 100644 index 0000000..b718537 --- /dev/null +++ b/mobile/lib/screens/autonomy_settings_screen.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/models.dart'; +import '../services/api_client.dart'; +import '../state/session_state.dart'; + +class AutonomySettingsScreen extends StatefulWidget { + const AutonomySettingsScreen({super.key}); + + @override + State createState() => _AutonomySettingsScreenState(); +} + +class _AutonomySettingsScreenState extends State { + final _keyword = TextEditingController(); + List _rules = []; + String? _error; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final session = context.read(); + if (session.user == null) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final rules = await session.api.listWhitelist(session.user!.id); + setState(() => _rules = rules); + } on ApiException catch (e) { + setState(() => _error = '목록 로드 실패 (${e.statusCode})'); + } finally { + setState(() => _loading = false); + } + } + + @override + void dispose() { + _keyword.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final session = context.watch(); + + return Scaffold( + appBar: AppBar(title: const Text('자율성 설정')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('전역 레벨', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: AutonomyLevel.L0, label: Text('L0'), tooltip: '초안만'), + ButtonSegment(value: AutonomyLevel.L1, label: Text('L1'), tooltip: '승인 후 발송'), + ButtonSegment(value: AutonomyLevel.L2, label: Text('L2'), tooltip: '화이트리스트 자동'), + ], + selected: {session.autonomyLevel}, + onSelectionChanged: (s) => session.setAutonomy(s.first), + ), + const SizedBox(height: 8), + Text( + '기본값은 L0입니다. L2는 아래 화이트리스트 주제에만 자동 발송됩니다.', + style: Theme.of(context).textTheme.bodySmall, + ), + const Divider(height: 32), + Text('L2 화이트리스트 주제', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: _keyword, + decoration: const InputDecoration( + labelText: '주제 키워드', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () async { + final text = _keyword.text.trim(); + if (text.isEmpty || session.user == null) return; + try { + final rule = await session.api.addWhitelist(session.user!.id, text); + setState(() { + _rules = [..._rules, rule]; + _keyword.clear(); + }); + } on ApiException catch (e) { + setState(() => _error = '추가 실패 (${e.statusCode})'); + } + }, + child: const Text('추가'), + ), + ], + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + const SizedBox(height: 12), + if (_loading) + const Center(child: CircularProgressIndicator()) + else + ..._rules.map( + (r) => ListTile( + title: Text(r.topicKeyword), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () async { + if (session.user == null) return; + await session.api.deleteWhitelist(session.user!.id, r.id); + setState(() => _rules = _rules.where((x) => x.id != r.id).toList()); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart new file mode 100644 index 0000000..0c6c537 --- /dev/null +++ b/mobile/lib/screens/chat_screen.dart @@ -0,0 +1,252 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/models.dart'; +import '../services/api_client.dart'; +import '../services/ws_client.dart'; +import '../state/session_state.dart'; +import '../widgets/message_bubble.dart'; + +class ChatScreen extends StatefulWidget { + const ChatScreen({super.key, required this.conversationId}); + + final int conversationId; + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final _input = TextEditingController(); + final _messages = []; + ConversationSocket? _socket; + StreamSubscription? _sub; + String? _banner; + DraftResult? _pendingDraft; + bool _busy = false; + + @override + void initState() { + super.initState(); + _socket = ConversationSocket(widget.conversationId)..connect(); + _sub = _socket!.events.listen(_onEvent); + } + + void _onEvent(Map event) { + final retractionId = _socket!.parseRetractionId(event); + if (retractionId != null) { + setState(() { + final i = _messages.indexWhere((m) => m.id == retractionId); + if (i >= 0) { + final m = _messages[i]; + _messages[i] = ChatMessage( + id: m.id, + conversationId: m.conversationId, + senderId: m.senderId, + senderMode: m.senderMode, + text: m.text, + retracted: true, + createdAt: m.createdAt, + ); + } + }); + return; + } + final msg = _socket!.parseMessageEvent(event); + if (msg == null) return; + if (_messages.any((m) => m.id == msg.id)) return; + setState(() => _messages.add(msg)); + } + + @override + void dispose() { + _sub?.cancel(); + _socket?.dispose(); + _input.dispose(); + super.dispose(); + } + + Future _sendHuman() async { + final session = context.read(); + final text = _input.text.trim(); + if (text.isEmpty || session.user == null) return; + setState(() => _busy = true); + try { + final msg = await session.api.sendMessage( + conversationId: widget.conversationId, + senderId: session.user!.id, + text: text, + ); + _input.clear(); + if (!_messages.any((m) => m.id == msg.id)) { + setState(() => _messages.add(msg)); + } + } on ApiException catch (e) { + setState(() => _banner = '전송 실패 (${e.statusCode})'); + } finally { + setState(() => _busy = false); + } + } + + Future _requestDraft() async { + final session = context.read(); + setState(() { + _busy = true; + _banner = null; + _pendingDraft = null; + }); + try { + final contextLines = _messages + .where((m) => !m.retracted) + .map((m) => '${m.isTwin ? "분신" : "상대"}: ${m.text}') + .toList(); + if (_input.text.trim().isNotEmpty) { + contextLines.add('상대: ${_input.text.trim()}'); + } + final draft = await session.api.requestDraft( + conversationId: widget.conversationId, + contextLines: contextLines.isEmpty ? ['상대: 안녕'] : contextLines, + styleExamples: const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게'], + ); + setState(() { + _pendingDraft = draft; + if (draft.isEscalate) { + _banner = '에스컬레이션: ${draft.text}'; + } + }); + } on ApiException catch (e) { + setState(() => _banner = '초안 실패 (${e.statusCode}): ${e.body}'); + } finally { + setState(() => _busy = false); + } + } + + Future _sendTwinApproved() async { + final session = context.read(); + final draft = _pendingDraft; + if (draft == null || draft.isEscalate || session.user == null) return; + setState(() => _busy = true); + try { + final msg = await session.api.sendMessage( + conversationId: widget.conversationId, + senderId: session.user!.id, + text: draft.text, + senderMode: SenderMode.twin, + approved: true, + ); + setState(() { + _pendingDraft = null; + if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg); + }); + } on ApiException catch (e) { + setState(() => _banner = '분신 발송 차단 (${e.statusCode}): ${e.body}'); + } finally { + setState(() => _busy = false); + } + } + + Future _veto() async { + final session = context.read(); + try { + await session.api.vetoConversation(widget.conversationId); + setState(() => _banner = '거부권 적용: 이 대화방에서 분신 자동응대가 중단됩니다.'); + } on ApiException catch (e) { + setState(() => _banner = '거부권 실패 (${e.statusCode})'); + } + } + + Future _retract(ChatMessage message) async { + final session = context.read(); + try { + await session.api.retractMessage(message.id); + } on ApiException catch (e) { + setState(() => _banner = '되돌리기 실패 (${e.statusCode})'); + } + } + + @override + Widget build(BuildContext context) { + final session = context.watch(); + final me = session.user?.id; + + return Scaffold( + appBar: AppBar( + title: Text('대화방 #${widget.conversationId}'), + actions: [ + TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')), + ], + ), + body: Column( + children: [ + if (_banner != null) + MaterialBanner( + content: Text(_banner!), + actions: [ + TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')), + ], + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _messages.length, + itemBuilder: (context, i) { + final m = _messages[i]; + return MessageBubble( + message: m, + isMine: me != null && m.senderId == me, + onRetract: m.isTwin ? () => _retract(m) : null, + ); + }, + ), + ), + 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('보내기'), + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _input, + minLines: 1, + maxLines: 4, + decoration: const InputDecoration( + hintText: '메시지', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + tooltip: '초안 요청', + onPressed: _busy ? null : _requestDraft, + icon: const Icon(Icons.auto_awesome), + ), + const SizedBox(width: 4), + IconButton.filled( + tooltip: '보내기', + onPressed: _busy ? null : _sendHuman, + icon: const Icon(Icons.send), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart new file mode 100644 index 0000000..9b31bb1 --- /dev/null +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/session_state.dart'; +import 'autonomy_settings_screen.dart'; +import 'chat_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}); + + @override + State createState() => _ConversationListScreenState(); +} + +class _ConversationListScreenState extends State { + final _convId = TextEditingController(text: '1'); + final _rooms = {1}; + + @override + void dispose() { + _convId.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final session = context.watch(); + final rooms = _rooms.toList()..sort(); + + return Scaffold( + appBar: AppBar( + title: Text('분신 · ${session.user?.displayName ?? ''}'), + actions: [ + IconButton( + tooltip: '자율성 설정', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AutonomySettingsScreen()), + ); + }, + icon: const Icon(Icons.tune), + ), + ], + ), + body: ListView( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _convId, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '대화방 ID', + border: OutlineInputBorder(), + helperText: 'core-backend에 대화방 목록 API가 생기기 전 임시 진입', + ), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + final id = int.tryParse(_convId.text.trim()); + if (id == null) return; + setState(() => _rooms.add(id)); + }, + child: const Text('추가'), + ), + ], + ), + ), + for (final id in rooms) + ListTile( + leading: const CircleAvatar(child: Icon(Icons.chat_bubble_outline)), + title: Text('대화방 #$id'), + subtitle: const Text('탭해서 입장'), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ChatScreen(conversationId: id)), + ); + }, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/signup_screen.dart b/mobile/lib/screens/signup_screen.dart new file mode 100644 index 0000000..89652a9 --- /dev/null +++ b/mobile/lib/screens/signup_screen.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/session_state.dart'; + +class SignupScreen extends StatefulWidget { + const SignupScreen({super.key}); + + @override + State createState() => _SignupScreenState(); +} + +class _SignupScreenState extends State { + final _invite = TextEditingController(); + final _name = TextEditingController(); + + @override + void dispose() { + _invite.dispose(); + _name.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final session = context.watch(); + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 48), + Text('분신', style: Theme.of(context).textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800)), + const SizedBox(height: 8), + Text( + '초대 코드로 클로즈드 베타에 참여합니다.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + TextField( + controller: _invite, + decoration: const InputDecoration( + labelText: '초대 코드', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _name, + decoration: const InputDecoration( + labelText: '표시 이름', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.done, + ), + if (session.error != null) ...[ + const SizedBox(height: 12), + Text(session.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + const Spacer(), + FilledButton( + onPressed: session.loading + ? null + : () => session.signup(_invite.text.trim(), _name.text.trim()), + child: session.loading + ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('시작하기'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart new file mode 100644 index 0000000..4df6293 --- /dev/null +++ b/mobile/lib/services/api_client.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../config.dart'; +import '../models/models.dart'; + +class ApiException implements Exception { + ApiException(this.statusCode, this.body); + final int statusCode; + final String body; + + @override + String toString() => 'ApiException($statusCode): $body'; +} + +/// Thin REST client for `core-backend/` endpoints used by the v1 scaffold. +class ApiClient { + ApiClient({http.Client? httpClient, String? baseUrl}) + : _http = httpClient ?? http.Client(), + _base = baseUrl ?? AppConfig.coreApiBase; + + final http.Client _http; + final String _base; + + Uri _u(String path) => Uri.parse('$_base$path'); + + Future> _json( + String method, + String path, { + Map? body, + }) async { + final req = http.Request(method, _u(path)); + req.headers['Content-Type'] = 'application/json'; + if (body != null) req.body = jsonEncode(body); + final streamed = await _http.send(req); + final res = await http.Response.fromStream(streamed); + if (res.statusCode >= 400) { + throw ApiException(res.statusCode, res.body); + } + if (res.body.isEmpty) return {}; + return jsonDecode(res.body) as Map; + } + + Future> _jsonList(String path) async { + final res = await _http.get(_u(path)); + if (res.statusCode >= 400) { + throw ApiException(res.statusCode, res.body); + } + return jsonDecode(res.body) as List; + } + + Future signup({required String inviteCode, required String displayName}) async { + final json = await _json('POST', '/auth/signup', body: { + 'invite_code': inviteCode, + 'display_name': displayName, + }); + // core-backend returns {id, display_name} only — keep the invite we sent. + return User( + id: json['id'] as int, + displayName: json['display_name'] as String? ?? displayName, + inviteCode: inviteCode, + ); + } + + Future sendMessage({ + required int conversationId, + required int senderId, + required String text, + SenderMode senderMode = SenderMode.human, + bool approved = false, + }) async { + final json = await _json('POST', '/conversations/$conversationId/messages', body: { + 'sender_id': senderId, + 'text': text, + 'sender_mode': senderMode == SenderMode.twin ? 'twin' : 'human', + if (approved) 'approved': true, + }); + return ChatMessage.fromJson(json); + } + + Future requestDraft({ + required int conversationId, + required List contextLines, + List? styleExamples, + }) async { + final body = { + 'context_lines': contextLines, + if (styleExamples != null) 'style_examples': styleExamples, + }; + final json = await _json('POST', '/conversations/$conversationId/draft', body: body); + return DraftResult.fromJson(json); + } + + Future vetoConversation(int conversationId) async { + await _json('POST', '/conversations/$conversationId/veto'); + } + + Future retractMessage(int messageId) async { + await _json('POST', '/messages/$messageId/retract'); + } + + Future patchTwinSettings(int userId, AutonomyLevel level) async { + final json = await _json('PATCH', '/users/$userId/twin-settings', body: { + 'autonomy_level': level.name, + }); + return TwinSettings.fromJson(json); + } + + Future> listWhitelist(int userId) async { + final list = await _jsonList('/users/$userId/whitelist-rules'); + return list.map((e) => WhitelistRule.fromJson(e as Map)).toList(); + } + + Future addWhitelist(int userId, String topicKeyword) async { + final json = await _json('POST', '/users/$userId/whitelist-rules', body: { + 'topic_keyword': topicKeyword, + }); + return WhitelistRule.fromJson(json); + } + + Future deleteWhitelist(int userId, int ruleId) async { + await _json('DELETE', '/users/$userId/whitelist-rules/$ruleId'); + } +} diff --git a/mobile/lib/services/ws_client.dart b/mobile/lib/services/ws_client.dart new file mode 100644 index 0000000..6732211 --- /dev/null +++ b/mobile/lib/services/ws_client.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../config.dart'; +import '../models/models.dart'; + +/// Conversation-scoped WebSocket relay (`GET /ws/conversations/:id`). +class ConversationSocket { + ConversationSocket(this.conversationId); + + final int conversationId; + WebSocketChannel? _channel; + final _controller = StreamController>.broadcast(); + + Stream> get events => _controller.stream; + + void connect() { + final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId'); + _channel = WebSocketChannel.connect(uri); + _channel!.stream.listen( + (raw) { + if (raw is! String) return; + final decoded = jsonDecode(raw); + if (decoded is Map) { + _controller.add(decoded); + } + }, + onError: _controller.addError, + onDone: () {}, + ); + } + + ChatMessage? parseMessageEvent(Map event) { + final type = event['type'] as String?; + if (type != null && type != 'message') return null; + // Backend may wrap payload or send the message object directly. + final payload = event['message'] as Map? ?? event; + if (payload['id'] == null || payload['text'] == null) return null; + return ChatMessage.fromJson(payload); + } + + int? parseRetractionId(Map event) { + if (event['type'] != 'retraction') return null; + return event['id'] as int?; + } + + Future dispose() async { + await _channel?.sink.close(); + await _controller.close(); + } +} diff --git a/mobile/lib/state/session_state.dart b/mobile/lib/state/session_state.dart new file mode 100644 index 0000000..343ee0d --- /dev/null +++ b/mobile/lib/state/session_state.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/models.dart'; +import '../services/api_client.dart'; + +class SessionState extends ChangeNotifier { + SessionState({ApiClient? api}) : _api = api ?? ApiClient(); + + final ApiClient _api; + User? user; + AutonomyLevel autonomyLevel = AutonomyLevel.L0; + String? error; + bool loading = false; + + static const _kUserId = 'user_id'; + static const _kDisplayName = 'display_name'; + static const _kInvite = 'invite_code'; + + Future restore() async { + final prefs = await SharedPreferences.getInstance(); + final id = prefs.getInt(_kUserId); + final name = prefs.getString(_kDisplayName); + final invite = prefs.getString(_kInvite); + if (id != null && name != null && invite != null) { + user = User(id: id, displayName: name, inviteCode: invite); + notifyListeners(); + } + } + + Future signup(String inviteCode, String displayName) async { + loading = true; + error = null; + notifyListeners(); + try { + final created = await _api.signup(inviteCode: inviteCode, displayName: displayName); + user = created; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_kUserId, created.id); + await prefs.setString(_kDisplayName, created.displayName); + await prefs.setString(_kInvite, created.inviteCode); + } on ApiException catch (e) { + error = '가입 실패 (${e.statusCode}): ${e.body}'; + } catch (e) { + error = e.toString(); + } finally { + loading = false; + notifyListeners(); + } + } + + Future setAutonomy(AutonomyLevel level) async { + if (user == null) return; + try { + final settings = await _api.patchTwinSettings(user!.id, level); + autonomyLevel = settings.autonomyLevel; + notifyListeners(); + } on ApiException catch (e) { + error = '자율성 변경 실패 (${e.statusCode})'; + notifyListeners(); + } + } + + ApiClient get api => _api; +} diff --git a/mobile/lib/widgets/message_bubble.dart b/mobile/lib/widgets/message_bubble.dart new file mode 100644 index 0000000..4d79980 --- /dev/null +++ b/mobile/lib/widgets/message_bubble.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +import '../models/models.dart'; + +/// Twin messages use a dashed border + badge (PRD §3.1 분신 뱃지). +class MessageBubble extends StatelessWidget { + const MessageBubble({ + super.key, + required this.message, + required this.isMine, + this.onRetract, + }); + + final ChatMessage message; + final bool isMine; + final VoidCallback? onRetract; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final twin = message.isTwin; + final bg = isMine + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHighest; + + final bubble = Container( + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.78), + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(14), + border: twin + ? Border.all(color: theme.colorScheme.tertiary, width: 1.5, strokeAlign: BorderSide.strokeAlignOutside) + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (twin) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + '분신', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.tertiary, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + message.retracted ? '(되돌린 메시지)' : message.text, + style: theme.textTheme.bodyMedium?.copyWith( + fontStyle: message.retracted ? FontStyle.italic : FontStyle.normal, + color: message.retracted ? theme.disabledColor : null, + ), + ), + if (twin && isMine && !message.retracted && onRetract != null) + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: onRetract, + child: const Text('되돌리기'), + ), + ), + ], + ), + ); + + // Dashed look for twin: overlay a custom painter border when twin. + if (!twin) { + return Align( + alignment: isMine ? Alignment.centerRight : Alignment.centerLeft, + child: bubble, + ); + } + + return Align( + alignment: isMine ? Alignment.centerRight : Alignment.centerLeft, + child: CustomPaint( + painter: _DashedRRectPainter(color: theme.colorScheme.tertiary), + child: bubble, + ), + ); + } +} + +class _DashedRRectPainter extends CustomPainter { + _DashedRRectPainter({required this.color}); + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + final rrect = RRect.fromRectAndRadius( + Rect.fromLTWH(1, 1, size.width - 2, size.height - 2), + const Radius.circular(14), + ); + final path = Path()..addRRect(rrect); + final dashed = _dashPath(path, dashLength: 5, gapLength: 4); + canvas.drawPath(dashed, paint); + } + + Path _dashPath(Path source, {required double dashLength, required double gapLength}) { + final metrics = source.computeMetrics(); + final out = Path(); + for (final metric in metrics) { + var distance = 0.0; + while (distance < metric.length) { + final next = distance + dashLength; + out.addPath(metric.extractPath(distance, next.clamp(0, metric.length)), Offset.zero); + distance = next + gapLength; + } + } + return out; + } + + @override + bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) => oldDelegate.color != color; +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock new file mode 100644 index 0000000..e33e219 --- /dev/null +++ b/mobile/pubspec.lock @@ -0,0 +1,410 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + url: "https://pub.dev" + source: hosted + version: "2.4.13" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.8.1 <4.0.0" + flutter: ">=3.29.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml new file mode 100644 index 0000000..f10ba98 --- /dev/null +++ b/mobile/pubspec.yaml @@ -0,0 +1,93 @@ +name: bunsin_mobile +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.8.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + http: ^1.6.0 + web_socket_channel: ^3.0.3 + provider: ^6.1.5+1 + shared_preferences: ^2.5.3 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart new file mode 100644 index 0000000..10a4477 --- /dev/null +++ b/mobile/test/widget_test.dart @@ -0,0 +1,12 @@ +import 'package:bunsin_mobile/main.dart'; +import 'package:bunsin_mobile/state/session_state.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('shows signup when logged out', (tester) async { + final session = SessionState(); + await tester.pumpWidget(BunsinApp(session: session)); + expect(find.text('분신'), findsOneWidget); + expect(find.text('시작하기'), findsOneWidget); + }); +}