화면 전면 개편, 복구 코드 폐지와 다시 연결 승인·제명, 기록을 wiki(Obsidian)로

- 처음 시작은 이름·번호만, 키는 조용히 생성. 복구 코드·조각 맡기기 화면 삭제
- BlockService: 새 기기 다시 연결 요청 승인 / 영구 제명(그 번호 인사 차단)
- 아래 메뉴 채팅(첫 화면)·관계·업무·명함·나, 배지
- 채팅 목록·대화방(날짜 구분, 업무 카드, 긴급 띠, + 버튼, ⋮ 메뉴), 관계 탭, 나 탭, 받은 요청 화면, 프로필 정리
- 표시 도우미: 010 번호, 목록 시각, 마감, 미리보기
- 테마 정비(글꼴 명시, 목록·버튼·칩·FAB), 옛 홈 타일·중복 화면 삭제
- test_ux/shots_test.dart 로 화면 캡처, wiki/ 에 모든 가이드·결정·진행 기록·화면 점검
- 시험 313개 통과, analyze 0건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
diadmin 2026-10-26 20:33:37 +09:00
parent 3a31b40e54
commit e7982c808c
100 changed files with 2038 additions and 1673 deletions

4
.gitignore vendored
View File

@ -50,3 +50,7 @@ app.*.map.json
# 보관함 서버 실행 데이터 # 보관함 서버 실행 데이터
server/*.sqlite3 server/*.sqlite3
__pycache__/ __pycache__/
# Obsidian 개인 화면 상태
wiki/.obsidian/workspace*.json
.claude/settings.local.json

View File

@ -1,7 +1,18 @@
# Friendly Map — Claude 작업 지침 (인수인계) # Friendly Map — Claude 작업 지침 (인수인계)
## 0. 가장 먼저 (이 지시가 모든 것보다 최우선)
- **모든 가이드·문서·결정·진행 기록·화면 점검은 프로젝트의 `wiki/` Obsidian 보관함에 저장하고 관리한다.** 새 내용은 wiki 에 먼저 쓰고, 다른 문서(README 등)는 wiki 를 가리키기만 한다.
- 세션을 시작하면 `wiki/홈.md` 와 `wiki/00 최우선 규칙.md` 부터 읽는다. 일할 때마다 `wiki/진행 로그/<날짜>.md`, `wiki/02 결정 기록.md` 를 갱신한다.
이 파일은 이전 대화(claude.ai 프로젝트 "Frindly Map")에서 정한 것을 새 세션에 넘기는 문서다. 이 파일은 이전 대화(claude.ai 프로젝트 "Frindly Map")에서 정한 것을 새 세션에 넘기는 문서다.
세션을 시작하면 먼저 이 파일, README.md, tool/check_modules.py 를 읽고, 작업 전에 아래 원칙을 한 번 읊고 시작한다. 세션을 시작하면 먼저 이 파일, wiki/홈.md, tool/check_modules.py 를 읽고, 작업 전에 아래 원칙을 한 번 읊고 시작한다.
## 최우선 규칙 (모든 규칙보다 위)
- 질문은 반드시 최종 화면이 담긴 설치 가능한 APK 를 먼저 건넨 뒤(사용자가 설치해 써 보며 답할 수 있게), 캡처도 함께 보여 주고, 그 화면을 기준으로 "이렇게 하면 / 저렇게 하면" 비교로만 묻는다. 화면 없는 질문 금지.
- 암호 비트 수·키 구조·함수 설계 같은 내부 기술 질문은 하지 않는다. 내가 정하고 결과에 적는다. 책임 회피성 질문 금지.
- 방법론: 화면 먼저 → 화면 보고 사용자가 답 → 그 답에 따라 내부 아키텍처를 바꾼다.
- 모든 기록(결정·진행 로그·로드맵·화면 점검)은 프로젝트의 `wiki/` Obsidian 보관함에 남긴다. 일할 때마다 갱신.
- 화면 캡처: `flutter test test_ux/shots_test.dart --update-goldens` → `test_ux/shots/*.png` (한글 글꼴 `build/ux/kr.ttf` 필요, 맥의 /Library/Fonts/Arial Unicode.ttf 복사).
## 사용자와 일하는 규칙 (1순위, 어기지 말 것) ## 사용자와 일하는 규칙 (1순위, 어기지 말 것)
- 모든 답변은 한글. 고친 파일은 일부 발췌가 아니라 처음부터 끝까지 완본으로 보여 준다. - 모든 답변은 한글. 고친 파일은 일부 발췌가 아니라 처음부터 끝까지 완본으로 보여 준다.
@ -19,7 +30,7 @@ Android 다크모드 인맥 앱(Flutter/Dart). 옛 "Family MAP" + 카카오톡
## 이미 정한 기술 결정 (바꾸지 말 것) ## 이미 정한 기술 결정 (바꾸지 말 것)
- 모든 데이터는 기기에 암호화 저장(안드로이드 SQLCipher). 전화번호는 식별자일 뿐 키 재료가 아니다. - 모든 데이터는 기기에 암호화 저장(안드로이드 SQLCipher). 전화번호는 식별자일 뿐 키 재료가 아니다.
- 모든 기기가 서버 겸 클라이언트(P2P). 외부 DB(server/)는 암호화된 임시 우편함 + 기준 시각 제공뿐. - 모든 기기가 서버 겸 클라이언트(P2P). 외부 DB(server/)는 암호화된 임시 우편함 + 기준 시각 제공뿐.
- 12단어 복구 코드, Shamir 조각 기본 5/3(설정으로 조절), 사람 식별은 전화번호 1순위·이름 2순위. - (2026-10-26 사용자 결정으로 바뀜) 복구 코드·조각·보관 키 없음. 처음 시작은 이름·번호만, 키는 조용히 만든다. 폰을 잃으면 데이터는 끝. 새 폰에서 다시 시작하면 관계를 유지하는 사용자 각자가 승인(다시 연결) 또는 제명(그 번호 영구 차단). 탈퇴 후 재가입 없음. 사람 식별은 전화번호 1순위·이름 2순위.
- 대화는 영구 보관, 1MB/기기 사용률 80% 초과 시 별도 저장(조절 가능). 시각은 1/100초, 충돌 시 먼저 쓴 변경이 앞선다. 고친 명함은 "다시 보내기"에서만 보낸다. 그룹 대화 키는 돌리지 않는다. - 대화는 영구 보관, 1MB/기기 사용률 80% 초과 시 별도 저장(조절 가능). 시각은 1/100초, 충돌 시 먼저 쓴 변경이 앞선다. 고친 명함은 "다시 보내기"에서만 보낸다. 그룹 대화 키는 돌리지 않는다.
- 가족 모임 데이터(가계도, 생일, 기일, 통화 주제)는 기기 안에만 저장하고 어디로도 보내지 않는다. 전화번호 없는 친척은 `local:<id>` 번호의 기기 안 전용 사람이다. - 가족 모임 데이터(가계도, 생일, 기일, 통화 주제)는 기기 안에만 저장하고 어디로도 보내지 않는다. 전화번호 없는 친척은 `local:<id>` 번호의 기기 안 전용 사람이다.

135
README.md
View File

@ -1,133 +1,12 @@
# Friendly Map # Friendly Map
가족·친구·직장 관계를 한 앱에서. 새 친구가 "어디서 봤지?" 싶을 때 목적별 명함을 주고받아 관계를 유추한다. 가족·친구·직장 관계를 채팅에서 시작해 이어 가는 안드로이드 앱 (Flutter).
안드로이드 우선, Flutter. 모든 기기가 서버 겸 클라이언트(P2P)이고, 외부 DB는 암호화된 임시 우편함일 뿐이다.
## 원칙 (1순위) **모든 가이드·문서·결정·진행 기록은 [`wiki/`](wiki/홈.md) (Obsidian 보관함)에서 관리한다.**
1. 구조가 기능보다 먼저다. 화면과 내부를 분리한다. - 기능 안내: [wiki/가이드/기능 안내.md](wiki/가이드/기능%20안내.md)
2. 모듈은 언제든 빼고 끼울 수 있다. 기능을 더하거나 빼도 다른 모듈을 고치지 않는다. - 빌드·시험·저장소: [wiki/05 빌드 · 시험 · 저장소.md](wiki/05%20빌드%20·%20시험%20·%20저장소.md)
3. 파일은 300~400줄, 최대 700줄. - 결정 기록: [wiki/02 결정 기록.md](wiki/02%20결정%20기록.md)
4. 모듈별로 시험한다. 시험이 끝난 모듈은 다시 만들지 않고 그 위에 쌓는다. - 보관함 서버: [wiki/가이드/보관함 서버.md](wiki/가이드/보관함%20서버.md)
5. 한 번에 한 플랫폼. 지금은 안드로이드. 다른 플랫폼은 `platform_<이름>` 모듈만 새로 붙인다.
## 모듈과 의존 방향 (`tool/check_modules.py` 가 검사) Obsidian 에서 `wiki/` 폴더를 "보관함으로 열기" 하면 된다.
| 모듈 | 하는 일 | 쓸 수 있는 모듈 |
|---|---|---|
| core | 모듈끼리 주고받는 약속(키·전송·암호·봉투·명함 항목 이름) | 없음 |
| platform | 기기마다 다른 기능의 약속(보안 저장소, 저장 공간, 센서, QR, 발견, 위치, 통화 기록·전화, 알림, 주소록) | 없음 |
| platform_android | platform 의 안드로이드 구현(플러그인은 여기서만) | core, platform, storage |
| storage | 저장소 약속 + 메모리/SQLite 구현 | platform |
| keys | 12단어 복구 코드, 키 파생, 둘만 아는 키 암호, 복구 조각 나누기·합치기 | core, platform |
| sharing | 명함·대화방이 누구와 공유되는지 | storage |
| sync | 봉투 만들기·반영, 충돌 규칙, 시계 맞추기, 재전송 | core, storage |
| transport | 메모리·직접 연결·외부 보관함·늦게 만드는 연결 | core |
| discovery | QR 내용, 흔들기 감지, 같은 네트워크 주소록 | platform |
| inference | "어디서 봤지?" 유추와 추천 | core |
| visibility | 공개 범위 표(정보 × 관계)와 명함 거르기. 순수 계산 | core |
| layout | 홈·명함 틀(템플릿) 순서/보이기/편집 | core |
| kinship | 가계도로 촌수·호칭 계산(순수) | 없음 |
| events | 생일·기일 일정, 알림 날짜 계획, 통화 기록 요약(순수) | 없음 |
| groups | 그룹 정의(이름·종류·순서·구성원·하위 그룹), 비밀번호 잠금, 여러 번 틀리면 기다리기(순수) | core |
| work | 대화 속 업무 표시(📌 업무·🚨 긴급 호출) 읽고 쓰기, 맡은 업무 목록(순수) | 없음 |
| app | 서비스 묶음(`FriendlyApp`). 화면이 쓰는 유일한 내부 입구 | 위 모듈들(키·전송 구현 제외) |
| ui | 화면. `app` 의 서비스만 쓴다 | app, core, platform, storage, discovery, inference, layout, visibility, kinship, events, groups, work |
모듈 폴더 밖의 `lib/main.dart`, `lib/composition/` 이 부품을 고르는 유일한 곳이다.
암호 구현·저장소·연결 방식을 바꾸려면 `lib/composition/android_bootstrap.dart` 의 해당 줄만 바꾼다.
## 시험
```
tool/test_module.sh # 모듈 목록
tool/test_module.sh sync # 모듈 하나만
flutter test # 전체
python3 tool/check_modules.py # 줄 수·import 방향
```
## 시작
```
flutter create --platforms=android --org com.friendlymap --project-name friendly_map .
python3 tool/android_patch.py # 권한, 평문 통신, minSdk 23
flutter pub get
flutter analyze
flutter test
flutter run
```
보관함 서버(선택): `server/README.md`. 서버 주소를 설정 `mailbox_url` 에 넣으면 같은 네트워크 밖에서도 주고받는다.
## 알려진 한계
- 보관함의 우편함 토큰은 번호별 처음 쓴 기기에 묶인다(번호 선점 가능). 나중에 문자 인증으로 막는다.
- 그룹 대화방은 사람이 나가도 키를 바꾸지 않는다.
- 위치는 지도 타일 없이 레이더(내 위치 중심, 방위·거리)로 보여 준다. 지도 SDK는 붙이지 않았다.
- 문자 인증은 만들어 뒀지만 문자 발송 주소(`--sms-webhook`)를 정해 서버에 넣어야 켜진다. 켜기 전에는 처음 쓴 기기에 묶이는 방식(번호 선점 가능)이다.
## 가족 모임 (촌수 · 통화 요약 · 생일/기일 알림)
- 홈의 "가족 모임"에서 가족을 한 분씩 추가한다. 번호가 없는 친척도 넣을 수 있다(이 기기 안에서만 쓰이고 친구 요청은 가지 않는다).
- "나의 부모/자녀/배우자"를 고르거나 부모·자녀·배우자를 이어 가계도를 만들면, 나와 내 자녀 각각의 눈으로 본 호칭과 촌수가 나온다(예: 내게는 친사촌 형 4촌, 내 아들에게는 당숙 5촌).
- 사람을 누르면 관계, 통화 횟수·시간·마지막 통화·통화 잘 되는 시간대, 주제 메모, 다가오는 생일/기일을 요약하고 전화·문자·대화 버튼이 있다.
- 돌아가신 날을 적으면 맨 위에 "故" 띠가 뜨고(설정에서 끌 수 있다) 생일 대신 기일을 알린다.
- 설정 > "생일·기일 알림"에 `7, 2, 1, 0` 처럼 적으면 그 날짜 전 오전 9시에 알림이 울린다(기본 1일 전·당일).
- 음력: 생일·기일마다 "음력"을 켤 수 있다(1951~2049년 변환표 내장, 기일은 윤달도 지원). 제사 알림이 음력 날짜로 잡힌다.
- 한계: 통화 "내용"은 기기에 남지 않으므로 주제는 직접 적는다. 통화 기록 읽기(READ_CALL_LOG)는 Play 스토어 심사 제한이 있다.
- 안드로이드: `call_log`, `url_launcher`, `flutter_local_notifications`, `timezone` 플러그인과 권한·수신기·desugaring 설정이 필요하다(`tool/android_patch.py` 가 맞춘다).
## 복구 조각 (친구에게 맡기기)
- 설정 → 친구에게 맡기기: 12단어를 n조각으로 나눠(필요한 수 k) 친구마다 한 장씩 암호화해 보낸다. 친구 한 명의 조각으로는 아무것도 알 수 없다.
- 친구는 설정 → 내가 맡은 조각에서 조각 글자를 본다. 새 폰에서 복구할 때 필요한 수만큼의 조각 글자를 "복구 코드로 되살리기" 칸에 한 줄씩 붙여 넣는다.
- 조각 수나 필요한 수를 바꾸면 새로 나눠야 하고, 이전에 맡긴 조각은 새 조각과 섞이지 않는다.
## 공개 범위
- 내 정보 → 공개 범위 표(학교 · 회사·직책 · 생일 · 연락처 × 가족 · 회사 · 동문 · 그룹)에서 칸을 눌러 공개/일부/숨김을 정한다.
- 명함을 보내거나 "다시 보내기"를 할 때 받는 사람의 관계(연결된 관계 + 보내는 명함 종류)에 맞게 걸러서 보낸다. 관계를 모르는 사람에게는 내가 고른 그대로 간다.
- 표를 고쳐도 이미 보낸 명함은 "다시 보내기" 전까지 그대로다.
## 위치 공유
- 홈의 "위치" 구역에서 켠다. 기본은 꺼짐.
- 켜면 1분마다, 공개 범위 표의 "실시간 위치"가 공개·일부인 관계의 친구에게만 보낸다. 관계를 모르는 친구에게는 보내지 않는다. "일부"는 약 1km 단위로 뭉개서 보낸다.
- 위치는 둘만 아는 키로 잠가서 보내고, 받은 쪽에는 마지막 위치 하나만 남는다.
- 안드로이드: `geolocator` 플러그인과 위치 권한이 필요하다 (`tool/android_patch.py` 가 권한을 넣는다).
## 번호 확인 (문자 인증)
- 내 정보 → "내 번호 확인하기": 문자로 온 6자리를 넣으면 이 기기의 토큰이 내 번호에 묶인다. 폰을 바꾼 진짜 주인은 다시 확인하면 새 기기로 넘어오고, 이전 기기는 막힌다.
- 서버: `python3 mailbox_server.py --sms-webhook <주소> --require-verify`. 문자 발송은 `SmsSender` 하나(웹훅은 `{to,text}` 를 POST)라 업체를 바꿔도 서버 나머지는 그대로다.
- `--require-verify` 를 켜면 확인을 마친 기기만 우편함을 열 수 있다 (번호 선점 차단). 켜지 않으면 예전처럼 처음 쓴 기기에 묶인다.
- 5분 만료, 번호당 1시간 3번, 5번 오답이면 새로 요청. `--sms-log` 는 시험용(문자를 보내지 않고 기록)이라 실서비스에서 쓰면 안 된다.
- 서버 시험: `cd server && python3 -m unittest test_mailbox test_verify`
## 그룹 · 주소록 가져오기
- 설정(내 정보 → 저장·보안) → "그룹 · 주소록". 처음 그룹은 가족·학교·회사·그룹·여행이고, 더하기·이름과 종류 고치기·순서 바꾸기·지우기가 된다(지워도 사람은 인맥에 남는다).
- 그룹 아래에 하위 그룹을 4단까지 만든다. 처음에는 학교 › 초등학교·중학교·고등학교·대학교·대학원, 가족 › 친가·외가·처가·시가·친척(촌수 모름)이 있고, 회사 › 회사 이름 › 프로젝트·팀은 직접 만든다. 같은 부모 아래에서만 이름이 겹치지 않으면 된다. 지우면 하위 그룹도 함께 지워진다(사람은 남는다).
- 그룹마다 비밀번호(4~16자)를 걸 수 있다. 잠긴 그룹은 열 때 비밀번호를 묻고, 닫혀 있으면 하위 그룹과 인맥 목록에서도 그 사람들을 숨긴다. 연 뒤 정한 시간(기본 5분, 1~60분)이 지나거나 앱을 내리면 다시 잠근다.
- 5번 틀리면 30초, 그 뒤로 틀릴 때마다 두 배(최대 1시간) 기다려야 한다. 비밀번호는 PBKDF2-SHA256 값만 남긴다. 잊으면 그 그룹은 지우고 다시 만들어야 한다.
- "주소록에서 가져오기": 이름·초성(ㄱㅁㅅ)·번호로 찾고, 휴대폰 주소록 그룹(라벨)으로 거르고, 이미 있는 사람은 빼고 볼 수 있다. 한 명씩 또는 보이는 사람을 모두 고른 뒤, 넣을 그룹(여러 개)을 골라 가져온다.
- 번호가 여럿이면 휴대폰(010) 번호를 먼저 쓴다. 가져온 사람은 이 기기 인맥에만 들어가고, "친구 요청도 보내기"를 켰을 때만 상대에게 요청이 간다. 주소록은 읽기만 한다.
- 그룹은 이 기기에만 저장한다. 안드로이드: `flutter_contacts` 플러그인과 READ_CONTACTS 권한이 필요하다(`tool/android_patch.py` 가 넣는다).
## 관계 정하기 (채팅에서 시작하는 관계망)
- 대화방 위의 관계 아이콘, 또는 사람 화면의 "관계 정하기"에서 고른다: 가족·친척 / 학교 동문 / 회사·업무·팀 / 그룹·모임·여행.
- 가족: 나(또는 가계도에 있는 다른 가족)의 부모·자녀·배우자·형제자매를 고르면 가계도에 잇고 촌수·호칭을 계산한다. 아버지 쪽은 친가, 어머니 쪽은 외가, 배우자 쪽은 처가·시가 그룹에 들어간다. 모르면 "친척(촌수 모름)"에 모아 두고, 나중에 이으면 모음에서 빠진다.
- 형제자매를 고를 때 기준 사람의 부모가 가계도에 없으면 "부모(이름 모름)"를 이 기기 안에서만 쓰는 사람으로 만들어 잇는다.
- 학교·회사·모임: 그 종류의 그룹 나무에서 골라 넣거나, + 로 그 아래 새 그룹(예: 학교 › 고등학교 › 가람고 27회, 회사 › 한빛전자 › 결제팀)을 만들어 바로 넣는다.
- 넣으면 연결 고리(가족·동문·직장 동료·같은 모임)가 하나 남아, 공개 범위 표가 그 관계로 적용된다. 같은 종류의 그룹에서 모두 빠지면 연결 고리도 지운다.
## 업무 · 팀 · 긴급 호출
- 회사 종류 그룹(예: 회사 › 한빛전자 › 결제팀) 안의 "팀 대화방"을 누르면 구성원과 회사 맥락의 대화방이 생긴다. 회사 명함을 아직 공유하지 않은 사람이 있으면 그 이름을 알려 주고 만들지 않는다.
- 대화방 위 업무 메뉴: "📌 업무 맡기기"(할 일·담당·마감), "🚨 긴급 호출", "이 방 업무 보기". 채팅 목록 위 체크 아이콘은 모든 방의 "맡은 업무".
- 업무와 긴급 호출은 보통 대화로 간다(`📌 업무 [김민수] 결제 API 정리 (마감 10/30)`, `🚨 긴급 호출: …`). 받는 쪽 앱이 읽어서 업무 목록에 넣고(담당 이름이 내 이름이면 "내 업무"), 긴급 호출은 바로 알림을 띄운다. 새 봉투 형식은 없다.
- 지난 대화를 길게 누르면 "업무로 정리"(내 목록에만). 업무 목록은 이 기기에만 저장하고, 끝냄 표시·밀어서 지우기가 된다.
- 안드로이드: 긴급 호출 알림은 `flutter_local_notifications` 의 높은 중요도 채널(urgent)을 쓴다.
## 이직 알림
- 상대가 회사 명함을 고쳐 "다시 보내기" 하면, 이 기기가 알던 회사와 비교해 바뀌었으면 알림이 생긴다(채팅 목록 맨 위, 사람 화면, 그룹 관리). 예: "김민수님이 한빛전자에서 누리소프트로 옮겼어요", "지금은 다니지 않음"이면 "…를 떠났어요".
- "확인 · 옮기기": 이전 회사 이름이 경로에 든 회사 그룹(예: 회사 › 한빛전자 › 결제팀)에서는 이전 구성원으로 흐리게 남기고, "회사 › 새 회사" 그룹(없으면 만든다)에 넣는다. 연결 고리는 '직장 동료'가 새 회사로 바뀌고 '전 직장 동료'(이전 경로)가 남는다.
- "이전 회사에서 지우기": 흐리게 남기지 않고 뺀다. "무시": 알림만 지운다. 이전 구성원은 그룹 안에서 ✕ 로 지울 수 있다.
- 처음 받는 회사 명함, 앱을 새로 연 뒤의 첫 비교는 알림을 만들지 않는다(알던 회사로만 기억). 알던 회사·알림은 이 기기에만 저장한다.

View File

@ -5,12 +5,14 @@
library; library;
export 'src/app_deps.dart'; export 'src/app_deps.dart';
export 'src/block_service.dart';
export 'src/call_service.dart'; export 'src/call_service.dart';
export 'src/card_service.dart'; export 'src/card_service.dart';
export 'src/career_service.dart'; export 'src/career_service.dart';
export 'src/change_bus.dart'; export 'src/change_bus.dart';
export 'src/chat_service.dart'; export 'src/chat_service.dart';
export 'src/contact_import_service.dart'; export 'src/contact_import_service.dart';
export 'src/display.dart';
export 'src/event_service.dart'; export 'src/event_service.dart';
export 'src/facts_service.dart'; export 'src/facts_service.dart';
export 'src/fields.dart'; export 'src/fields.dart';

View File

@ -0,0 +1,68 @@
import 'dart:convert';
import 'package:friendly_map/modules/storage/storage.dart';
import 'change_bus.dart';
import 'friend_service.dart';
/// 받은 요청 한 건과, 이미 아는 사람이면 그 사람 (= 새 기기에서 다시 연결을 청함).
class IncomingRequest {
const IncomingRequest(this.request, this.known);
final FriendRequest request;
final Person? known;
/// 이미 관계가 있던 사람이 새 기기(새 키)로 다시 연결을 청함.
bool get reconnect => known != null;
}
/// 다시 연결 승인과 영구 제명.
///
/// 복구 코드·보관 키는 없다. 폰을 새로 시작한 사람은 관계를 유지하고 있는 사용자 각자의 승인을 받아야 다시 이어진다.
/// 제명하면 그 번호의 인사·요청은 이 기기에서 영원히 받지 않는다 (탈퇴 후 재가입 없음).
class BlockService {
BlockService(this._storage, this._clock, this._friends, this._bus);
static const _key = 'blocked.phones';
final Storage _storage;
final Clock _clock;
final FriendService _friends;
final ChangeBus _bus;
Future<Set<String>> blocked() async {
try {
return {...(jsonDecode(await _storage.settings.getText(_key) ?? '[]') as List).whereType<String>()};
} catch (_) {
return {};
}
}
Future<bool> isBlocked(String phone) async => (await blocked()).contains(phone);
/// 받은 요청들 (다시 연결 요청이 위로).
Future<List<IncomingRequest>> incoming() async {
final out = <IncomingRequest>[];
for (final r in await _friends.requests()) {
out.add(IncomingRequest(r, await _storage.people.byPhone(r.phone)));
}
out.sort((a, b) => (a.reconnect == b.reconnect) ? b.request.receivedAt.compareTo(a.request.receivedAt) : (a.reconnect ? -1 : 1));
return out;
}
/// 승인: 새 키로 다시 잇는다 (이전 대화·그룹·명함 기록은 그대로).
Future<Person?> approve(String phone) => _friends.accept(phone);
/// 영구 제명: 요청을 거절하고 이 번호를 막는다.
Future<void> expel(String phone) async {
final set = await blocked()..add(phone);
await _storage.settings.putText(_key, jsonEncode(set.toList()..sort()), _clock.now());
await _friends.decline(phone);
_bus.notify();
}
/// 제명 풀기 (나 내 정보 > 제명한 번호에서).
Future<void> unblock(String phone) async {
final set = await blocked()..remove(phone);
await _storage.settings.putText(_key, jsonEncode(set.toList()..sort()), _clock.now());
_bus.notify();
}
}

View File

@ -0,0 +1,64 @@
import 'package:friendly_map/modules/work/work.dart';
import 'facts_service.dart' show isLocalPhone;
/// 화면에 보여 줄 전화번호. "+821033334444" → "010-3333-4444", "+8225551234" → "02-555-1234".
/// 한국 번호가 아니면 그대로, 기기 안 전용 사람(local:)은 빈 글자.
String displayPhone(String phone) {
if (isLocalPhone(phone)) return '';
if (!phone.startsWith('+82')) return phone;
final d = '0${phone.substring(3)}';
if (d.startsWith('02')) {
final rest = d.substring(2);
if (rest.length == 7) return '02-${rest.substring(0, 3)}-${rest.substring(3)}';
if (rest.length == 8) return '02-${rest.substring(0, 4)}-${rest.substring(4)}';
return d;
}
if (d.length == 11) return '${d.substring(0, 3)}-${d.substring(3, 7)}-${d.substring(7)}';
if (d.length == 10) return '${d.substring(0, 3)}-${d.substring(3, 6)}-${d.substring(6)}';
return d;
}
String _two(int n) => n.toString().padLeft(2, '0');
/// "오후 3:05"
String clockText(DateTime t) {
final am = t.hour < 12;
final h = t.hour % 12 == 0 ? 12 : t.hour % 12;
return '${am ? '오전' : '오후'} $h:${_two(t.minute)}';
}
/// 목록용 시각 (1/100초 stamp): 오늘이면 "오후 3:05", 어제면 "어제", 올해면 "10월 24일", 아니면 "2025. 3. 1."
String listTime(int stamp, {DateTime? now}) {
final t = DateTime.fromMillisecondsSinceEpoch(stamp * 10);
final n = now ?? DateTime.now();
final today = DateTime(n.year, n.month, n.day);
final day = DateTime(t.year, t.month, t.day);
final diff = today.difference(day).inDays;
if (diff == 0) return clockText(t);
if (diff == 1) return '어제';
if (t.year == n.year) return '${t.month}월 ${t.day}일';
return '${t.year}. ${t.month}. ${t.day}.';
}
/// 대화방 날짜 구분선: "2026년 10월 26일 월요일"
String dayLabel(DateTime t) {
const w = ['월', '화', '수', '목', '금', '토', '일'];
return '${t.year}년 ${t.month}월 ${t.day}일 ${w[t.weekday - 1]}요일';
}
/// 마감까지: "오늘", "D-3", "2일 지남"
String dueText(DateTime due, {DateTime? now}) {
final n = now ?? DateTime.now();
final left = DateTime(due.year, due.month, due.day).difference(DateTime(n.year, n.month, n.day)).inDays;
if (left == 0) return '오늘 마감';
if (left < 0) return '${-left}일 지남';
return 'D-$left';
}
/// 채팅 목록 미리보기: 업무·긴급 표시는 사람이 읽는 말로.
String previewOf(String body) => switch (WorkNote.parse(body)) {
TaskNote(:final title, :final assignee) => '업무 · $title${assignee == null ? '' : ' (담당 $assignee)'}',
UrgentNote(:final text) => '긴급 호출 · ${text.isEmpty ? '확인이 필요해요' : text}',
null => body.replaceAll('\n', ' '),
};

View File

@ -4,6 +4,7 @@ import 'package:friendly_map/modules/sharing/sharing.dart';
import 'package:friendly_map/modules/sync/sync.dart'; import 'package:friendly_map/modules/sync/sync.dart';
import 'app_deps.dart'; import 'app_deps.dart';
import 'block_service.dart';
import 'card_service.dart'; import 'card_service.dart';
import 'career_service.dart'; import 'career_service.dart';
import 'change_bus.dart'; import 'change_bus.dart';
@ -64,6 +65,7 @@ class FriendlyApp {
relate = RelateService(groups, kin, network, bus); relate = RelateService(groups, kin, network, bus);
work = WorkService(storage, clock, share, chats, groups, bus, deps.instant); work = WorkService(storage, clock, share, chats, groups, bus, deps.instant);
career = CareerService(storage, clock, groups, relate, network, bus); career = CareerService(storage, clock, groups, relate, network, bus);
blocks = BlockService(storage, clock, friends, bus);
} }
final AppDeps deps; final AppDeps deps;
@ -89,6 +91,7 @@ class FriendlyApp {
late final RelateService relate; late final RelateService relate;
late final WorkService work; late final WorkService work;
late final CareerService career; late final CareerService career;
late final BlockService blocks;
StreamSubscription<SyncEvent>? _events; StreamSubscription<SyncEvent>? _events;
final Set<Future<void>> _tasks = {}; final Set<Future<void>> _tasks = {};
@ -100,7 +103,10 @@ class FriendlyApp {
Future<void> start() async { Future<void> start() async {
_events ??= sync.events.listen((e) { _events ??= sync.events.listen((e) {
if (e.type == SyncEventType.hello) { if (e.type == SyncEventType.hello) {
final f = friends.onHello(e); // 제명한 번호의 인사는 받지 않는다.
final f = blocks.isBlocked(e.phone ?? '').then((no) async {
if (!no) await friends.onHello(e);
});
_tasks.add(f); _tasks.add(f);
unawaited(f.whenComplete(() => _tasks.remove(f))); unawaited(f.whenComplete(() => _tasks.remove(f)));
} else { } else {

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart'; import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/core/core.dart'; import 'package:friendly_map/modules/core/core.dart';
import 'package:friendly_map/modules/storage/storage.dart'; import 'package:friendly_map/modules/storage/storage.dart';
import 'package:friendly_map/modules/work/work.dart';
import '../app_scope.dart'; import '../app_scope.dart';
import '../theme.dart'; import '../theme.dart';
@ -10,66 +11,134 @@ import '../widgets/career_panel.dart';
import '../widgets/common.dart'; import '../widgets/common.dart';
import 'add_friend_screen.dart'; import 'add_friend_screen.dart';
import 'chat_room_screen.dart'; import 'chat_room_screen.dart';
import 'tasks_screen.dart'; import 'contact_import_screen.dart';
import 'requests_screen.dart';
/// 채팅 목록. class _Data {
class ChatListScreen extends StatelessWidget { const _Data(this.rooms, this.requests, this.reconnects);
final List<RoomSummary> rooms;
final int requests;
final int reconnects;
}
/// 채팅 (처음 화면): 받은 요청·이직 알림 띠, 대화방 목록, 새 대화.
class ChatListScreen extends StatefulWidget {
const ChatListScreen({super.key}); const ChatListScreen({super.key});
@override @override
Widget build(BuildContext context) { State<ChatListScreen> createState() => _ChatListScreenState();
return Scaffold( }
appBar: AppBar(
title: const Text('채팅', style: TextStyle(fontWeight: FontWeight.w700)), class _ChatListScreenState extends State<ChatListScreen> {
actions: [ String? _query; // null 이면 찾기 칸을 닫음
IconButton(tooltip: '맡은 업무', onPressed: () => pushPage(context, const TasksScreen()), icon: const Icon(Icons.checklist)),
IconButton( Future<_Data> _load(FriendlyApp app) async {
tooltip: '새 친구', final inc = await app.blocks.incoming();
onPressed: () => pushPage(context, const AddFriendScreen()), return _Data(await app.chats.summaries(), inc.length, inc.where((r) => r.reconnect).length);
icon: const Icon(Icons.person_add_alt_1),
),
],
),
floatingActionButton: FloatingActionButton(
heroTag: null,
tooltip: '새 대화',
onPressed: () => _newChat(context),
child: const Icon(Icons.chat_bubble_outline),
),
body: AsyncView<List<RoomSummary>>(
load: (app) => app.chats.summaries(),
builder: (context, rooms) {
return ListView(
padding: const EdgeInsets.only(top: 8, bottom: 90),
children: [
const CareerNoticesPanel(),
if (rooms.isEmpty) const EmptyNote('아직 대화가 없어요.\n오른쪽 아래 버튼으로 친구와 대화를 시작해 보세요.'),
for (final r in rooms) _RoomTile(r),
],
);
},
),
);
} }
Future<void> _newChat(BuildContext context) async { Future<void> _newChat(BuildContext context) async {
final app = AppScope.of(context).app; final app = AppScope.of(context).app;
final people = await app.friends.people(); final hidden = await app.groups.hiddenPeople();
final people = [for (final p in await app.friends.people()) if (!hidden.contains(p.id) && !isLocalPhone(p.phone)) p];
if (!context.mounted) return; if (!context.mounted) return;
if (people.isEmpty) { if (people.isEmpty) {
showNote(context, '먼저 친구를 추가해 주세요.');
pushPage(context, const AddFriendScreen()); pushPage(context, const AddFriendScreen());
return; return;
} }
await showModalBottomSheet<void>( await showModalBottomSheet<void>(context: context, isScrollControlled: true, builder: (_) => _NewChatSheet(people));
context: context, }
showDragHandle: true,
isScrollControlled: true, @override
builder: (_) => _NewChatSheet(people), Widget build(BuildContext context) {
final q = _query;
return Scaffold(
appBar: AppBar(
title: q == null
? const Text('채팅')
: TextField(
autofocus: true,
decoration: const InputDecoration(hintText: '대화방 이름·내용 찾기', isDense: true, prefixIcon: Icon(Icons.search)),
onChanged: (v) => setState(() => _query = v),
),
actions: [
IconButton(
tooltip: q == null ? '대화 찾기' : '찾기 닫기',
onPressed: () => setState(() => _query = q == null ? '' : null),
icon: Icon(q == null ? Icons.search : Icons.close),
),
],
),
floatingActionButton: FloatingActionButton.extended(
heroTag: null,
onPressed: () => _newChat(context),
icon: const Icon(Icons.edit_outlined),
label: const Text('새 대화'),
),
body: AsyncView<_Data>(
load: _load,
builder: (context, d) {
final t = (q ?? '').trim();
final rooms = t.isEmpty
? d.rooms
: [for (final r in d.rooms) if (r.room.title.contains(t) || (r.last?.body.contains(t) ?? false)) r];
return ListView(padding: const EdgeInsets.only(top: 4, bottom: 96), children: [
if (d.requests > 0) _RequestStrip(d.requests, d.reconnects),
const CareerNoticesPanel(),
if (d.rooms.isEmpty) const _Empty(),
if (d.rooms.isNotEmpty && rooms.isEmpty) const EmptyNote('맞는 대화가 없어요.'),
for (final r in rooms) _RoomTile(r),
]);
},
),
); );
} }
} }
class _RequestStrip extends StatelessWidget {
const _RequestStrip(this.count, this.reconnects);
final int count;
final int reconnects;
@override
Widget build(BuildContext context) => Panel(
onTap: () => pushPage(context, const RequestsScreen()),
child: Row(children: [
Icon(reconnects > 0 ? Icons.phonelink_lock : Icons.person_add_alt_1, color: Pal.accent),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(reconnects > 0 ? '새 기기로 다시 연결 요청 $reconnects건' : '받은 친구 요청 $count건', style: const TextStyle(fontWeight: FontWeight.w700)),
Text(reconnects > 0 ? '승인하면 이어지고, 제명하면 영구히 막혀요' : '눌러서 수락하거나 거절해요', style: const TextStyle(fontSize: 12, color: Pal.muted)),
]),
),
const Icon(Icons.chevron_right, color: Pal.muted),
]),
);
}
class _Empty extends StatelessWidget {
const _Empty();
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(32, 72, 32, 0),
child: Column(children: [
const Icon(Icons.forum_outlined, size: 56, color: Pal.muted),
const SizedBox(height: 16),
const Text('아직 대화가 없어요', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
const Text('친구를 추가하거나 주소록에서 불러와 첫 대화를 시작해 보세요.', textAlign: TextAlign.center, style: TextStyle(color: Pal.muted, height: 1.5)),
const SizedBox(height: 20),
Row(children: [
Expanded(child: OutlinedButton(onPressed: () => pushPage(context, const ContactImportScreen()), child: const Text('주소록에서'))),
const SizedBox(width: 8),
Expanded(child: FilledButton(onPressed: () => pushPage(context, const AddFriendScreen()), child: const Text('친구 추가'))),
]),
]),
);
}
class _RoomTile extends StatelessWidget { class _RoomTile extends StatelessWidget {
const _RoomTile(this.s); const _RoomTile(this.s);
@ -78,32 +147,34 @@ class _RoomTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final r = s.room; final r = s.room;
return Panel( final body = s.last?.body;
final urgent = body != null && WorkNote.parse(body) is UrgentNote;
return ListTile(
minTileHeight: 72,
onTap: () => pushPage(context, ChatRoomScreen(roomId: r.id)), onTap: () => pushPage(context, ChatRoomScreen(roomId: r.id)),
child: Row(children: [ leading: r.kind == 'group'
Avatar(r.title, color: Pal.ofKind(r.contextKind)), ? CircleAvatar(radius: 22, backgroundColor: Pal.ofKind(r.contextKind).withValues(alpha: 0.2), child: Icon(Icons.groups, color: Pal.ofKind(r.contextKind)))
const SizedBox(width: 12), : Avatar(r.title, color: Pal.ofKind(r.contextKind)),
Expanded( title: Row(children: [
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Flexible(child: Text(r.title, maxLines: 1, overflow: TextOverflow.ellipsis)),
Row(children: [ if (r.contextKind != CardKinds.personal) ...[
Flexible(child: Text(r.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700))), const SizedBox(width: 6),
if (r.contextKind != CardKinds.personal) ...[ KindChip(r.contextKind, CardKinds.label(r.contextKind)),
const SizedBox(width: 8), ],
KindChip(r.contextKind, CardKinds.label(r.contextKind)),
],
]),
const SizedBox(height: 3),
Text(s.last?.body ?? '새 대화방', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, color: Pal.muted)),
]),
),
const SizedBox(width: 8),
Text(shortTime(s.last?.sentAt ?? r.updatedAt), style: const TextStyle(fontSize: 11, color: Pal.muted)),
]), ]),
subtitle: Row(children: [
if (urgent) const Padding(padding: EdgeInsets.only(right: 4), child: Icon(Icons.campaign, size: 15, color: Pal.urgent)),
Expanded(
child: Text(body == null ? '대화를 시작해 보세요' : previewOf(body),
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: urgent ? Pal.urgent : Pal.muted)),
),
]),
trailing: Text(listTime(s.last?.sentAt ?? r.updatedAt), style: const TextStyle(fontSize: 12, color: Pal.muted)),
); );
} }
} }
/// 새 대화: 친구와 맥락(어떤 명함으로 하는 대화인지)을 고른다. /// 새 대화: 사람과 맥락(어떤 명함으로 하는 대화인지)을 고른다.
class _NewChatSheet extends StatefulWidget { class _NewChatSheet extends StatefulWidget {
const _NewChatSheet(this.people); const _NewChatSheet(this.people);
@ -115,6 +186,7 @@ class _NewChatSheet extends StatefulWidget {
class _NewChatSheetState extends State<_NewChatSheet> { class _NewChatSheetState extends State<_NewChatSheet> {
String _context = CardKinds.personal; String _context = CardKinds.personal;
String _q = '';
Future<void> _open(Person p) async { Future<void> _open(Person p) async {
final nav = Navigator.of(context); final nav = Navigator.of(context);
@ -122,53 +194,41 @@ class _NewChatSheetState extends State<_NewChatSheet> {
final room = await app.chats.openDirect(p.id, _context); final room = await app.chats.openDirect(p.id, _context);
if (!mounted) return; if (!mounted) return;
if (room == null) { if (room == null) {
showNote(context, '${CardKinds.label(_context)} 대화는 ${CardKinds.label(_context)} 명함을 먼저 서로 공유해야 해요.'); showNote(context, '${CardKinds.label(_context)} 대화는 ${p.name}님께 ${CardKinds.label(_context)} 명함을 먼저 보내야 열려요.');
return; return;
} }
nav.pop(); nav.pop();
if (!nav.mounted) return;
nav.push(MaterialPageRoute<void>(builder: (_) => ChatRoomScreen(roomId: room.id))); nav.push(MaterialPageRoute<void>(builder: (_) => ChatRoomScreen(roomId: room.id)));
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final shown = [for (final p in widget.people) if (_q.isEmpty || p.name.contains(_q) || p.phone.contains(_q)) p];
return SafeArea( return SafeArea(
child: SizedBox( child: SizedBox(
height: MediaQuery.of(context).size.height * 0.6, height: MediaQuery.of(context).size.height * 0.75,
child: Column(children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Padding(padding: EdgeInsets.fromLTRB(20, 0, 20, 12), child: Text('새 대화', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700))),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Wrap(spacing: 6, runSpacing: 6, children: [
for (final k in CardKinds.all)
ChoiceChip(label: Text(k == CardKinds.personal ? '일반' : CardKinds.label(k)), selected: _context == k, onSelected: (_) => setState(() => _context = k)),
]),
),
const Padding( const Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, 8), padding: EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Align(alignment: Alignment.centerLeft, child: Text('어떤 대화인가요?', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700))), child: Text('회사·학교·가족 대화는 그 명함을 보낸 사람과만 열려요.', style: TextStyle(fontSize: 12, color: Pal.muted)),
), ),
SizedBox( Padding(
height: 48, padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListView( child: TextField(decoration: const InputDecoration(hintText: '이름으로 찾기', prefixIcon: Icon(Icons.search), isDense: true), onChanged: (v) => setState(() => _q = v.trim())),
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
for (final k in CardKinds.all)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(CardKinds.label(k)),
selected: _context == k,
onSelected: (_) => setState(() => _context = k),
),
),
],
),
), ),
const Divider(), const SizedBox(height: 4),
Expanded( Expanded(
child: ListView(children: [ child: ListView(children: [
for (final p in widget.people) for (final p in shown)
ListTile( ListTile(leading: Avatar(p.name, size: 40), title: Text(p.name), subtitle: Text(displayPhone(p.phone)), onTap: () => _open(p)),
minTileHeight: 56,
leading: Avatar(p.name, size: 40),
title: Text(p.name),
subtitle: Text(p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)),
onTap: () => _open(p),
),
]), ]),
), ),
]), ]),

View File

@ -10,21 +10,26 @@ import '../app_scope.dart';
import '../theme.dart'; import '../theme.dart';
import '../widgets/async_view.dart'; import '../widgets/async_view.dart';
import '../widgets/common.dart'; import '../widgets/common.dart';
import '../widgets/message_bubble.dart';
import '../widgets/relate_sheet.dart'; import '../widgets/relate_sheet.dart';
import '../widgets/work_dialogs.dart'; import '../widgets/work_dialogs.dart';
import 'archive_screen.dart'; import 'archive_screen.dart';
import 'person_screen.dart';
import 'tasks_screen.dart'; import 'tasks_screen.dart';
enum _Work { assign, urgent, tasks } enum _Menu { relate, person, tasks, archive }
class _RoomData { class _RoomData {
const _RoomData(this.room, this.messages, this.advice); const _RoomData(this.room, this.messages, this.advice, this.names, this.taskMessages, this.openTasks);
final ChatRoom? room; final ChatRoom? room;
final List<Message> messages; final List<Message> messages;
final ArchiveAdvice advice; final ArchiveAdvice advice;
final Map<String, String> names; // 사람 id → 이름
final Set<String> taskMessages; // 업무 목록에 들어간 대화 id
final int openTasks;
} }
/// 대화방. 맥락(개인·회사 …)을 이름표로 보여 주고, 별도 저장이 필요하면 알려 준다. /// 대화방: 날짜 구분, 말풍선·업무 카드·긴급 호출 띠, + 버튼(업무 맡기기·긴급 호출), ⋮ 메뉴(관계 정하기·업무 보기).
class ChatRoomScreen extends StatefulWidget { class ChatRoomScreen extends StatefulWidget {
const ChatRoomScreen({super.key, required this.roomId}); const ChatRoomScreen({super.key, required this.roomId});
@ -43,6 +48,28 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
super.dispose(); super.dispose();
} }
Future<_RoomData> _load(FriendlyApp app) async {
final room = await app.deps.storage.rooms.get(widget.roomId);
final names = <String, String>{};
for (final p in await app.work.roomPeople(widget.roomId)) {
names[p.id] = p.name;
}
final tasks = await app.work.tasks(roomId: widget.roomId, includeDone: true);
return _RoomData(
room,
await app.chats.messages(widget.roomId, limit: 200),
await app.settings.adviseRoom(widget.roomId),
names,
{for (final t in tasks) if (t.messageId != null) t.messageId!},
tasks.where((t) => !t.done).length,
);
}
String? _peer(ChatRoom room) {
final ids = (jsonDecode(room.membersJson) as List).whereType<String>().toList();
return room.kind == 'direct' && ids.isNotEmpty ? ids.first : null;
}
Future<void> _send() async { Future<void> _send() async {
final text = _input.text; final text = _input.text;
if (text.trim().isEmpty) return; if (text.trim().isEmpty) return;
@ -50,52 +77,90 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
await AppScope.of(context).app.chats.send(widget.roomId, text); await AppScope.of(context).app.chats.send(widget.roomId, text);
} }
Future<void> _work(_Work w, ChatRoom room) async { Future<void> _plus(ChatRoom room) async {
final app = AppScope.of(context).app; final app = AppScope.of(context).app;
switch (w) { final pick = await showModalBottomSheet<String>(
case _Work.assign: context: context,
final people = await app.work.roomPeople(room.id); builder: (c) => SafeArea(
if (!mounted) return; child: Column(mainAxisSize: MainAxisSize.min, children: [
final r = await askAssign(context, people); ListTile(leading: const Icon(Icons.task_alt, color: Pal.accent), title: const Text('업무 맡기기'), subtitle: const Text('할 일·담당·마감을 정해 보내요'), onTap: () => Navigator.pop(c, 'task')),
if (r == null) return; ListTile(leading: const Icon(Icons.campaign, color: Pal.urgent), title: const Text('긴급 호출'), subtitle: const Text('상대 폰에 바로 알림이 떠요'), onTap: () => Navigator.pop(c, 'urgent')),
await app.work.assign(room.id, r.title, assigneeId: r.assigneeId, due: r.due); const SizedBox(height: 8),
case _Work.urgent: ]),
final text = await askUrgent(context); ),
if (text == null) return; );
await app.work.urgent(room.id, text); if (!mounted || pick == null) return;
case _Work.tasks: if (pick == 'task') {
pushPage(context, TasksScreen(roomId: room.id, title: '${room.title} 업무')); final people = await app.work.roomPeople(room.id);
if (!mounted) return;
final r = await askAssign(context, people);
if (r != null) await app.work.assign(room.id, r.title, assigneeId: r.assigneeId, due: r.due);
} else {
final text = await askUrgent(context);
if (text != null) await app.work.urgent(room.id, text);
} }
} }
Future<void> _toTask(Message m) async { Future<void> _toTask(Message m) async {
final app = AppScope.of(context).app; final app = AppScope.of(context).app;
final ok = await showModalBottomSheet<bool>( final mineTask = await showModalBottomSheet<bool>(
context: context, context: context,
showDragHandle: true,
builder: (c) => SafeArea( builder: (c) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Text(m.body, maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Pal.muted)), child: Text(previewOf(m.body), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Pal.muted)),
), ),
ListTile(leading: const Icon(Icons.push_pin_outlined), title: const Text('업무로 정리 (내가 할 일)'), onTap: () => Navigator.pop(c, true)), ListTile(leading: const Icon(Icons.person_outline), title: const Text('내 업무로 정리'), onTap: () => Navigator.pop(c, true)),
ListTile(leading: const Icon(Icons.inbox_outlined), title: const Text('업무로 정리 (담당 없음)'), onTap: () => Navigator.pop(c, false)), ListTile(leading: const Icon(Icons.inbox_outlined), title: const Text('업무로 정리 (담당 없음)'), onTap: () => Navigator.pop(c, false)),
const SizedBox(height: 8),
]), ]),
), ),
); );
if (ok == null) return; if (mineTask == null) return;
final t = await app.work.fromMessage(m.id, assigneeId: ok ? Task.me : null); final t = await app.work.fromMessage(m.id, assigneeId: mineTask ? Task.me : null);
if (mounted) showNote(context, t == null ? '이미 업무로 정리한 대화예요' : '업무 목록에 넣었어요'); if (mounted) showNote(context, t == null ? '이미 업무로 정리한 대화예요' : '업무 목록에 넣었어요');
} }
Future<_RoomData> _load(FriendlyApp app) async { Future<void> _menu(_Menu w, ChatRoom room) async {
final rooms = await app.chats.summaries(); final peer = _peer(room);
ChatRoom? room; switch (w) {
for (final s in rooms) { case _Menu.relate:
if (s.room.id == widget.roomId) room = s.room; if (peer != null) await showRelateSheet(context, peer, room.title);
case _Menu.person:
if (peer != null) pushPage(context, PersonScreen(personId: peer));
case _Menu.tasks:
pushPage(context, TasksScreen(roomId: room.id, title: '${room.title} 업무'));
case _Menu.archive:
pushPage(context, ArchiveScreen(roomId: room.id));
} }
return _RoomData(room, await app.chats.messages(widget.roomId, limit: 200), await app.settings.adviseRoom(widget.roomId)); }
List<Widget> _items(_RoomData d, ChatRoom? room) {
final out = <Widget>[];
final group = room?.kind == 'group';
DateTime? lastDay;
for (var i = 0; i < d.messages.length; i++) {
final m = d.messages[i];
final t = DateTime.fromMillisecondsSinceEpoch(m.sentAt * 10);
final day = DateTime(t.year, t.month, t.day);
if (lastDay != day) out.add(DaySeparator(day));
lastDay = day;
final next = i + 1 < d.messages.length ? d.messages[i + 1] : null;
// 같은 사람이 같은 분에 이어 보낸 대화는 마지막 것에만 시각을 붙인다.
final sameMinute = next != null && next.senderId == m.senderId && next.sentAt ~/ 6000 == m.sentAt ~/ 6000;
final prev = i > 0 ? d.messages[i - 1] : null;
out.add(MessageBubble(
m,
sender: group ? d.names[m.senderId] ?? '알 수 없음' : room?.title,
showSender: group && (prev == null || prev.senderId != m.senderId),
showTime: !sameMinute,
inTasks: d.taskMessages.contains(m.id),
onLongPress: () => _toTask(m),
onAddTask: () => AppScope.of(context).app.work.fromMessage(m.id, assigneeId: WorkService.noteOf(m.body) is TaskNote ? null : Task.me),
));
}
return out;
} }
@override @override
@ -104,8 +169,10 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
load: _load, load: _load,
builder: (context, d) { builder: (context, d) {
final room = d.room; final room = d.room;
final items = _items(d, room).reversed.toList();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
titleSpacing: 0,
title: Row(children: [ title: Row(children: [
Flexible(child: Text(room?.title ?? '대화', overflow: TextOverflow.ellipsis)), Flexible(child: Text(room?.title ?? '대화', overflow: TextOverflow.ellipsis)),
if (room != null && room.contextKind != CardKinds.personal) ...[ if (room != null && room.contextKind != CardKinds.personal) ...[
@ -114,24 +181,17 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
], ],
]), ]),
actions: [ actions: [
if (room != null && room.kind == 'direct') if (room != null && d.openTasks > 0)
IconButton( TextButton.icon(onPressed: () => _menu(_Menu.tasks, room), icon: const Icon(Icons.task_alt, size: 18), label: Text('업무 ${d.openTasks}')),
tooltip: '관계 정하기',
icon: const Icon(Icons.hub_outlined),
onPressed: () {
final ids = (jsonDecode(room.membersJson) as List).whereType<String>();
if (ids.isNotEmpty) showRelateSheet(context, ids.first, room.title);
},
),
if (room != null) if (room != null)
PopupMenuButton<_Work>( PopupMenuButton<_Menu>(
tooltip: '업무', tooltip: '메뉴',
icon: const Icon(Icons.work_outline), onSelected: (w) => _menu(w, room),
onSelected: (w) => _work(w, room), itemBuilder: (_) => [
itemBuilder: (_) => const [ if (_peer(room) != null) const PopupMenuItem(value: _Menu.person, child: ListTile(leading: Icon(Icons.person_outline), title: Text('프로필 보기'))),
PopupMenuItem(value: _Work.assign, child: Text('📌 업무 맡기기')), if (_peer(room) != null) const PopupMenuItem(value: _Menu.relate, child: ListTile(leading: Icon(Icons.hub_outlined), title: Text('관계 정하기'))),
PopupMenuItem(value: _Work.urgent, child: Text('🚨 긴급 호출')), const PopupMenuItem(value: _Menu.tasks, child: ListTile(leading: Icon(Icons.task_alt), title: Text('이 방 업무'))),
PopupMenuItem(value: _Work.tasks, child: Text('이 방 업무 보기')), const PopupMenuItem(value: _Menu.archive, child: ListTile(leading: Icon(Icons.inventory_2_outlined), title: Text('대화 보관'))),
], ],
), ),
], ],
@ -141,16 +201,11 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
Panel( Panel(
margin: const EdgeInsets.fromLTRB(16, 4, 16, 8), margin: const EdgeInsets.fromLTRB(16, 4, 16, 8),
onTap: () => pushPage(context, ArchiveScreen(roomId: widget.roomId)), onTap: () => pushPage(context, ArchiveScreen(roomId: widget.roomId)),
child: Row(children: [ child: const Row(children: [
const Icon(Icons.inventory_2_outlined, color: Pal.accent), Icon(Icons.inventory_2_outlined, color: Pal.accent),
const SizedBox(width: 12), SizedBox(width: 12),
Expanded( Expanded(child: Text('대화가 많아요. 오래된 대화를 보관함으로 옮길까요?', style: TextStyle(fontSize: 13))),
child: Text( Icon(Icons.chevron_right, color: Pal.muted),
d.advice.overText ? '이 방의 대화가 많아요. 오래된 대화를 별도 저장으로 나눌까요?' : '기기 저장 공간이 부족해져요. 대화를 별도 저장으로 나눌까요?',
style: const TextStyle(fontSize: 13),
),
),
const Icon(Icons.chevron_right, color: Pal.muted),
]), ]),
), ),
Expanded( Expanded(
@ -158,31 +213,32 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
? const EmptyNote('첫 메시지를 보내 보세요.') ? const EmptyNote('첫 메시지를 보내 보세요.')
: ListView.builder( : ListView.builder(
reverse: true, reverse: true,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
itemCount: d.messages.length, itemCount: items.length,
itemBuilder: (_, i) { itemBuilder: (_, i) => items[i],
final m = d.messages[d.messages.length - 1 - i];
return GestureDetector(onLongPress: () => _toTask(m), child: _Bubble(m));
},
), ),
), ),
SafeArea( Container(
top: false, color: Pal.surface,
child: Padding( child: SafeArea(
padding: const EdgeInsets.fromLTRB(12, 6, 8, 8), top: false,
child: Row(children: [ child: Padding(
Expanded( padding: const EdgeInsets.fromLTRB(4, 6, 8, 6),
child: TextField( child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
controller: _input, IconButton(tooltip: '업무·긴급 호출', onPressed: room == null ? null : () => _plus(room), icon: const Icon(Icons.add_circle_outline)),
minLines: 1, Expanded(
maxLines: 4, child: TextField(
textInputAction: TextInputAction.newline, controller: _input,
decoration: const InputDecoration(hintText: '메시지', contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 10)), minLines: 1,
maxLines: 5,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(hintText: '메시지 보내기', isDense: true, fillColor: Pal.bg),
),
), ),
), const SizedBox(width: 6),
const SizedBox(width: 6), IconButton.filled(onPressed: _send, tooltip: '보내기', icon: const Icon(Icons.arrow_upward)),
IconButton.filled(onPressed: _send, tooltip: '보내기', icon: const Icon(Icons.send)), ]),
]), ),
), ),
), ),
]), ]),
@ -191,36 +247,3 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
); );
} }
} }
class _Bubble extends StatelessWidget {
const _Bubble(this.m);
final Message m;
@override
Widget build(BuildContext context) {
final mine = m.senderId == ChatService.me;
final note = WorkService.noteOf(m.body);
final urgent = note is UrgentNote;
return Align(
alignment: mine ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 3),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
decoration: BoxDecoration(
color: mine ? Pal.accent : Pal.surface,
borderRadius: BorderRadius.circular(14),
border: urgent ? Border.all(color: Pal.danger, width: 2) : (mine ? null : Border.all(color: Pal.line)),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
if (note is TaskNote)
Text(note.assignee == null ? '업무' : '업무 · ${note.assignee}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: mine ? Colors.black54 : Pal.accent)),
Text(m.body, style: TextStyle(color: mine ? Colors.black : Pal.text, height: 1.4)),
const SizedBox(height: 2),
Text(shortTime(m.sentAt), style: TextStyle(fontSize: 10, color: mine ? Colors.black54 : Pal.muted)),
]),
),
);
}
}

View File

@ -1,52 +0,0 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/core/core.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
import 'person_screen.dart';
/// 같은 회사·학교·모임 사람들의 묶음. 받은 명함의 항목으로 자동으로 묶인다.
class ClusterScreen extends StatelessWidget {
const ClusterScreen({super.key, required this.kind});
final String kind; // company | school | group
@override
Widget build(BuildContext context) {
final color = Pal.ofKind(kind);
return Scaffold(
appBar: AppBar(title: Text('${CardKinds.label(kind)} 사람들')),
body: AsyncView<List<Cluster>>(
load: (app) => app.network.clusters(kind),
builder: (context, clusters) {
if (clusters.isEmpty) return EmptyNote('${CardKinds.label(kind)} 명함을 받으면\n같은 곳 사람들이 여기에 묶여요.');
return ListView(padding: const EdgeInsets.only(top: 8, bottom: 32), children: [
for (final c in clusters)
Panel(
padding: EdgeInsets.zero,
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
leading: Avatar(c.title, color: color),
title: Text(c.title, style: const TextStyle(fontWeight: FontWeight.w700)),
subtitle: Text('${c.people.length}명', style: const TextStyle(color: Pal.muted, fontSize: 12)),
children: [
for (final p in c.people)
ListTile(
minTileHeight: 52,
leading: Avatar(p.name, size: 36, color: color),
title: Text(p.name),
onTap: () => pushPage(context, PersonScreen(personId: p.id)),
),
],
),
),
),
]);
},
),
);
}
}

View File

@ -22,7 +22,7 @@ class _ContactImportScreenState extends State<ContactImportScreen> {
bool _denied = false; bool _denied = false;
String _query = ''; String _query = '';
String? _label; String? _label;
bool _hideKnown = true; bool _hideKnown = false;
final Set<String> _picked = {}; // 주소록 번호표 final Set<String> _picked = {}; // 주소록 번호표
bool _busy = false; bool _busy = false;
@ -120,33 +120,23 @@ class _ContactImportScreenState extends State<ContactImportScreen> {
onChanged: (v) => setState(() => _query = v), onChanged: (v) => setState(() => _query = v),
), ),
), ),
SizedBox( Padding(
height: 44, padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListView(scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), children: [ child: Wrap(spacing: 6, runSpacing: 6, children: [
Padding( ChoiceChip(label: const Text('전체'), selected: _label == null, onSelected: (_) => setState(() => _label = null)),
padding: const EdgeInsets.symmetric(horizontal: 4), for (final l in labels) ChoiceChip(label: Text(l), selected: _label == l, onSelected: (_) => setState(() => _label = _label == l ? null : l)),
child: FilterChip(label: const Text('이미 있는 사람 빼기'), selected: _hideKnown, onSelected: (v) => setState(() => _hideKnown = v)), FilterChip(label: const Text('새 사람만'), selected: _hideKnown, onSelected: (v) => setState(() => _hideKnown = v)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ChoiceChip(label: const Text('주소록 전체'), selected: _label == null, onSelected: (_) => setState(() => _label = null)),
),
for (final l in labels)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ChoiceChip(label: Text(l), selected: _label == l, onSelected: (_) => setState(() => _label = _label == l ? null : l)),
),
]), ]),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 8, 0), padding: const EdgeInsets.fromLTRB(20, 4, 8, 0),
child: Row(children: [ child: Row(children: [
Expanded(child: Text('보이는 ${shown.length}명 · 고른 ${_picked.length}명', style: const TextStyle(fontSize: 12, color: Pal.muted))), Expanded(child: Text('${shown.length}명 · ${_picked.length}명 고름', style: const TextStyle(fontSize: 12, color: Pal.muted))),
TextButton( TextButton(
onPressed: pickable.isEmpty onPressed: pickable.isEmpty
? null ? null
: () => setState(() => allPicked ? _picked.removeAll(pickable) : _picked.addAll(pickable)), : () => setState(() => allPicked ? _picked.removeAll(pickable) : _picked.addAll(pickable)),
child: Text(allPicked ? '보이는 사람 모두 풀기' : '보이는 사람 모두 고르기'), child: Text(allPicked ? '모두 풀기' : '모두 고르기'),
), ),
]), ]),
), ),
@ -162,8 +152,8 @@ class _ContactImportScreenState extends State<ContactImportScreen> {
: c.phone == null : c.phone == null
? '쓸 수 있는 번호가 없어요' ? '쓸 수 있는 번호가 없어요'
: c.known != null : c.known != null
? '${c.phone} · 이미 인맥에 있어요' ? '${displayPhone(c.phone!)} · 이미 인맥에 있어요'
: c.phone!; : displayPhone(c.phone!);
return CheckboxListTile( return CheckboxListTile(
value: _picked.contains(c.contact.id), value: _picked.contains(c.contact.id),
onChanged: c.importable ? (v) => setState(() => v == true ? _picked.add(c.contact.id) : _picked.remove(c.contact.id)) : null, onChanged: c.importable ? (v) => setState(() => v == true ? _picked.add(c.contact.id) : _picked.remove(c.contact.id)) : null,

View File

@ -1,97 +0,0 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
import 'infer_screen.dart';
import 'shake_screen.dart';
class _DiscoverData {
const _DiscoverData(this.requests, this.suggestions);
final List<FriendRequest> requests;
final List<Suggestion> suggestions;
}
/// 찾기: 받은 친구 요청, 자동 연동 추천, 흔들어서 명함 주고받기.
class DiscoverScreen extends StatelessWidget {
const DiscoverScreen({super.key});
Future<_DiscoverData> _load(FriendlyApp app) async => _DiscoverData(await app.friends.requests(), await app.network.suggestions());
Future<void> _accept(BuildContext context, FriendRequest r) async {
final app = AppScope.of(context).app;
final check = await app.friends.check(r.phone, r.name);
String? same;
if (check.match != null && check.match!.kind == MatchKind.phoneDiffers) {
final who = check.match!.candidates.first;
if (!context.mounted) return;
final isSame = await askConfirm(context, '이미 아는 분인가요?', '${who.name}님(${who.phone})이 이미 있어요.\n같은 분이 번호를 바꾼 건가요?', ok: '같은 분이에요');
if (isSame) same = who.id;
}
await app.friends.accept(r.phone, samePersonId: same);
if (context.mounted) showNote(context, '${r.name}님과 친구가 되었어요.');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('찾기', style: TextStyle(fontWeight: FontWeight.w700))),
body: AsyncView<_DiscoverData>(
load: _load,
builder: (context, d) => ListView(padding: const EdgeInsets.only(bottom: 32), children: [
Panel(
onTap: () => pushPage(context, const ShakeScreen()),
child: const Row(children: [
Icon(Icons.vibration, color: Pal.accent),
SizedBox(width: 12),
Expanded(child: Text('흔들어서 명함 주고받기')),
Icon(Icons.chevron_right, color: Pal.muted),
]),
),
const SectionHeader('받은 친구 요청'),
if (d.requests.isEmpty) const EmptyNote('새 요청이 없어요.'),
for (final r in d.requests)
Panel(
child: Row(children: [
Avatar(r.name, size: 40),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(r.name, style: const TextStyle(fontWeight: FontWeight.w700)),
Text(r.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)),
]),
),
TextButton(onPressed: () => AppScope.of(context).app.friends.decline(r.phone), child: const Text('거절')),
FilledButton(onPressed: () => _accept(context, r), child: const Text('수락')),
]),
),
const SectionHeader('자동 연동 추천'),
if (d.suggestions.isEmpty) const EmptyNote('명함을 주고받으면 같은 학교·회사·모임 사람을 여기서 추천해요.'),
for (final s in d.suggestions)
Panel(
onTap: () => pushPage(context, InferScreen(personId: s.person.id)),
child: Row(children: [
Avatar(s.person.name, size: 40),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(s.person.name, style: const TextStyle(fontWeight: FontWeight.w700)),
Text('${s.guess.relation} · ${s.guess.detail}', style: const TextStyle(fontSize: 12, color: Pal.muted)),
]),
),
Text('${s.guess.confidence}%', style: const TextStyle(color: Pal.accent, fontWeight: FontWeight.w700)),
const SizedBox(width: 4),
TextButton(
onPressed: () => AppScope.of(context).app.network.addLink(s.person.id, s.guess.relation, s.guess.detail),
child: const Text('확정'),
),
]),
),
]),
),
);
}
}

View File

@ -1,135 +0,0 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
import '../widgets/person_picker.dart';
import 'person_screen.dart';
/// 가족 관계를 세대 줄로 나눈다 (화면 배치용 단순 분류).
int _row(String relation) {
const up = ['부모', '아버지', '어머니', '아빠', '엄마', '할아버지', '할머니', '외할', '친할', '조부', '삼촌', '이모', '고모', '큰아버지', '작은아버지'];
const down = ['아들', '딸', '자녀', '손자', '손녀', '조카'];
if (up.any(relation.contains)) return 0;
if (down.any(relation.contains)) return 2;
return 1;
}
/// 가계도: 윗세대 / 나와 같은 세대 / 아랫세대 줄로 가족을 보여 준다.
class FamilyScreen extends StatelessWidget {
const FamilyScreen({super.key});
Future<void> _add(BuildContext context) async {
final app = AppScope.of(context).app;
final people = await app.friends.people();
if (!context.mounted) return;
final p = await pickPerson(context, people, title: '가족으로 추가할 친구');
if (p == null || !context.mounted) return;
final ctl = TextEditingController();
final rel = await showDialog<String>(
context: context,
builder: (c) => AlertDialog(
title: Text('${p.name}님은 나의…'),
content: TextField(controller: ctl, autofocus: true, decoration: const InputDecoration(hintText: '예: 어머니, 동생, 큰아버지')),
actions: [
TextButton(onPressed: () => Navigator.pop(c), child: const Text('취소')),
TextButton(onPressed: () => Navigator.pop(c, ctl.text.trim()), child: const Text('추가')),
],
),
);
ctl.dispose();
if (rel == null || rel.isEmpty) return;
await app.network.addLink(p.id, '가족', rel);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('가족')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _add(context),
icon: const Icon(Icons.add),
label: const Text('가족 추가'),
),
body: AsyncView<List<FamilyMember>>(
load: (app) => app.network.family(),
builder: (context, members) {
if (members.isEmpty) {
return const EmptyNote('가족 명함을 받거나, 친구를 가족으로 추가하면\n여기에 가계도가 만들어져요.');
}
final rows = [<FamilyMember>[], <FamilyMember>[], <FamilyMember>[]];
for (final m in members) {
rows[_row(m.relation)].add(m);
}
return ListView(padding: const EdgeInsets.only(bottom: 100), children: [
_GenerationRow('윗세대', rows[0]),
_GenerationRow('같은 세대', rows[1], showMe: true),
_GenerationRow('아랫세대', rows[2]),
]);
},
),
);
}
}
class _GenerationRow extends StatelessWidget {
const _GenerationRow(this.title, this.members, {this.showMe = false});
final String title;
final List<FamilyMember> members;
final bool showMe;
@override
Widget build(BuildContext context) {
if (members.isEmpty && !showMe) return const SizedBox.shrink();
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
SectionHeader(title),
SizedBox(
height: 104,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
if (showMe)
const _Node(name: '나', relation: '', color: Pal.accent),
for (final m in members)
_Node(
name: m.person.name,
relation: m.relation,
color: Pal.accent,
onTap: () => pushPage(context, PersonScreen(personId: m.person.id)),
),
],
),
),
]);
}
}
class _Node extends StatelessWidget {
const _Node({required this.name, required this.relation, required this.color, this.onTap});
final String name;
final String relation;
final Color color;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: SizedBox(
width: 84,
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Avatar(name, size: 52, color: color),
const SizedBox(height: 6),
Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
if (relation.isNotEmpty) Text(relation, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Pal.muted)),
]),
),
);
}
}

View File

@ -81,8 +81,10 @@ class GroupDetailScreen extends StatelessWidget {
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
Flexible(child: Text(g.name, overflow: TextOverflow.ellipsis)), Flexible(child: Text(g.name, overflow: TextOverflow.ellipsis)),
const SizedBox(width: 8), if (g.name != CardKinds.label(g.kind)) ...[
KindChip(g.kind, CardKinds.label(g.kind)), const SizedBox(width: 8),
KindChip(g.kind, CardKinds.label(g.kind)),
],
]), ]),
if (v.depth > 1) Text(v.path, style: const TextStyle(fontSize: 11, color: Pal.muted)), if (v.depth > 1) Text(v.path, style: const TextStyle(fontSize: 11, color: Pal.muted)),
]), ]),
@ -144,7 +146,7 @@ class GroupDetailScreen extends StatelessWidget {
ListTile( ListTile(
leading: Avatar(p.name, size: 40, color: Pal.ofKind(g.kind)), leading: Avatar(p.name, size: 40, color: Pal.ofKind(g.kind)),
title: Text(p.name), title: Text(p.name),
subtitle: Text(isLocalPhone(p.phone) ? '번호 없음' : p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)), subtitle: Text(isLocalPhone(p.phone) ? '번호 없음' : displayPhone(p.phone), style: const TextStyle(fontSize: 12, color: Pal.muted)),
onTap: () => pushPage(context, PersonScreen(personId: p.id)), onTap: () => pushPage(context, PersonScreen(personId: p.id)),
trailing: IconButton( trailing: IconButton(
tooltip: '그룹에서 빼기', tooltip: '그룹에서 빼기',

View File

@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/career_panel.dart';
import '../widgets/common.dart';
import '../widgets/group_dialogs.dart';
import '../widgets/group_menu.dart';
import 'contact_import_screen.dart';
import 'group_detail_screen.dart';
class _Data {
const _Data(this.groups, this.relock);
final List<GroupView> groups; // 펼친 전체 (보이는 것만)
final int relock;
}
/// 설정 > 그룹 관리: 맨 위 그룹 목록. 누르면 그 그룹 안(하위 그룹·구성원)으로 들어간다.
class GroupsScreen extends StatelessWidget {
const GroupsScreen({super.key});
Future<void> _add(BuildContext context) async {
final app = AppScope.of(context).app;
final r = await askGroupInfo(context);
if (r == null || !context.mounted) return;
final g = await app.groups.add(r.$1, r.$2);
if (context.mounted && g == null) showNote(context, '이름이 비었거나 같은 이름의 그룹이 있어요');
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context).app;
return Scaffold(
appBar: AppBar(
title: const Text('그룹 관리'),
actions: [
IconButton(tooltip: '주소록에서 가져오기', onPressed: () => pushPage(context, const ContactImportScreen()), icon: const Icon(Icons.contact_phone_outlined)),
IconButton(tooltip: '새 그룹', onPressed: () => _add(context), icon: const Icon(Icons.add)),
],
),
body: AsyncView<_Data>(
load: (a) async => _Data(await a.groups.all(), await a.groups.relockMinutes()),
builder: (context, d) => ListView(padding: const EdgeInsets.only(top: 4, bottom: 32), children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, 10),
child: Text('가족·학교 같은 그룹을 정하고 그 아래에 하위 그룹(고등학교, 회사 › 팀 …)을 만들어 사람을 묶어요. '
'비밀번호를 건 그룹은 열 때마다 물어보고, 닫혀 있으면 하위 그룹과 인맥 목록에서도 숨겨요. 그룹은 이 기기에만 저장돼요.',
style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)),
),
const CareerNoticesPanel(),
for (final v in d.groups)
if (v.depth == 1)
GroupTile(
v,
children: d.groups.where((c) => c.group.parent == v.group.id).length,
onOpen: () => pushPage(context, GroupDetailScreen(groupId: v.group.id)),
),
Panel(
child: Row(children: [
const Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('잠긴 그룹 다시 잠그기'),
Text('연 뒤 이 시간이 지나거나 앱을 내리면 다시 잠가요', style: TextStyle(fontSize: 11, color: Pal.muted)),
]),
),
IconButton.outlined(tooltip: '줄이기', onPressed: () => app.groups.setRelockMinutes(d.relock - 1), icon: const Icon(Icons.remove)),
SizedBox(width: 56, child: Text('${d.relock}분', textAlign: TextAlign.center, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Pal.accent))),
IconButton.outlined(tooltip: '늘리기', onPressed: () => app.groups.setRelockMinutes(d.relock + 1), icon: const Icon(Icons.add)),
]),
),
]),
),
);
}
}

View File

@ -1,60 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:friendly_map/modules/app/app.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
/// 친구가 나에게 맡긴 복구 조각. 그 친구가 새 폰에서 복구할 때 조각 글자를 알려 준다.
class HeldSharesScreen extends StatelessWidget {
const HeldSharesScreen({super.key});
Future<void> _show(BuildContext context, HeldShare h) {
return showDialog<void>(
context: context,
builder: (d) => AlertDialog(
title: Text('${h.ownerName}님의 복구 조각'),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('본인이 맞는지 직접 확인한 뒤에만 알려 주세요.', style: TextStyle(fontSize: 12, color: Pal.danger)),
const SizedBox(height: 10),
SelectableText(h.code, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
]),
actions: [
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: h.code));
if (d.mounted) Navigator.pop(d);
if (context.mounted) showNote(context, '복사했어요.');
},
child: const Text('복사'),
),
TextButton(onPressed: () => Navigator.pop(d), child: const Text('닫기')),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('내가 맡은 조각')),
body: AsyncView<List<HeldShare>>(
load: (app) => app.recovery.held(),
builder: (context, list) {
if (list.isEmpty) return const EmptyNote('친구가 맡긴 복구 조각이 아직 없어요.');
return ListView(padding: const EdgeInsets.only(top: 8), children: [
for (final h in list)
ListTile(
leading: Avatar(h.ownerName, size: 40),
title: Text(h.ownerName),
subtitle: Text(h.ownerPhone, style: const TextStyle(fontSize: 12, color: Pal.muted)),
trailing: const Icon(Icons.chevron_right, color: Pal.muted),
onTap: () => _show(context, h),
),
]);
},
),
);
}
}

View File

@ -1,72 +0,0 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/layout/layout.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
/// 홈 편집: 구역을 끌어서 순서 바꾸기, 보이기/숨기기, 이름 바꾸기, 지우기, 처음으로.
class HomeEditScreen extends StatelessWidget {
const HomeEditScreen({super.key});
Future<void> _rename(BuildContext context, HomeItem item) async {
final c = TextEditingController(text: item.title);
final name = await showDialog<String>(
context: context,
builder: (d) => AlertDialog(
title: const Text('이름 바꾸기'),
content: TextField(controller: c, autofocus: true),
actions: [
TextButton(onPressed: () => Navigator.pop(d), child: const Text('취소')),
TextButton(onPressed: () => Navigator.pop(d, c.text.trim()), child: const Text('저장')),
],
),
);
c.dispose();
if (name == null || name.isEmpty || !context.mounted) return;
await AppScope.of(context).app.layout.editHome(item.copyWith(title: name));
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context).app;
return Scaffold(
appBar: AppBar(
title: const Text('홈 편집'),
actions: [
TextButton(
onPressed: () async {
if (await askConfirm(context, '처음 구성으로 되돌릴까요?', '숨기거나 지운 구역이 모두 돌아와요.', ok: '되돌리기')) await app.layout.resetHome();
},
child: const Text('처음으로'),
),
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('완료')),
],
),
body: AsyncView<List<HomeItem>>(
load: (app) => app.layout.home(),
builder: (context, items) {
if (items.isEmpty) return const EmptyNote('구역이 모두 지워졌어요.\n"처음으로"를 누르면 돌아와요.');
return ReorderableListView(
padding: const EdgeInsets.only(bottom: 32),
onReorderItem: (from, to) => app.layout.moveHome(from, to),
children: [
for (final it in items)
ListTile(
key: ValueKey(it.id),
title: Text(it.title, style: TextStyle(color: it.visible ? Pal.text : Pal.muted)),
onTap: () => _rename(context, it),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
Switch(value: it.visible, onChanged: (v) => app.layout.editHome(it.copyWith(visible: v))),
IconButton(tooltip: '지우기', icon: const Icon(Icons.delete_outline, color: Pal.muted), onPressed: () => app.layout.removeHome(it.id)),
const SizedBox(width: 24),
]),
),
],
);
},
),
);
}
}

View File

@ -1,13 +1,23 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app_scope.dart'; import '../app_scope.dart';
import 'card_wallet_screen.dart'; import 'card_wallet_screen.dart';
import 'chat_list_screen.dart'; import 'chat_list_screen.dart';
import 'discover_screen.dart'; import 'me_screen.dart';
import 'network_home_screen.dart'; import 'relations_screen.dart';
import 'privacy_screen.dart'; import 'tasks_screen.dart';
/// 아래 메뉴 5칸. 칸을 바꿔도 각 화면의 상태(스크롤 등)는 그대로 둔다. class _Badges {
const _Badges(this.chats, this.relations, this.tasks);
final int chats; // 이직 알림
final int relations; // 받은 요청 (새 친구·다시 연결)
final int tasks; // 내 업무 (안 끝난 것)
}
/// 아래 메뉴 5칸: 채팅(처음 화면) · 관계 · 업무 · 명함 · 나. 칸을 바꿔도 각 화면의 상태는 그대로 둔다.
/// 앱을 내리면 연 그룹을 모두 다시 잠근다.
class HomeShell extends StatefulWidget { class HomeShell extends StatefulWidget {
const HomeShell({super.key}); const HomeShell({super.key});
@ -17,6 +27,23 @@ class HomeShell extends StatefulWidget {
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver { class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
int _index = 0; int _index = 0;
_Badges _b = const _Badges(0, 0, 0);
StreamSubscription<void>? _sub;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_sub ??= AppScope.of(context).app.changes.listen((_) => _count());
_count();
}
Future<void> _count() async {
final a = AppScope.of(context).app;
final req = (await a.blocks.incoming()).length;
final career = (await a.career.notices()).length;
final mine = (await a.work.tasks(onlyMine: true)).length;
if (mounted) setState(() => _b = _Badges(career, req, mine));
}
@override @override
void initState() { void initState() {
@ -27,10 +54,10 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_sub?.cancel();
super.dispose(); super.dispose();
} }
/// 앱을 내리면 연 그룹을 모두 다시 잠근다.
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) { if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) {
@ -38,28 +65,28 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
} }
} }
static const _tabs = <(IconData, String)>[ static Widget _icon(IconData i, int n) => n == 0 ? Icon(i) : Badge(label: Text('$n'), child: Icon(i));
(Icons.home_outlined, '홈'),
(Icons.chat_bubble_outline, '채팅'),
(Icons.travel_explore, '찾기'),
(Icons.badge_outlined, '명함'),
(Icons.person_outline, '내 정보'),
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: IndexedStack(index: _index, children: const [ body: IndexedStack(index: _index, children: const [
NetworkHomeScreen(),
ChatListScreen(), ChatListScreen(),
DiscoverScreen(), RelationsScreen(),
TasksScreen(asTab: true),
CardWalletScreen(), CardWalletScreen(),
PrivacyScreen(), MeScreen(),
]), ]),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
selectedIndex: _index, selectedIndex: _index,
onDestinationSelected: (i) => setState(() => _index = i), onDestinationSelected: (i) => setState(() => _index = i),
destinations: [for (final t in _tabs) NavigationDestination(icon: Icon(t.$1), label: t.$2)], destinations: [
NavigationDestination(icon: _icon(Icons.chat_bubble_outline, _b.chats), selectedIcon: _icon(Icons.chat_bubble, _b.chats), label: '채팅'),
NavigationDestination(icon: _icon(Icons.hub_outlined, _b.relations), selectedIcon: _icon(Icons.hub, _b.relations), label: '관계'),
NavigationDestination(icon: _icon(Icons.task_alt, _b.tasks), label: '업무'),
const NavigationDestination(icon: Icon(Icons.badge_outlined), selectedIcon: Icon(Icons.badge), label: '명함'),
const NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: '나'),
],
), ),
); );
} }

View File

@ -0,0 +1,213 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
import '../widgets/verify_phone_tile.dart';
import '../widgets/visibility_matrix_view.dart';
import 'location_screen.dart';
import 'settings_screen.dart';
class _Data {
const _Data(this.me, this.settings, this.hideAll, this.relock, this.blocked);
final MyProfile? me;
final AppSettings settings;
final bool hideAll;
final int relock;
final int blocked;
}
/// 나: 내 프로필과 설정 목록.
class MeScreen extends StatelessWidget {
const MeScreen({super.key});
Future<_Data> _load(FriendlyApp a) async => _Data(
await a.profile.me(),
await a.settings.load(),
await a.settings.hideAll(),
await a.groups.relockMinutes(),
(await a.blocks.blocked()).length,
);
Future<void> _rename(BuildContext context, String current) async {
final app = AppScope.of(context).app;
final c = TextEditingController(text: current);
final name = await showDialog<String>(
context: context,
builder: (d) => AlertDialog(
title: const Text('이름 바꾸기'),
content: TextField(controller: c, autofocus: true),
actions: [
TextButton(onPressed: () => Navigator.pop(d), child: const Text('취소')),
TextButton(onPressed: () => Navigator.pop(d, c.text.trim()), child: const Text('저장')),
],
),
);
c.dispose();
if (name != null && name.isNotEmpty) await app.profile.rename(name);
}
Future<void> _exchange(BuildContext context, String current) async {
final app = AppScope.of(context).app;
final v = await showModalBottomSheet<String>(
context: context,
builder: (c) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
for (final (id, t, s) in const [
('request', '요청 후 내가 수락', '친구 요청이 오면 내가 확인하고 받아요'),
('auto', '바로 친구가 되기', '요청이 오면 바로 친구가 되고 기본 명함을 보내요'),
])
ListTile(
leading: Icon(current == id ? Icons.radio_button_checked : Icons.radio_button_off, color: current == id ? Pal.accent : Pal.muted),
title: Text(t),
subtitle: Text(s),
onTap: () => Navigator.pop(c, id),
),
const SizedBox(height: 8),
]),
),
);
if (v != null) await app.settings.update(exchangeMode: v);
}
Future<void> _relock(BuildContext context, int current) async {
final app = AppScope.of(context).app;
final v = await showModalBottomSheet<int>(
context: context,
builder: (c) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Padding(padding: EdgeInsets.fromLTRB(20, 0, 20, 8), child: Text('잠긴 그룹을 연 뒤 다시 잠그는 시간', style: TextStyle(fontWeight: FontWeight.w700))),
for (final m in const [1, 5, 10, 30, 60])
ListTile(
leading: Icon(current == m ? Icons.radio_button_checked : Icons.radio_button_off, color: current == m ? Pal.accent : Pal.muted),
title: Text('$m분'),
onTap: () => Navigator.pop(c, m),
),
]),
),
);
if (v != null) await app.groups.setRelockMinutes(v);
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context).app;
return Scaffold(
appBar: AppBar(title: const Text('나')),
body: AsyncView<_Data>(
load: _load,
builder: (context, d) => ListView(padding: const EdgeInsets.only(bottom: 32), children: [
Panel(
onTap: d.me == null ? null : () => _rename(context, d.me!.name),
child: Row(children: [
Avatar(d.me?.name ?? '?', size: 56),
const SizedBox(width: 14),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(d.me?.name ?? '', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(displayPhone(d.me?.phone ?? ''), style: const TextStyle(color: Pal.muted)),
]),
),
const Icon(Icons.edit_outlined, color: Pal.muted, size: 20),
]),
),
const VerifyPhoneTile(),
const _Header('공유'),
_Item(Icons.visibility_outlined, '공개 범위', '관계별로 학교·회사·생일·연락처·위치를 보일지', onTap: () => pushPage(context, const _VisibilityPage())),
_Item(Icons.swap_horiz, '친구 요청 받기', d.settings.exchangeMode == 'auto' ? '바로 친구가 되기' : '요청 후 내가 수락',
onTap: () => _exchange(context, d.settings.exchangeMode)),
SwitchListTile(
secondary: const Icon(Icons.visibility_off_outlined),
title: const Text('모르는 사람 요청 막기'),
subtitle: const Text('모르는 번호의 친구 요청을 받지 않아요'),
value: d.hideAll,
onChanged: (v) => app.settings.setHideAll(v),
),
_Item(Icons.place_outlined, '위치 공유', '공개 범위에 맞춰 친구에게만', onTap: () => pushPage(context, const LocationScreen())),
const _Header('보안'),
_Item(Icons.lock_clock_outlined, '잠긴 그룹 다시 잠그기', '연 뒤 ${d.relock}분, 또는 앱을 내리면', onTap: () => _relock(context, d.relock)),
_Item(Icons.block, '제명한 번호', d.blocked == 0 ? '없음' : '${d.blocked}개', onTap: () => pushPage(context, const _BlockedPage())),
const _Header('기기'),
_Item(Icons.notifications_none, '알림 · 저장 공간', '생일·기일 알림, 대화 보관 기준', onTap: () => pushPage(context, const SettingsScreen())),
const Padding(
padding: EdgeInsets.fromLTRB(20, 20, 20, 0),
child: Text(
'모든 정보는 이 폰 안에만 암호화되어 있어요. 폰을 바꾸거나 잃으면 이 폰의 기록은 되살릴 수 없고, '
'새 폰에서 시작하면 친구들이 각자 승인해야 다시 연결돼요.',
style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.6),
),
),
]),
),
);
}
}
class _Header extends StatelessWidget {
const _Header(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 6),
child: Text(text, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: Pal.accent)),
);
}
class _Item extends StatelessWidget {
const _Item(this.icon, this.title, this.sub, {required this.onTap});
final IconData icon;
final String title;
final String sub;
final VoidCallback onTap;
@override
Widget build(BuildContext context) =>
ListTile(leading: Icon(icon), title: Text(title), subtitle: Text(sub), trailing: const Icon(Icons.chevron_right), onTap: onTap);
}
class _VisibilityPage extends StatelessWidget {
const _VisibilityPage();
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('공개 범위')),
body: ListView(padding: const EdgeInsets.only(bottom: 32), children: const [
Padding(
padding: EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Text('칸을 누르면 공개 → 일부 → 숨김으로 바뀌어요. 명함을 보낼 때 받는 사람과의 관계에 맞게 걸러서 보내요.',
style: TextStyle(fontSize: 13, color: Pal.muted, height: 1.5)),
),
VisibilityMatrixView(),
]),
);
}
class _BlockedPage extends StatelessWidget {
const _BlockedPage();
@override
Widget build(BuildContext context) {
final app = AppScope.of(context).app;
return Scaffold(
appBar: AppBar(title: const Text('제명한 번호')),
body: AsyncView<List<String>>(
load: (a) async => (await a.blocks.blocked()).toList()..sort(),
builder: (context, list) => list.isEmpty
? const EmptyNote('제명한 번호가 없어요.')
: ListView(children: [
for (final p in list)
ListTile(
leading: const Icon(Icons.block, color: Pal.danger),
title: Text(displayPhone(p)),
trailing: TextButton(onPressed: () => app.blocks.unblock(p), child: const Text('제명 풀기')),
),
]),
),
);
}
}

View File

@ -1,83 +0,0 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/core/core.dart';
import 'package:friendly_map/modules/layout/layout.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
import 'card_wallet_screen.dart';
import 'chat_list_screen.dart';
import 'cluster_screen.dart';
import 'discover_screen.dart';
import 'family_screen.dart';
import 'gathering_screen.dart';
import 'home_edit_screen.dart';
import 'location_screen.dart';
import 'people_screen.dart';
/// 홈: 구역(템플릿)을 보여 준다. 길게 누르면 편집 모드. 구역 id → 화면 연결은 이 표 하나로만 한다.
class NetworkHomeScreen extends StatelessWidget {
const NetworkHomeScreen({super.key});
static final Map<String, Widget Function()> _targets = {
'chats': () => const ChatListScreen(),
'network': () => const PeopleScreen(),
'family': () => const FamilyScreen(),
'gathering': () => const GatheringScreen(),
'company': () => const ClusterScreen(kind: CardKinds.company),
'school': () => const ClusterScreen(kind: CardKinds.school),
'group': () => const ClusterScreen(kind: CardKinds.group),
'location': () => const LocationScreen(),
'discover': () => const DiscoverScreen(),
'cards': () => const CardWalletScreen(),
};
static Color _color(String id) => switch (id) {
'company' => Pal.company,
'school' => Pal.school,
'group' => Pal.group,
'family' || 'gathering' => Pal.accent,
_ => Pal.muted,
};
@override
Widget build(BuildContext context) {
void edit() => pushPage(context, const HomeEditScreen());
return Scaffold(
appBar: AppBar(
title: const Text('Friendly Map', style: TextStyle(fontWeight: FontWeight.w700)),
actions: [IconButton(tooltip: '홈 편집', onPressed: edit, icon: const Icon(Icons.tune))],
),
body: AsyncView<List<HomeItem>>(
load: (app) => app.layout.home(),
builder: (context, all) {
final items = all.where((i) => i.visible && _targets.containsKey(i.id)).toList();
if (items.isEmpty) return const EmptyNote('보이는 구역이 없어요.\n오른쪽 위 버튼에서 홈을 편집해 보세요.');
return GridView.count(
padding: const EdgeInsets.all(16),
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.5,
children: [
for (final it in items)
InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => pushPage(context, _targets[it.id]!()),
onLongPress: edit,
child: Ink(
decoration: BoxDecoration(color: Pal.surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: Pal.line)),
padding: const EdgeInsets.all(14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: _color(it.id), shape: BoxShape.circle)),
Text(it.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
]),
),
),
],
);
},
),
);
}
}

View File

@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import '../theme.dart';
/// 처음 시작: 이름과 번호만 받는다. 키는 이 기기 안에서 조용히 만들고, 복구 코드는 없다.
///
/// [onStart] 가 문제를 돌려주면(번호·이름이 잘못됨) 아래에 보여 준다.
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key, required this.onStart});
final Future<String?> Function(String phone, String name) onStart;
@override
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final _phone = TextEditingController();
final _name = TextEditingController();
String? _error;
bool _busy = false;
@override
void dispose() {
_phone.dispose();
_name.dispose();
super.dispose();
}
bool get _ready => _phone.text.trim().length >= 9 && _name.text.trim().isNotEmpty;
Future<void> _start() async {
FocusScope.of(context).unfocus();
setState(() {
_busy = true;
_error = null;
});
final err = await widget.onStart(_phone.text, _name.text);
if (mounted && err != null) {
setState(() {
_busy = false;
_error = err;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: LayoutBuilder(
builder: (context, box) => SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: box.maxHeight - 24),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
const SizedBox(height: 72),
Align(
alignment: Alignment.centerLeft,
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(color: Pal.accent, borderRadius: BorderRadius.circular(18)),
child: const Icon(Icons.hub, color: Colors.black, size: 36),
),
),
const SizedBox(height: 24),
const Text('Friendly Map', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800)),
const SizedBox(height: 8),
const Text('대화하다 알게 된 관계를\n가족 · 학교 · 회사로 이어 두세요.', style: TextStyle(fontSize: 16, height: 1.5, color: Pal.muted)),
const SizedBox(height: 40),
TextField(
controller: _name,
textInputAction: TextInputAction.next,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(labelText: '이름'),
),
const SizedBox(height: 12),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.done,
onChanged: (_) => setState(() {}),
onSubmitted: (_) => _ready && !_busy ? _start() : null,
decoration: const InputDecoration(labelText: '내 휴대폰 번호', hintText: '010-1234-5678'),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(_error!, style: const TextStyle(color: Pal.danger, fontSize: 13)),
),
const SizedBox(height: 32),
const Text('모든 정보는 이 폰 안에 암호화되어 저장되고, 내가 보낸 것만 상대에게 가요.',
style: TextStyle(fontSize: 12, height: 1.5, color: Pal.muted)),
const SizedBox(height: 12),
SizedBox(
height: 52,
child: FilledButton(
onPressed: _ready && !_busy ? _start : null,
child: _busy
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
: const Text('시작하기', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
),
),
]),
),
),
),
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/storage/storage.dart'; import 'package:friendly_map/modules/storage/storage.dart';
import '../theme.dart'; import '../theme.dart';
@ -48,7 +49,7 @@ class PeopleScreen extends StatelessWidget {
ListTile( ListTile(
leading: Avatar(p.name, size: 40), leading: Avatar(p.name, size: 40),
title: Text(p.name), title: Text(p.name),
subtitle: Text(p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)), subtitle: Text(displayPhone(p.phone), style: const TextStyle(fontSize: 12, color: Pal.muted)),
trailing: const Icon(Icons.chevron_right, color: Pal.muted), trailing: const Icon(Icons.chevron_right, color: Pal.muted),
onTap: () => pushPage(context, PersonScreen(personId: p.id)), onTap: () => pushPage(context, PersonScreen(personId: p.id)),
), ),

View File

@ -63,84 +63,51 @@ class PersonScreen extends StatelessWidget {
final p = d.person; final p = d.person;
if (p == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('찾을 수 없는 사람이에요.')); if (p == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('찾을 수 없는 사람이에요.'));
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(p.name), actions: [ appBar: AppBar(title: const Text('프로필'), actions: [
IconButton(tooltip: '관계 정하기', onPressed: () => showRelateSheet(context, p.id, p.name), icon: const Icon(Icons.hub_outlined)), IconButton(tooltip: '정보 고치기', onPressed: () => pushPage(context, PersonFactsScreen(personId: p.id)), icon: const Icon(Icons.edit_outlined)),
IconButton(
tooltip: '정보 고치기',
onPressed: () => pushPage(context, PersonFactsScreen(personId: p.id)),
icon: const Icon(Icons.edit_outlined),
),
]), ]),
body: ListView(padding: const EdgeInsets.only(bottom: 32), children: [ body: ListView(padding: const EdgeInsets.only(bottom: 32), children: [
if (d.info.markDeceased) DeceasedBanner(d.info.facts), if (d.info.markDeceased) DeceasedBanner(d.info.facts),
CareerNoticesPanel(personId: p.id), CareerNoticesPanel(personId: p.id),
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(children: [ child: Column(children: [
Avatar(p.name, size: 56), Avatar(p.name, size: 84),
const SizedBox(width: 14), const SizedBox(height: 12),
Expanded( Text(p.name, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ if (d.info.reachable) ...[
Text(p.name, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), const SizedBox(height: 4),
const SizedBox(height: 2), Text(displayPhone(p.phone), style: const TextStyle(fontSize: 15, color: Pal.muted)),
if (d.info.reachable) Text(p.phone, style: const TextStyle(color: Pal.muted)), ],
if (p.publicKey == null && d.info.reachable) const Text('아직 수락 전이에요', style: TextStyle(color: Pal.accent, fontSize: 12)), if (p.publicKey == null && d.info.reachable)
]), const Padding(
), padding: EdgeInsets.only(top: 6),
child: Text('상대가 아직 친구 요청을 받지 않았어요', style: TextStyle(color: Pal.accent, fontSize: 12)),
),
]), ]),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(children: [ child: Row(children: [
Expanded( _Action(Icons.chat_bubble_outline, '대화', () => _chat(context, CardKinds.personal)),
child: FilledButton.icon( if (d.info.reachable && d.info.canCall) _Action(Icons.call_outlined, '전화', () => _call(context, false)),
onPressed: () => _chat(context, CardKinds.personal), if (d.info.reachable && d.info.canCall) _Action(Icons.sms_outlined, '문자', () => _call(context, true)),
icon: const Icon(Icons.chat_bubble_outline), _Action(Icons.hub_outlined, '관계', () => showRelateSheet(context, p.id, p.name)),
label: const Text('대화'), _Action(Icons.travel_explore, '어디서 봤지', () => pushPage(context, InferScreen(personId: p.id))),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: () => pushPage(context, InferScreen(personId: p.id)),
icon: const Icon(Icons.travel_explore),
label: const Text('어디서 봤지?'),
),
),
]), ]),
), ),
if (d.info.reachable && d.info.canCall) const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
child: Row(children: [
Expanded(
child: FilledButton.tonalIcon(
onPressed: () => _call(context, false),
icon: const Icon(Icons.call),
label: const Text('전화'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.tonalIcon(
onPressed: () => _call(context, true),
icon: const Icon(Icons.sms_outlined),
label: const Text('문자'),
),
),
]),
),
const SizedBox(height: 10),
PlacesPanel(personId: p.id, name: p.name), PlacesPanel(personId: p.id, name: p.name),
RelationPanel(d.info.relations, onEdit: () => pushPage(context, PersonFactsScreen(personId: p.id))), if (d.info.relations.any((v) => v.relation.related))
CallPanel(d.info), RelationPanel(d.info.relations, onEdit: () => pushPage(context, PersonFactsScreen(personId: p.id))),
if (d.info.reachable && d.info.canReadLog) CallPanel(d.info),
EventsPanel(d.info.events), EventsPanel(d.info.events),
if (d.links.isNotEmpty) ...[ if (d.links.any((l) => !_groupWords.contains(l.relation))) ...[
const SectionHeader('연결 고리'), const SectionHeader('연결 고리'),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Wrap(spacing: 8, runSpacing: 8, children: [ child: Wrap(spacing: 8, runSpacing: 8, children: [
for (final l in d.links) for (final l in d.links.where((l) => !_groupWords.contains(l.relation)))
Chip(label: Text(l.detail.isEmpty ? l.relation : '${l.relation} · ${l.detail}')), Chip(label: Text(l.detail.isEmpty ? l.relation : '${l.relation} · ${l.detail}')),
]), ]),
), ),
@ -148,14 +115,6 @@ class PersonScreen extends StatelessWidget {
const SectionHeader('받은 명함'), const SectionHeader('받은 명함'),
if (d.cards.isEmpty) const EmptyNote('받은 명함이 아직 없어요.'), if (d.cards.isEmpty) const EmptyNote('받은 명함이 아직 없어요.'),
for (final c in d.cards) _ReceivedCardTile(c, d.labels), for (final c in d.cards) _ReceivedCardTile(c, d.labels),
const SectionHeader('이 사람과 대화하기'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Wrap(spacing: 8, runSpacing: 8, children: [
for (final k in CardKinds.all.where((k) => k != CardKinds.personal))
ActionChip(label: Text('${CardKinds.label(k)} 대화'), onPressed: () => _chat(context, k)),
]),
),
]), ]),
); );
}, },
@ -163,6 +122,37 @@ class PersonScreen extends StatelessWidget {
} }
} }
/// 그룹에 넣을 때 자동으로 생기는 연결 고리 (그룹 칸에 이미 보이므로 따로 보이지 않는다).
const _groupWords = {'가족', '동문', '직장 동료', '같은 모임'};
class _Action extends StatelessWidget {
const _Action(this.icon, this.label, this.onTap);
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(color: Pal.surface, shape: BoxShape.circle, border: Border.all(color: Pal.line)),
child: Icon(icon, color: Pal.accent),
),
const SizedBox(height: 6),
Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
]),
),
),
);
}
class _ReceivedCardTile extends StatelessWidget { class _ReceivedCardTile extends StatelessWidget {
const _ReceivedCardTile(this.c, this.labels); const _ReceivedCardTile(this.c, this.labels);
@ -181,7 +171,7 @@ class _ReceivedCardTile extends StatelessWidget {
Row(children: [ Row(children: [
KindChip(c.kind, CardKinds.label(c.kind)), KindChip(c.kind, CardKinds.label(c.kind)),
const Spacer(), const Spacer(),
Text(shortTime(c.updatedAt), style: const TextStyle(fontSize: 11, color: Pal.muted)), Text(listTime(c.updatedAt), style: const TextStyle(fontSize: 11, color: Pal.muted)),
]), ]),
const SizedBox(height: 8), const SizedBox(height: 8),
if (lines.isEmpty) const Text('비어 있는 명함', style: TextStyle(color: Pal.muted)), if (lines.isEmpty) const Text('비어 있는 명함', style: TextStyle(color: Pal.muted)),

View File

@ -1,125 +0,0 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
import '../widgets/verify_phone_tile.dart';
import '../widgets/visibility_matrix_view.dart';
import 'discover_screen.dart';
import 'settings_screen.dart';
class _MeData {
const _MeData(this.me, this.settings, this.hideAll, this.requests);
final MyProfile? me;
final AppSettings settings;
final bool hideAll;
final int requests;
}
/// 내 정보: 프로필, 명함 교환 방식, 모든 정보 숨기기, 저장·보안 설정으로 가는 길.
class PrivacyScreen extends StatelessWidget {
const PrivacyScreen({super.key});
Future<_MeData> _load(FriendlyApp app) async =>
_MeData(await app.profile.me(), await app.settings.load(), await app.settings.hideAll(), (await app.friends.requests()).length);
Future<void> _rename(BuildContext context, String current) async {
final app = AppScope.of(context).app;
final c = TextEditingController(text: current);
final name = await showDialog<String>(
context: context,
builder: (d) => AlertDialog(
title: const Text('이름 바꾸기'),
content: TextField(controller: c, autofocus: true),
actions: [
TextButton(onPressed: () => Navigator.pop(d), child: const Text('취소')),
TextButton(onPressed: () => Navigator.pop(d, c.text.trim()), child: const Text('저장')),
],
),
);
c.dispose();
if (name != null && name.isNotEmpty) await app.profile.rename(name);
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context).app;
return Scaffold(
appBar: AppBar(
title: const Text('내 정보', style: TextStyle(fontWeight: FontWeight.w700)),
actions: [TextButton(onPressed: () => pushPage(context, const SettingsScreen()), child: const Text('저장·보안'))],
),
body: AsyncView<_MeData>(
load: _load,
builder: (context, d) => ListView(padding: const EdgeInsets.only(bottom: 32), children: [
Panel(
onTap: d.me == null ? null : () => _rename(context, d.me!.name),
child: Row(children: [
Avatar(d.me?.name ?? '?', size: 48),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(d.me?.name ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(d.me?.phone ?? '', style: const TextStyle(fontSize: 12, color: Pal.muted)),
]),
),
const Icon(Icons.edit_outlined, color: Pal.muted, size: 18),
]),
),
const VerifyPhoneTile(),
if (d.requests > 0)
Panel(
onTap: () => pushPage(context, const DiscoverScreen()),
child: Row(children: [
const Icon(Icons.notifications_active_outlined, color: Pal.accent),
const SizedBox(width: 12),
Expanded(child: Text('받은 친구 요청 ${d.requests}건')),
const Icon(Icons.chevron_right, color: Pal.muted),
]),
),
const SectionHeader('공개 범위'),
const Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text('칸을 눌러 공개 · 일부 · 숨김으로 바꿔요. 명함을 보낼 때 받는 사람의 관계에 맞게 적용돼요.', style: TextStyle(fontSize: 11, color: Pal.muted)),
),
const VisibilityMatrixView(),
const SectionHeader('명함 교환 방식'),
RadioGroup<String>(
groupValue: d.settings.exchangeMode,
onChanged: (v) {
if (v != null) app.settings.update(exchangeMode: v);
},
child: const Column(children: [
RadioListTile<String>(
value: 'request',
activeColor: Pal.accent,
title: Text('요청 후 상대 수락'),
subtitle: Text('요청이 오면 내가 확인하고 받아요', style: TextStyle(fontSize: 11, color: Pal.muted)),
),
RadioListTile<String>(
value: 'auto',
activeColor: Pal.accent,
title: Text('자동 교환'),
subtitle: Text('요청이 오면 바로 친구가 되고 기본 명함을 보내요', style: TextStyle(fontSize: 11, color: Pal.muted)),
),
]),
),
const SectionHeader('숨기기'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ToggleRow(
title: '모든 정보 숨기기',
subtitle: '모르는 사람의 친구 요청을 받지 않아요',
value: d.hideAll,
onChanged: (v) => app.settings.setHideAll(v),
),
),
]),
),
);
}
}

View File

@ -1,80 +0,0 @@
import 'package:flutter/material.dart';
import '../app_scope.dart';
import '../theme.dart';
/// 내 번호와 이름을 정한다 (기본 명함 5장이 만들어진다).
class ProfileSetupScreen extends StatefulWidget {
const ProfileSetupScreen({super.key, required this.onDone});
final VoidCallback onDone;
@override
State<ProfileSetupScreen> createState() => _ProfileSetupScreenState();
}
class _ProfileSetupScreenState extends State<ProfileSetupScreen> {
final _phone = TextEditingController();
final _name = TextEditingController();
String? _error;
bool _busy = false;
@override
void dispose() {
_phone.dispose();
_name.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() {
_busy = true;
_error = null;
});
final p = await AppScope.of(context).app.profile.setUp(_phone.text, _name.text);
if (!mounted) return;
if (p == null) {
setState(() {
_busy = false;
_error = '전화번호와 이름을 확인해 주세요.';
});
return;
}
widget.onDone();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('내 정보')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
const Text('친구가 나를 찾을 때 쓰는 전화번호와 이름이에요.', style: TextStyle(color: Pal.muted, height: 1.5)),
const SizedBox(height: 20),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(labelText: '내 전화번호', hintText: '010-1234-5678'),
),
const SizedBox(height: 14),
TextField(
controller: _name,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(labelText: '이름'),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(_error!, style: const TextStyle(color: Pal.danger)),
),
const SizedBox(height: 24),
SizedBox(
height: 52,
child: FilledButton(onPressed: _busy ? null : _save, child: const Text('저장하고 시작')),
),
],
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/discovery/discovery.dart'; import 'package:friendly_map/modules/discovery/discovery.dart';
import 'package:qr_flutter/qr_flutter.dart' show QrImageView; import 'package:qr_flutter/qr_flutter.dart' show QrImageView;
@ -29,7 +30,7 @@ class MyQrScreen extends StatelessWidget {
const SizedBox(height: 20), const SizedBox(height: 20),
Text(q.name, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), Text(q.name, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(q.phone, style: const TextStyle(color: Pal.muted)), Text(displayPhone(q.phone), style: const TextStyle(color: Pal.muted)),
const SizedBox(height: 16), const SizedBox(height: 16),
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 32), padding: EdgeInsets.symmetric(horizontal: 32),

View File

@ -1,101 +0,0 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
class _Data {
const _Data(this.people, this.split, this.settings);
final List<Person> people;
final RecoverySplit? split;
final AppSettings settings;
}
/// 복구 조각을 친구에게 맡긴다. 친구마다 조각 한 장, 설정한 수만큼의 친구에게 나눠 맡긴다.
class RecoveryEntrustScreen extends StatefulWidget {
const RecoveryEntrustScreen({super.key});
@override
State<RecoveryEntrustScreen> createState() => _RecoveryEntrustScreenState();
}
class _RecoveryEntrustScreenState extends State<RecoveryEntrustScreen> {
bool _busy = false;
Future<_Data> _load(FriendlyApp app) async =>
_Data(await app.friends.people(), await app.recovery.split(), await app.settings.load());
Future<void> _resplit(AppSettings s) async {
final scope = AppScope.of(context);
setState(() => _busy = true);
try {
final codes = await scope.bootstrap.splitRecovery(s.recoveryShares, s.recoveryThreshold);
await scope.app.recovery.saveSplit(codes, s.recoveryThreshold);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _entrust(Person p, RecoverySplit split) async {
final app = AppScope.of(context).app;
final index = split.given[p.id] ?? split.free.first;
final r = await app.recovery.entrust(p.id, index);
if (!mounted) return;
showNote(context, switch (r.name) {
'sent' => '${p.name}님에게 조각을 맡겼어요.',
'queued' => '${p.name}님이 온라인이 되면 전달돼요.',
'noKey' => '${p.name}님의 키를 아직 몰라요. 친구 수락이 끝난 뒤에 다시 해 주세요.',
_ => '맡기지 못했어요.',
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('복구 조각 맡기기')),
body: AsyncView<_Data>(
load: _load,
builder: (context, d) {
final split = d.split;
final stale = split == null || split.total != d.settings.recoveryShares || split.threshold != d.settings.recoveryThreshold;
return ListView(padding: const EdgeInsets.only(bottom: 32), children: [
Panel(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
split == null || stale
? '${d.settings.recoveryShares}조각으로 나눠 친구에게 맡겨요'
: '맡긴 친구 ${split.entrusted} / ${split.total}명',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text('${d.settings.recoveryShares}명 중 ${d.settings.recoveryThreshold}명이 조각을 내놓으면 복구돼요. 친구 한 명의 조각만으로는 아무것도 알 수 없어요.',
style: const TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)),
if (stale) ...[
const SizedBox(height: 10),
if (split != null)
const Text('조각 수나 필요한 수를 바꿔서 새로 나눠야 해요. 이전에 맡긴 조각은 쓸 수 없게 돼요.', style: TextStyle(fontSize: 12, color: Pal.danger, height: 1.5)),
const SizedBox(height: 8),
FilledButton(onPressed: _busy ? null : () => _resplit(d.settings), child: Text(split == null ? '조각 만들기' : '새로 나누기')),
],
]),
),
if (!stale && d.people.isEmpty) const EmptyNote('아직 친구가 없어요.'),
if (!stale)
for (final p in d.people)
ListTile(
leading: Avatar(p.name, size: 40),
title: Text(p.name),
subtitle: Text(split.given.containsKey(p.id) ? '맡김 · ${split.given[p.id]! + 1}번 조각' : p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)),
trailing: split.given.containsKey(p.id)
? TextButton(onPressed: () => _entrust(p, split), child: const Text('다시 보내기'))
: FilledButton.tonal(onPressed: split.free.isEmpty || p.publicKey == null ? null : () => _entrust(p, split), child: const Text('맡기기')),
),
]);
},
),
);
}
}

View File

@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
import '../widgets/group_dialogs.dart';
import '../widgets/group_menu.dart';
import 'add_friend_screen.dart';
import 'contact_import_screen.dart';
import 'gathering_screen.dart';
import 'group_detail_screen.dart';
import 'people_screen.dart';
import 'person_screen.dart';
import 'requests_screen.dart';
import 'shake_screen.dart';
class _Data {
const _Data(this.groups, this.people, this.hidden, this.requests, this.suggestions);
final List<GroupView> groups;
final List<Person> people; // 보이는 사람 (닫힌 잠긴 그룹 사람 제외)
final int hidden;
final int requests;
final List<Suggestion> suggestions;
}
/// 관계: 사람 찾기, 빠른 추가, 그룹(가족·학교·회사·모임과 하위 그룹), 이런 사이 아닐까요.
class RelationsScreen extends StatefulWidget {
const RelationsScreen({super.key});
@override
State<RelationsScreen> createState() => _RelationsScreenState();
}
class _RelationsScreenState extends State<RelationsScreen> {
String _q = '';
Future<_Data> _load(FriendlyApp a) async {
final hidden = await a.groups.hiddenPeople();
final all = await a.friends.people();
final shown = [for (final p in all) if (!hidden.contains(p.id)) p];
return _Data(await a.groups.all(), shown, all.length - shown.length, (await a.blocks.incoming()).length, await a.network.suggestions());
}
Future<void> _addGroup() async {
final app = AppScope.of(context).app;
final r = await askGroupInfo(context);
if (r == null || !mounted) return;
if (await app.groups.add(r.$1, r.$2) == null && mounted) showNote(context, '이름이 비었거나 같은 이름의 그룹이 있어요');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('관계'),
actions: [IconButton(tooltip: '친구 추가', onPressed: () => pushPage(context, const AddFriendScreen()), icon: const Icon(Icons.person_add_alt_1))],
),
body: AsyncView<_Data>(
load: _load,
builder: (context, d) {
final q = _q.trim();
return ListView(padding: const EdgeInsets.only(bottom: 32), children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: TextField(
decoration: InputDecoration(hintText: '사람 찾기 (${d.people.length}명)', prefixIcon: const Icon(Icons.search), isDense: true),
onChanged: (v) => setState(() => _q = v),
),
),
if (q.isNotEmpty) ..._results(context, d, q) else ..._home(context, d),
]);
},
),
);
}
List<Widget> _results(BuildContext context, _Data d, String q) {
final hit = [for (final p in d.people) if (p.name.contains(q) || p.phone.contains(q) || displayPhone(p.phone).contains(q)) p];
if (hit.isEmpty) return const [EmptyNote('맞는 사람이 없어요.')];
return [
for (final p in hit)
ListTile(leading: Avatar(p.name, size: 40), title: Text(p.name), subtitle: Text(displayPhone(p.phone)), onTap: () => pushPage(context, PersonScreen(personId: p.id))),
];
}
List<Widget> _home(BuildContext context, _Data d) {
final roots = [for (final v in d.groups) if (v.depth == 1) v];
int kids(GroupView v) => d.groups.where((c) => c.group.parent == v.group.id).length;
return [
if (d.requests > 0)
Panel(
onTap: () => pushPage(context, const RequestsScreen()),
child: Row(children: [
const Icon(Icons.mark_email_unread_outlined, color: Pal.accent),
const SizedBox(width: 12),
Expanded(child: Text('받은 요청 ${d.requests}건', style: const TextStyle(fontWeight: FontWeight.w700))),
const Icon(Icons.chevron_right, color: Pal.muted),
]),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
child: Row(children: [
_Quick(Icons.people_alt_outlined, '모든 사람', '${d.people.length}명', () => pushPage(context, const PeopleScreen())),
_Quick(Icons.account_tree_outlined, '가계도', '촌수', () => pushPage(context, const GatheringScreen())),
_Quick(Icons.contact_phone_outlined, '주소록', '불러오기', () => pushPage(context, const ContactImportScreen())),
_Quick(Icons.vibration, '흔들기', '명함 교환', () => pushPage(context, const ShakeScreen())),
]),
),
SectionHeader('그룹', trailing: TextButton.icon(onPressed: _addGroup, icon: const Icon(Icons.add, size: 18), label: const Text('새 그룹'))),
for (final v in roots) GroupTile(v, children: kids(v), onOpen: () => pushPage(context, GroupDetailScreen(groupId: v.group.id))),
if (d.hidden > 0)
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 0),
child: Row(children: [
const Icon(Icons.lock, size: 14, color: Pal.muted),
const SizedBox(width: 6),
Text('잠긴 그룹의 ${d.hidden}명은 숨겨져 있어요', style: const TextStyle(fontSize: 12, color: Pal.muted)),
]),
),
if (d.suggestions.isNotEmpty) ...[
const SectionHeader('이런 사이 아닐까요?'),
for (final s in d.suggestions.take(5))
ListTile(
leading: Avatar(s.person.name, size: 40),
title: Text(s.person.name),
subtitle: Text('${s.guess.relation} · ${s.guess.detail}', maxLines: 1, overflow: TextOverflow.ellipsis),
onTap: () => pushPage(context, PersonScreen(personId: s.person.id)),
trailing: TextButton(
onPressed: () => AppScope.of(context).app.network.addLink(s.person.id, s.guess.relation, s.guess.detail),
child: const Text('맞아요'),
),
),
],
];
}
}
class _Quick extends StatelessWidget {
const _Quick(this.icon, this.title, this.sub, this.onTap);
final IconData icon;
final String title;
final String sub;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Material(
color: Pal.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(children: [
Icon(icon, color: Pal.accent),
const SizedBox(height: 6),
Text(title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
Text(sub, style: const TextStyle(fontSize: 11, color: Pal.muted)),
]),
),
),
),
),
);
}

View File

@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
/// 받은 요청: 새 기기로 다시 연결(승인/제명)과 새 친구(수락/거절).
class RequestsScreen extends StatelessWidget {
const RequestsScreen({super.key});
Future<void> _expel(BuildContext context, IncomingRequest r) async {
final name = r.known?.name ?? r.request.name;
final ok = await askConfirm(context, '$name님을 제명할까요?', '이 번호는 앞으로 인사·요청·대화를 영원히 보낼 수 없어요. 기존 대화 기록은 남아요.', ok: '제명');
if (!ok || !context.mounted) return;
await AppScope.of(context).app.blocks.expel(r.request.phone);
if (context.mounted) showNote(context, '$name님을 제명했어요.');
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context).app;
return Scaffold(
appBar: AppBar(title: const Text('받은 요청')),
body: AsyncView<List<IncomingRequest>>(
load: (a) => a.blocks.incoming(),
builder: (context, list) {
if (list.isEmpty) return const EmptyNote('받은 요청이 없어요.');
return ListView(padding: const EdgeInsets.only(top: 4, bottom: 32), children: [
for (final r in list)
Panel(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Avatar(r.known?.name ?? r.request.name, size: 44),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(r.known?.name ?? r.request.name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
Text(displayPhone(r.request.phone), style: const TextStyle(fontSize: 13, color: Pal.muted)),
]),
),
if (r.reconnect)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: Pal.accent.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(10)),
child: const Text('새 기기', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Pal.accent)),
),
]),
const SizedBox(height: 10),
Text(
r.reconnect
? '이미 아는 분이 새 폰에서 다시 연결을 청했어요. 본인이 맞으면 승인하세요. 이전 대화와 그룹은 그대로 이어져요.'
: '${r.request.name}님이 친구 요청을 보냈어요.',
style: const TextStyle(fontSize: 13, color: Pal.muted, height: 1.5),
),
const SizedBox(height: 12),
Row(children: [
Expanded(
child: r.reconnect
? OutlinedButton(
style: OutlinedButton.styleFrom(foregroundColor: Pal.danger),
onPressed: () => _expel(context, r),
child: const Text('제명'),
)
: OutlinedButton(onPressed: () => app.friends.decline(r.request.phone), child: const Text('거절')),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: () async {
await app.blocks.approve(r.request.phone);
if (context.mounted) showNote(context, r.reconnect ? '다시 연결했어요.' : '친구가 되었어요.');
},
child: Text(r.reconnect ? '승인' : '수락'),
),
),
]),
]),
),
]);
},
),
);
}
}

View File

@ -6,44 +6,26 @@ import '../theme.dart';
import '../widgets/async_view.dart'; import '../widgets/async_view.dart';
import '../widgets/common.dart'; import '../widgets/common.dart';
import '../widgets/event_settings_panel.dart'; import '../widgets/event_settings_panel.dart';
import 'contact_import_screen.dart';
import 'groups_screen.dart';
import 'held_shares_screen.dart';
import 'recovery_entrust_screen.dart';
import 'welcome_screen.dart';
const int _mb = 1024 * 1024; const int _mb = 1024 * 1024;
/// 저장·보안 설정: 별도 저장 기준, 기기 저장 공간 기준, 그룹·주소록, 복구 코드와 조각 수. /// 알림 · 저장 공간: 생일·기일 알림, 대화 보관 기준.
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
Future<void> _showCode(BuildContext context) async {
final scope = AppScope.of(context);
final phrase = await scope.bootstrap.recoveryPhrase();
if (!context.mounted) return;
if (phrase == null) {
showNote(context, '이 기기에서 복구 코드를 찾을 수 없어요.');
return;
}
await Navigator.of(context).push(MaterialPageRoute<void>(
builder: (c) => RecoveryCodeScreen(phrase: phrase, readOnly: true, onDone: () => Navigator.of(c).pop()),
));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final svc = AppScope.of(context).app.settings; final svc = AppScope.of(context).app.settings;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('저장 · 보안 설정')), appBar: AppBar(title: const Text('알림 · 저장 공간')),
body: AsyncView<AppSettings>( body: AsyncView<AppSettings>(
load: (app) => app.settings.load(), load: (app) => app.settings.load(),
builder: (context, s) => ListView(padding: const EdgeInsets.only(bottom: 32), children: [ builder: (context, s) => ListView(padding: const EdgeInsets.only(bottom: 32), children: [
Panel( Panel(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('별도 저장 기준', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), const Text('대화 보관', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
const SizedBox(height: 4), const SizedBox(height: 4),
const Text('지우지 않고 영구 보관하되, 아래 기준을 넘으면 별도 저장소로 나눠 담아요.', style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)), const Text('대화는 지우지 않아요. 아래 기준을 넘으면 오래된 대화를 별도 보관함으로 옮겨 폰을 가볍게 해요.', style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)),
_Stepper( _Stepper(
title: '대화 텍스트 크기', title: '대화 텍스트 크기',
hint: '대화방 하나가 이 크기를 넘으면', hint: '대화방 하나가 이 크기를 넘으면',
@ -65,50 +47,6 @@ class SettingsScreen extends StatelessWidget {
]), ]),
), ),
const EventSettingsPanel(), const EventSettingsPanel(),
Panel(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('그룹 · 주소록', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
const Text('가족·학교 같은 그룹을 정하고 고쳐요. 그룹마다 비밀번호를 걸 수 있어요. 휴대폰 주소록에서 한 명씩 또는 여러 명을 골라 그룹에 넣어요.',
style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)),
const SizedBox(height: 10),
Row(children: [
Expanded(child: OutlinedButton(onPressed: () => pushPage(context, const GroupsScreen()), child: const Text('그룹 관리'))),
const SizedBox(width: 8),
Expanded(child: OutlinedButton(onPressed: () => pushPage(context, const ContactImportScreen()), child: const Text('주소록에서 가져오기'))),
]),
]),
),
Panel(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('복구 코드', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
const Text('코드를 잃으면 데이터를 되살릴 방법이 없어요.', style: TextStyle(fontSize: 12, color: Pal.danger, height: 1.5)),
const SizedBox(height: 8),
SizedBox(width: double.infinity, height: 44, child: OutlinedButton(onPressed: () => _showCode(context), child: const Text('코드 다시 확인'))),
const SizedBox(height: 12),
const Text('조각 맡기기', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700)),
_Stepper(
title: '조각 수',
value: '${s.recoveryShares}개',
onMinus: () => svc.update(recoveryShares: s.recoveryShares - 1),
onPlus: () => svc.update(recoveryShares: s.recoveryShares + 1),
),
_Stepper(
title: '복구에 필요한 수',
value: '${s.recoveryThreshold}개',
onMinus: () => svc.update(recoveryThreshold: s.recoveryThreshold - 1),
onPlus: () => svc.update(recoveryThreshold: s.recoveryThreshold + 1),
),
Text('${s.recoveryShares}명 중 ${s.recoveryThreshold}명이 모이면 복구돼요.', style: const TextStyle(fontSize: 12, color: Pal.muted)),
const SizedBox(height: 10),
Row(children: [
Expanded(child: OutlinedButton(onPressed: () => pushPage(context, const RecoveryEntrustScreen()), child: const Text('친구에게 맡기기'))),
const SizedBox(width: 8),
Expanded(child: OutlinedButton(onPressed: () => pushPage(context, const HeldSharesScreen()), child: const Text('내가 맡은 조각'))),
]),
]),
),
]), ]),
), ),
); );

View File

@ -62,7 +62,7 @@ class _ShakePickScreenState extends State<ShakePickScreen> {
value: n.phone, value: n.phone,
activeColor: Pal.accent, activeColor: Pal.accent,
title: Text(n.known?.name ?? n.phone), title: Text(n.known?.name ?? n.phone),
subtitle: Text(n.known == null ? '처음 보는 사람 · 친구로 추가해요' : n.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)), subtitle: Text(n.known == null ? '처음 보는 사람 · 친구로 추가해요' : displayPhone(n.phone), style: const TextStyle(fontSize: 12, color: Pal.muted)),
), ),
]), ]),
), ),

View File

@ -65,7 +65,7 @@ class _ShareScreenState extends State<ShareScreen> {
onChanged: (v) => setState(() => _personId = v), onChanged: (v) => setState(() => _personId = v),
child: Column(children: [ child: Column(children: [
for (final p in d.people) for (final p in d.people)
RadioListTile<String>(value: p.id, activeColor: Pal.accent, title: Text(p.name), subtitle: Text(p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted))), RadioListTile<String>(value: p.id, activeColor: Pal.accent, title: Text(p.name), subtitle: Text(displayPhone(p.phone), style: const TextStyle(fontSize: 12, color: Pal.muted))),
]), ]),
), ),
const SectionHeader('보낼 명함'), const SectionHeader('보낼 명함'),

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/work/work.dart'; import 'package:friendly_map/modules/work/work.dart';
import '../app_scope.dart'; import '../app_scope.dart';
@ -9,25 +10,23 @@ import 'chat_room_screen.dart';
/// 맡은 업무: 내 업무 / 전체, 끝난 것 보기. [roomId] 를 주면 그 방의 업무만. /// 맡은 업무: 내 업무 / 전체, 끝난 것 보기. [roomId] 를 주면 그 방의 업무만.
class TasksScreen extends StatefulWidget { class TasksScreen extends StatefulWidget {
const TasksScreen({super.key, this.roomId, this.title = '맡은 업무'}); const TasksScreen({super.key, this.roomId, this.title = '업무', this.asTab = false});
final String? roomId; final String? roomId;
final String title; final String title;
/// 아래 메뉴의 한 칸으로 쓸 때 (처음 보기를 "내 업무"로).
final bool asTab;
@override @override
State<TasksScreen> createState() => _TasksScreenState(); State<TasksScreen> createState() => _TasksScreenState();
} }
class _TasksScreenState extends State<TasksScreen> { class _TasksScreenState extends State<TasksScreen> {
bool _onlyMine = false; late bool _onlyMine = widget.asTab;
bool _done = false; bool _done = false;
String _due(DateTime d) { String _due(DateTime d) => '마감 ${d.month}/${d.day} · ${dueText(d)}';
final today = DateTime.now();
final left = DateTime(d.year, d.month, d.day).difference(DateTime(today.year, today.month, today.day)).inDays;
final when = left == 0 ? '오늘' : left < 0 ? '${-left}일 지남' : 'D-$left';
return '마감 ${d.month}/${d.day} · $when';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -50,7 +49,7 @@ class _TasksScreenState extends State<TasksScreen> {
key: ValueKey('$_onlyMine$_done'), key: ValueKey('$_onlyMine$_done'),
load: (a) => a.work.tasks(roomId: widget.roomId, onlyMine: _onlyMine, includeDone: _done), load: (a) => a.work.tasks(roomId: widget.roomId, onlyMine: _onlyMine, includeDone: _done),
builder: (context, list) { builder: (context, list) {
if (list.isEmpty) return const EmptyNote('맡은 업무가 없어요.\n대화방 메뉴의 "업무 맡기기"나, 대화를 길게 눌러 "업무로 정리"해 보세요.'); if (list.isEmpty) return EmptyNote(_onlyMine ? '내가 맡은 업무가 없어요.' : '업무가 없어요.\n대화방의 + 버튼으로 업무를 맡기거나, 대화를 길게 눌러 업무로 정리해요.');
return ListView(padding: const EdgeInsets.only(bottom: 32), children: [ return ListView(padding: const EdgeInsets.only(bottom: 32), children: [
for (final t in list) for (final t in list)
Dismissible( Dismissible(

View File

@ -1,180 +0,0 @@
import 'package:flutter/material.dart';
import '../theme.dart';
/// 처음 화면: 새로 시작하거나 12단어 복구 코드로 되살린다.
class WelcomeScreen extends StatefulWidget {
const WelcomeScreen({super.key, required this.onCreate, required this.onRestore});
final Future<void> Function() onCreate;
final Future<bool> Function(String phrase) onRestore;
@override
State<WelcomeScreen> createState() => _WelcomeScreenState();
}
class _WelcomeScreenState extends State<WelcomeScreen> {
final _phrase = TextEditingController();
bool _restoring = false;
bool _busy = false;
String? _error;
@override
void dispose() {
_phrase.dispose();
super.dispose();
}
Future<void> _create() async {
setState(() => _busy = true);
await widget.onCreate();
if (mounted) setState(() => _busy = false);
}
Future<void> _restore() async {
setState(() {
_busy = true;
_error = null;
});
final ok = await widget.onRestore(_phrase.text);
if (!mounted) return;
setState(() {
_busy = false;
if (!ok) _error = '복구 코드가 올바르지 않아요. 단어 12개를 순서대로 적거나, 친구 조각을 필요한 수만큼 붙여 넣어 주세요.';
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(24),
children: [
const SizedBox(height: 48),
const Text('Friendly Map', style: TextStyle(fontSize: 30, fontWeight: FontWeight.w800, color: Pal.accent)),
const SizedBox(height: 12),
const Text(
'내 인맥을 전방위적으로 확인하는 앱\n가족, 친구, 직장, 학교를 한 지도에서.',
style: TextStyle(fontSize: 15, height: 1.6, color: Pal.muted),
),
const SizedBox(height: 12),
const Text(
'모든 정보는 이 기기 안에 암호화되어 저장되고, 공유한 것만 상대에게 갑니다.',
style: TextStyle(fontSize: 13, height: 1.6, color: Pal.muted),
),
const SizedBox(height: 40),
SizedBox(
height: 52,
child: FilledButton(
onPressed: _busy ? null : _create,
child: const Text('새로 시작하기', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
),
),
const SizedBox(height: 12),
SizedBox(
height: 52,
child: OutlinedButton(
onPressed: _busy ? null : () => setState(() => _restoring = !_restoring),
child: const Text('복구 코드로 되살리기'),
),
),
if (_restoring) ...[
const SizedBox(height: 16),
TextField(
controller: _phrase,
minLines: 3,
maxLines: 4,
autocorrect: false,
enableSuggestions: false,
decoration: const InputDecoration(labelText: '복구 코드 (단어 12개) 또는 친구가 준 조각', hintText: 'word1 word2 word3 ... / fmr1.… 한 줄에 하나'),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_error!, style: const TextStyle(color: Pal.danger, fontSize: 13)),
),
const SizedBox(height: 12),
SizedBox(
height: 48,
child: FilledButton.tonal(onPressed: _busy ? null : _restore, child: const Text('되살리기')),
),
],
],
),
),
);
}
}
/// 새로 만든 복구 코드(단어 12개)를 보여 준다. 적어 두었다고 확인해야 넘어간다.
class RecoveryCodeScreen extends StatefulWidget {
const RecoveryCodeScreen({super.key, required this.phrase, required this.onDone, this.readOnly = false});
final String phrase;
final VoidCallback onDone;
/// 설정에서 다시 볼 때는 확인 없이 닫기만 한다.
final bool readOnly;
@override
State<RecoveryCodeScreen> createState() => _RecoveryCodeScreenState();
}
class _RecoveryCodeScreenState extends State<RecoveryCodeScreen> {
bool _noted = false;
@override
Widget build(BuildContext context) {
final words = widget.phrase.split(' ');
return Scaffold(
appBar: AppBar(title: const Text('복구 코드')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
const Text(
'이 단어 12개가 내 모든 정보의 열쇠예요.\n기기를 잃어버리면 이 코드로만 되살릴 수 있고, 코드를 잃으면 방법이 없어요.',
style: TextStyle(color: Pal.danger, height: 1.6),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var i = 0; i < words.length; i++)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Pal.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Pal.line),
),
child: Text('${i + 1}. ${words[i]}', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
),
],
),
const SizedBox(height: 20),
const Text('종이에 적어 안전한 곳에 두세요. 화면 캡처나 메신저로 보내지 마세요.',
style: TextStyle(color: Pal.muted, fontSize: 13, height: 1.5)),
const SizedBox(height: 12),
if (!widget.readOnly)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: _noted,
onChanged: (v) => setState(() => _noted = v ?? false),
title: const Text('적어 두었어요'),
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 8),
SizedBox(
height: 52,
child: FilledButton(
onPressed: (widget.readOnly || _noted) ? widget.onDone : null,
child: Text(widget.readOnly ? '닫기' : '시작하기'),
),
),
],
),
);
}
}

View File

@ -3,11 +3,10 @@ import 'package:friendly_map/modules/app/app.dart';
import 'bootstrap.dart'; import 'bootstrap.dart';
import 'screens/home_shell.dart'; import 'screens/home_shell.dart';
import 'screens/profile_setup_screen.dart'; import 'screens/onboarding_screen.dart';
import 'screens/welcome_screen.dart';
/// 앱이 열리기까지의 순서: 키 확인 → (처음이면) 새로 시작/복구 → 앱 열기 → 내 정보 정하기 → 홈. /// 앱이 열리기까지: 내 정보가 있으면 바로 채팅으로. 처음이면 이름·번호만 받고 키는 조용히 만든다.
/// 앱 내부가 열리면 [onOpened] 로 알려서 위쪽이 AppScope 를 씌우게 한다. /// 복구 코드나 보관 키는 없다. 앱 내부가 열리면 [onOpened] 로 알려서 위쪽이 AppScope 를 씌우게 한다.
class StartupGate extends StatefulWidget { class StartupGate extends StatefulWidget {
const StartupGate({super.key, required this.bootstrap, required this.onOpened}); const StartupGate({super.key, required this.bootstrap, required this.onOpened});
@ -18,11 +17,10 @@ class StartupGate extends StatefulWidget {
State<StartupGate> createState() => _StartupGateState(); State<StartupGate> createState() => _StartupGateState();
} }
enum _Stage { checking, welcome, code, profile, home } enum _Stage { checking, onboarding, home }
class _StartupGateState extends State<StartupGate> { class _StartupGateState extends State<StartupGate> {
_Stage _stage = _Stage.checking; _Stage _stage = _Stage.checking;
String _phrase = '';
FriendlyApp? _app; FriendlyApp? _app;
@override @override
@ -33,56 +31,40 @@ class _StartupGateState extends State<StartupGate> {
Future<void> _begin() async { Future<void> _begin() async {
if (await widget.bootstrap.hasKeys()) { if (await widget.bootstrap.hasKeys()) {
await _open(); final app = await _open();
} else if (mounted) { if (await app.profile.isSetUp()) {
setState(() => _stage = _Stage.welcome); await app.start();
if (mounted) setState(() => _stage = _Stage.home);
return;
}
} }
if (mounted) setState(() => _stage = _Stage.onboarding);
} }
Future<void> _open() async { Future<FriendlyApp> _open() async {
final app = await widget.bootstrap.openApp(); final app = _app ?? await widget.bootstrap.openApp();
if (!mounted) return; if (_app == null) {
_app = app; _app = app;
widget.onOpened(app); widget.onOpened(app);
final ready = await app.profile.isSetUp(); }
if (!mounted) return; return app;
if (ready) await app.start();
if (mounted) setState(() => _stage = ready ? _Stage.home : _Stage.profile);
} }
Future<void> _profileDone() async { /// 이름·번호를 받아 시작한다. 문제가 있으면 안내 글.
await _app?.start(); Future<String?> _start(String phone, String name) async {
if (!await widget.bootstrap.hasKeys()) await widget.bootstrap.createKeys();
final app = await _open();
final p = await app.profile.setUp(phone, name);
if (p == null) return '번호(010으로 시작)와 이름을 확인해 주세요.';
await app.start();
if (mounted) setState(() => _stage = _Stage.home); if (mounted) setState(() => _stage = _Stage.home);
return null;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => switch (_stage) {
switch (_stage) { _Stage.checking => const Scaffold(body: Center(child: CircularProgressIndicator())),
case _Stage.checking: _Stage.onboarding => OnboardingScreen(onStart: _start),
return const Scaffold(body: Center(child: CircularProgressIndicator())); _Stage.home => const HomeShell(),
case _Stage.welcome: };
return WelcomeScreen(
onCreate: () async {
final phrase = await widget.bootstrap.createKeys();
if (mounted) {
setState(() {
_phrase = phrase;
_stage = _Stage.code;
});
}
},
onRestore: (phrase) async {
if (!await widget.bootstrap.restoreKeys(phrase)) return false;
await _open();
return true;
},
);
case _Stage.code:
return RecoveryCodeScreen(phrase: _phrase, onDone: _open);
case _Stage.profile:
return ProfileSetupScreen(onDone: _profileDone);
case _Stage.home:
return const HomeShell();
}
}
} }

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
class Pal { class Pal {
static const bg = Color(0xFF121417); static const bg = Color(0xFF121417);
static const surface = Color(0xFF1B1E23); static const surface = Color(0xFF1B1E23);
static const raised = Color(0xFF262A31);
static const line = Color(0xFF2E333B); static const line = Color(0xFF2E333B);
static const text = Color(0xFFECEEF1); static const text = Color(0xFFECEEF1);
static const muted = Color(0xFFA3AAB5); static const muted = Color(0xFFA3AAB5);
@ -12,6 +13,8 @@ class Pal {
static const school = Color(0xFF5CC8BA); static const school = Color(0xFF5CC8BA);
static const group = Color(0xFFF28B82); static const group = Color(0xFFF28B82);
static const danger = Color(0xFFF28B82); static const danger = Color(0xFFF28B82);
static const urgent = Color(0xFFE5484D);
static const ok = Color(0xFF5CC8BA);
static Color ofKind(String kind) => switch (kind) { static Color ofKind(String kind) => switch (kind) {
'company' => company, 'company' => company,
@ -22,39 +25,81 @@ class Pal {
}; };
} }
/// 안드로이드 기본 글꼴 (한글은 시스템이 Noto Sans CJK 로 채운다). 테마의 모든 글자 모양에 같이 쓴다.
const _font = 'Roboto';
ThemeData buildTheme() { ThemeData buildTheme() {
final base = ColorScheme.fromSeed( final base = ColorScheme.fromSeed(
seedColor: Pal.accent, seedColor: Pal.accent,
brightness: Brightness.dark, brightness: Brightness.dark,
).copyWith( ).copyWith(
surface: Pal.bg, surface: Pal.bg,
surfaceContainerHigh: Pal.surface,
surfaceContainerHighest: Pal.raised,
primary: Pal.accent, primary: Pal.accent,
onPrimary: Colors.black, onPrimary: Colors.black,
onSurface: Pal.text, onSurface: Pal.text,
onSurfaceVariant: Pal.muted,
outline: Pal.line, outline: Pal.line,
outlineVariant: Pal.line,
error: Pal.danger,
); );
final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(12));
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
fontFamily: _font,
colorScheme: base, colorScheme: base,
scaffoldBackgroundColor: Pal.bg, scaffoldBackgroundColor: Pal.bg,
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
backgroundColor: Pal.bg, backgroundColor: Pal.bg,
foregroundColor: Pal.text, foregroundColor: Pal.text,
surfaceTintColor: Colors.transparent,
elevation: 0, elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: TextStyle(fontFamily: _font, fontSize: 20, fontWeight: FontWeight.w700, color: Pal.text),
), ),
navigationBarTheme: const NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: Pal.surface, backgroundColor: Pal.surface,
indicatorColor: Color(0x33F5B83D), indicatorColor: const Color(0x33F5B83D),
height: 64,
labelTextStyle: WidgetStateProperty.resolveWith(
(s) => TextStyle(fontFamily: _font, fontSize: 12, fontWeight: s.contains(WidgetState.selected) ? FontWeight.w700 : FontWeight.w500),
),
), ),
listTileTheme: const ListTileThemeData(
contentPadding: EdgeInsets.symmetric(horizontal: 20),
minVerticalPadding: 10,
titleTextStyle: TextStyle(fontFamily: _font, fontSize: 16, fontWeight: FontWeight.w600, color: Pal.text),
subtitleTextStyle: TextStyle(fontFamily: _font, fontSize: 13, color: Pal.muted),
iconColor: Pal.muted,
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(backgroundColor: Pal.accent, foregroundColor: Colors.black, elevation: 2, highlightElevation: 4),
filledButtonTheme: FilledButtonThemeData(style: FilledButton.styleFrom(minimumSize: const Size(64, 48), shape: shape, textStyle: const TextStyle(fontFamily: _font, fontSize: 15, fontWeight: FontWeight.w700))),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(minimumSize: const Size(64, 48), shape: shape, side: const BorderSide(color: Pal.line), foregroundColor: Pal.text),
),
textButtonTheme: TextButtonThemeData(style: TextButton.styleFrom(foregroundColor: Pal.accent, textStyle: const TextStyle(fontFamily: _font, fontWeight: FontWeight.w600))),
chipTheme: ChipThemeData(
backgroundColor: Pal.surface,
selectedColor: const Color(0x33F5B83D),
side: const BorderSide(color: Pal.line),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
labelStyle: const TextStyle(fontFamily: _font, fontSize: 13),
showCheckmark: false,
),
dialogTheme: DialogThemeData(backgroundColor: Pal.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))),
bottomSheetTheme: const BottomSheetThemeData(backgroundColor: Pal.surface, showDragHandle: true),
snackBarTheme: SnackBarThemeData(behavior: SnackBarBehavior.floating, backgroundColor: Pal.raised, contentTextStyle: const TextStyle(fontFamily: _font, color: Pal.text), shape: shape),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: Pal.surface, fillColor: Pal.surface,
border: OutlineInputBorder( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
borderRadius: BorderRadius.circular(12), border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Pal.line)),
borderSide: const BorderSide(color: Pal.line), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Pal.line)),
), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Pal.accent, width: 1.5)),
), ),
dividerTheme: const DividerThemeData(color: Pal.line, space: 1, thickness: 1),
dividerColor: Pal.line, dividerColor: Pal.line,
); );
} }

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/core/core.dart'; import 'package:friendly_map/modules/core/core.dart';
import 'package:friendly_map/modules/storage/storage.dart'; import 'package:friendly_map/modules/storage/storage.dart';
@ -124,7 +125,7 @@ class _PeoplePickerState extends State<_PeoplePicker> {
: (v) => setState(() => v == true ? _picked.add(p.id) : _picked.remove(p.id)), : (v) => setState(() => v == true ? _picked.add(p.id) : _picked.remove(p.id)),
secondary: Avatar(p.name, size: 36), secondary: Avatar(p.name, size: 36),
title: Text(p.name), title: Text(p.name),
subtitle: Text(widget.already.contains(p.id) ? '이미 들어 있어요' : p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)), subtitle: Text(widget.already.contains(p.id) ? '이미 들어 있어요' : displayPhone(p.phone), style: const TextStyle(fontSize: 12, color: Pal.muted)),
), ),
]), ]),
), ),

View File

@ -11,8 +11,8 @@ Future<Map<String, String>> fieldLabels(FriendlyApp app) async {
return out; return out;
} }
/// 값이 있는 항목만 "이름: 값" 줄로. /// 값이 있는 항목만 "이름: 값" 줄로. 전화번호는 010 형식으로.
List<MapEntry<String, String>> filledFields(Map<String, String> fields, Map<String, String> labels) => [ List<MapEntry<String, String>> filledFields(Map<String, String> fields, Map<String, String> labels) => [
for (final e in fields.entries) for (final e in fields.entries)
if (e.value.trim().isNotEmpty && e.key != 'former') MapEntry(labels[e.key] ?? e.key, e.value), if (e.value.trim().isNotEmpty && e.key != 'former') MapEntry(labels[e.key] ?? e.key, e.key == 'phone' ? displayPhone(e.value) : e.value),
]; ];

View File

@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import 'package:friendly_map/modules/work/work.dart';
import '../theme.dart';
/// 대화 한 줄: 보통 말풍선, 업무 카드, 긴급 호출 띠. [sender] 이름은 [showSender] 일 때만 말풍선 위에 보인다.
class MessageBubble extends StatelessWidget {
const MessageBubble(this.m, {super.key, this.sender, this.showSender = false, this.showTime = true, this.inTasks = false, this.onLongPress, this.onAddTask});
final Message m;
final String? sender;
final bool showSender; // 여럿이 있는 방에서 이어 보낸 첫 대화에만
final bool showTime;
final bool inTasks; // 이 업무가 이미 내 업무 목록에 있음
final VoidCallback? onLongPress;
final VoidCallback? onAddTask;
bool get mine => m.senderId == ChatService.me;
@override
Widget build(BuildContext context) {
final note = WorkService.noteOf(m.body);
final time = Text(clockText(DateTime.fromMillisecondsSinceEpoch(m.sentAt * 10)), style: const TextStyle(fontSize: 10, color: Pal.muted));
if (note is UrgentNote) return _urgent(note, time);
final maxW = MediaQuery.of(context).size.width * (note is TaskNote ? 0.8 : 0.72);
final body = note is TaskNote ? _task(note) : _text();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Column(crossAxisAlignment: mine ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [
if (showSender && sender != null && !mine) Padding(padding: const EdgeInsets.only(left: 4, bottom: 3), child: Text(sender!, style: const TextStyle(fontSize: 12, color: Pal.muted))),
Row(mainAxisAlignment: mine ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [
if (mine && showTime) Padding(padding: const EdgeInsets.only(right: 6), child: time),
GestureDetector(
onLongPress: onLongPress,
child: ConstrainedBox(constraints: BoxConstraints(maxWidth: maxW), child: body),
),
if (!mine && showTime) Padding(padding: const EdgeInsets.only(left: 6), child: time),
]),
]),
);
}
Widget _text() => Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: mine ? Pal.accent : Pal.raised,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(18),
topRight: const Radius.circular(18),
bottomLeft: Radius.circular(mine ? 18 : 4),
bottomRight: Radius.circular(mine ? 4 : 18),
),
),
child: Text(m.body, style: TextStyle(fontSize: 15, height: 1.4, color: mine ? Colors.black : Pal.text)),
);
Widget _task(TaskNote n) => Container(
decoration: BoxDecoration(color: Pal.surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: Pal.accent.withValues(alpha: 0.6))),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(14, 8, 14, 8),
decoration: BoxDecoration(color: Pal.accent.withValues(alpha: 0.14), borderRadius: const BorderRadius.vertical(top: Radius.circular(15))),
child: const Row(children: [
Icon(Icons.task_alt, size: 16, color: Pal.accent),
SizedBox(width: 6),
Text('업무', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: Pal.accent)),
]),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
child: Text(n.title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, height: 1.35)),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
child: Text(
[
'담당 ${n.assignee ?? '없음'}',
if (n.due != null) '마감 ${n.due!.month}/${n.due!.day} (${dueText(n.due!)})',
].join(' · '),
style: const TextStyle(fontSize: 12, color: Pal.muted),
),
),
if (!mine)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
child: inTasks
? const Padding(
padding: EdgeInsets.all(6),
child: Row(children: [Icon(Icons.check, size: 15, color: Pal.ok), SizedBox(width: 4), Text('업무 목록에 있어요', style: TextStyle(fontSize: 12, color: Pal.ok))]),
)
: TextButton.icon(onPressed: onAddTask, icon: const Icon(Icons.add, size: 16), label: const Text('업무 목록에 넣기')),
),
]),
);
Widget _urgent(UrgentNote n, Widget time) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Container(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
decoration: BoxDecoration(color: Pal.urgent.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(14), border: Border.all(color: Pal.urgent)),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Icon(Icons.campaign, color: Pal.urgent),
const SizedBox(width: 10),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(mine ? '긴급 호출을 보냈어요' : '${sender ?? '상대'}님의 긴급 호출', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: Pal.urgent)),
const SizedBox(height: 4),
Text(n.text.isEmpty ? '확인이 필요해요' : n.text, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
]),
),
time,
]),
),
);
}
/// 날짜 구분선.
class DaySeparator extends StatelessWidget {
const DaySeparator(this.day, {super.key});
final DateTime day;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(color: Pal.surface, borderRadius: BorderRadius.circular(12)),
child: Text(dayLabel(day), style: const TextStyle(fontSize: 12, color: Pal.muted)),
),
),
);
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/storage/storage.dart'; import 'package:friendly_map/modules/storage/storage.dart';
import '../theme.dart'; import '../theme.dart';
@ -27,7 +28,7 @@ Future<Person?> pickPerson(BuildContext context, List<Person> people, {String ti
minTileHeight: 56, minTileHeight: 56,
leading: Avatar(p.name, size: 40), leading: Avatar(p.name, size: 40),
title: Text(p.name), title: Text(p.name),
subtitle: Text(p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)), subtitle: Text(displayPhone(p.phone), style: const TextStyle(fontSize: 12, color: Pal.muted)),
onTap: () => Navigator.pop(c, p), onTap: () => Navigator.pop(c, p),
), ),
]), ]),

View File

@ -0,0 +1,93 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/transport/transport.dart';
import 'support/app_device.dart';
Future<void> befriend(AppDevice a, AppDevice b) async {
await a.app.friends.add(b.phone, b.name);
await AppDevice.settleAll();
await b.app.friends.accept(a.phone);
await AppDevice.settleAll();
}
void main() {
group('보여 주기', () {
test('전화번호는 010-0000-0000 처럼', () {
expect(displayPhone('+821033334444'), '010-3333-4444');
expect(displayPhone('+82313334444'), '031-333-4444');
expect(displayPhone('+8225551234'), '02-555-1234');
expect(displayPhone('+82255512345'), '02-5551-2345');
expect(displayPhone('+14155550000'), '+14155550000');
expect(displayPhone('local:abc'), '');
});
test('목록 시각: 오늘은 시:분, 어제, 올해는 월 일, 그 전은 연도까지', () {
final now = DateTime(2026, 10, 26, 18);
int s(DateTime t) => t.millisecondsSinceEpoch ~/ 10;
expect(listTime(s(DateTime(2026, 10, 26, 15, 5)), now: now), '오후 3:05');
expect(listTime(s(DateTime(2026, 10, 26, 0, 7)), now: now), '오전 12:07');
expect(listTime(s(DateTime(2026, 10, 25, 23)), now: now), '어제');
expect(listTime(s(DateTime(2026, 3, 1)), now: now), '3월 1일');
expect(listTime(s(DateTime(2025, 3, 1)), now: now), '2025. 3. 1.');
expect(dayLabel(DateTime(2026, 10, 26)), '2026년 10월 26일 월요일');
expect(dueText(DateTime(2026, 10, 30), now: now), 'D-4');
expect(dueText(DateTime(2026, 10, 26), now: now), '오늘 마감');
expect(dueText(DateTime(2026, 10, 24), now: now), '2일 지남');
});
test('미리보기: 업무·긴급 표시는 읽는 말로', () {
expect(previewOf('📌 업무 [김민수] 결제 API 정리 (마감 10/30)'), '업무 · 결제 API 정리 (담당 김민수)');
expect(previewOf('🚨 긴급 호출: 서버가 멈췄어요'), '긴급 호출 · 서버가 멈췄어요');
expect(previewOf('안녕\n하세요'), '안녕 하세요');
});
});
group('새 기기로 다시 연결 · 영구 제명', () {
late MemoryHub hub;
late AppDevice me, old;
setUp(() async {
hub = MemoryHub();
me = await AppDevice.create(hub, '010-1111-2222', '+821011112222', '나');
old = await AppDevice.create(hub, '010-3333-4444', '+821033334444', '김민수');
await befriend(me, old);
});
test('관계가 있던 번호가 새 키로 오면 "다시 연결 요청", 승인하면 새 키로 이어진다', () async {
final known = (await me.app.deps.storage.people.byPhone(old.phone))!;
final oldKey = known.publicKey;
final fresh = await AppDevice.create(hub, '010-3333-4444', '+821033334444', '김민수새폰');
await fresh.app.friends.add(me.phone, '나');
await AppDevice.settleAll();
final reqs = await me.app.blocks.incoming();
expect(reqs.single.reconnect, isTrue);
expect(reqs.single.known!.id, known.id);
await me.app.blocks.approve(old.phone);
final after = (await me.app.deps.storage.people.byPhone(old.phone))!;
expect(after.id, known.id, reason: '같은 사람으로 이어진다');
expect(after.publicKey, isNot(oldKey));
});
test('제명하면 요청이 사라지고 그 번호의 인사는 다시 받지 않는다', () async {
final fresh = await AppDevice.create(hub, '010-3333-4444', '+821033334444', '김민수새폰2');
await fresh.app.friends.add(me.phone, '나');
await AppDevice.settleAll();
await me.app.blocks.expel(old.phone);
expect(await me.app.blocks.incoming(), isEmpty);
expect(await me.app.blocks.isBlocked(old.phone), isTrue);
await fresh.app.friends.add(me.phone, '나');
await AppDevice.settleAll();
expect(await me.app.blocks.incoming(), isEmpty, reason: '다시 청해도 안 들어온다');
await me.app.blocks.unblock(old.phone);
expect(await me.app.blocks.isBlocked(old.phone), isFalse);
});
test('처음 보는 사람의 요청은 다시 연결이 아니다', () async {
final stranger = await AppDevice.create(hub, '010-9999-0000', '+821099990000', '처음');
await stranger.app.friends.add(me.phone, '나');
await AppDevice.settleAll();
expect((await me.app.blocks.incoming()).single.reconnect, isFalse);
});
});
}

View File

@ -1,11 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:friendly_map/modules/app/app.dart'; import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import 'package:friendly_map/modules/transport/transport.dart'; import 'package:friendly_map/modules/transport/transport.dart';
import 'package:friendly_map/modules/ui/src/screens/person_screen.dart'; import 'package:friendly_map/modules/ui/src/screens/person_screen.dart';
import 'package:friendly_map/modules/ui/ui.dart'; import 'package:friendly_map/modules/ui/ui.dart';
import 'support/app_device.dart'; import 'support/app_device.dart';
import 'support/fake_cipher.dart';
import 'support/storage_contract.dart' show FakeProbe;
/// 키·저장소를 어떻게 여는지는 화면이 모른다. 시험에서는 메모리 기기로 대신한다. /// 키·저장소를 어떻게 여는지는 화면이 모른다. 시험에서는 메모리 기기로 대신한다.
class FakeBootstrap implements Bootstrap { class FakeBootstrap implements Bootstrap {
@ -33,50 +36,75 @@ class FakeBootstrap implements Bootstrap {
} }
void main() { void main() {
testWidgets('키가 없으면 처음 화면이 보인다', (tester) async { Future<FriendlyApp> openWithFriend(WidgetTester tester) async {
await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap()));
await tester.pumpAndSettle();
expect(find.text('새로 시작하기'), findsOneWidget);
});
testWidgets('내 정보가 있으면 홈과 아래 메뉴가 보이고 칸을 옮길 수 있다', (tester) async {
final device = await tester.runAsync(() => AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '이미영', start: false)); final device = await tester.runAsync(() => AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '이미영', start: false));
await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap(keys: true, app: device!.app))); await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap(keys: true, app: device!.app)));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
return device.app;
}
Future<void> tab(WidgetTester tester, String label) async {
await tester.tap(find.descendant(of: find.byType(NavigationBar), matching: find.text(label)));
await tester.pumpAndSettle();
}
testWidgets('처음 시작: 복구 코드 없이 이름·번호만 넣으면 바로 채팅 화면', (tester) async {
final app = FriendlyApp(AppDeps(
storage: Storage.memory(),
transport: MemoryTransport('+821011112222', MemoryHub()),
cipher: FakeCipher('K'),
identity: FakeIdentity('K'),
probe: FakeProbe(10),
));
final boot = FakeBootstrap(app: app);
await tester.pumpWidget(FriendlyMapApp(bootstrap: boot));
await tester.pumpAndSettle();
expect(find.text('시작하기'), findsOneWidget);
expect(find.textContaining('복구'), findsNothing);
await tester.enterText(find.byType(TextField).at(0), '이미영');
await tester.enterText(find.byType(TextField).at(1), '010-1111-2222');
await tester.pump();
await tester.tap(find.text('시작하기'));
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
await tester.pumpAndSettle();
expect(boot.keys, isTrue, reason: '키는 조용히 만들어진다');
expect(find.byType(NavigationBar), findsOneWidget); expect(find.byType(NavigationBar), findsOneWidget);
await tester.tap(find.descendant(of: find.byType(NavigationBar), matching: find.text('명함'))); expect(find.text('아직 대화가 없어요'), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('내 명함'), findsWidgets);
}); });
testWidgets('설정에서 그룹 관리를 열면 처음 그룹 네 개가 보이고, 새 그룹을 더하고, 학교 안에 하위 그룹이 보인다', (tester) async { testWidgets('아래 메뉴: 채팅 · 관계 · 업무 · 명함 · 나', (tester) async {
final device = await tester.runAsync(() => AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '이미영', start: false)); await openWithFriend(tester);
await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap(keys: true, app: device!.app))); for (final l in ['채팅', '관계', '업무', '명함', '나']) {
await tester.pumpAndSettle(); expect(find.descendant(of: find.byType(NavigationBar), matching: find.text(l)), findsOneWidget);
await tester.tap(find.descendant(of: find.byType(NavigationBar), matching: find.text('내 정보'))); }
await tester.pumpAndSettle(); await tab(tester, '명함');
await tester.tap(find.text('저장·보안')); expect(find.text('내 명함'), findsWidgets);
await tester.pumpAndSettle(); await tab(tester, '나');
await tester.scrollUntilVisible(find.text('그룹 관리'), 200, scrollable: find.byType(Scrollable).last); expect(find.text('010-1111-2222'), findsOneWidget, reason: '번호는 010 형식으로');
await tester.tap(find.text('그룹 관리')); });
await tester.pumpAndSettle();
testWidgets('관계 탭: 그룹 네 개, 새 그룹 더하기, 학교 안에 하위 그룹', (tester) async {
await openWithFriend(tester);
await tab(tester, '관계');
for (final n in ['가족', '학교', '회사', '그룹·여행']) { for (final n in ['가족', '학교', '회사', '그룹·여행']) {
expect(find.text(n), findsOneWidget); expect(find.text(n), findsOneWidget);
} }
await tester.tap(find.byTooltip('새 그룹')); await tester.tap(find.text('새 그룹'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '등산 모임'); await tester.enterText(find.byType(TextField).last, '등산 모임');
await tester.tap(find.text('확인')); await tester.tap(find.text('확인'));
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50))); await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('등산 모임'), 200, scrollable: find.byType(Scrollable).first);
expect(find.text('등산 모임'), findsOneWidget); expect(find.text('등산 모임'), findsOneWidget);
await tester.scrollUntilVisible(find.text('학교'), -200, scrollable: find.byType(Scrollable).first);
await tester.tap(find.text('학교')); await tester.tap(find.text('학교'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('고등학교'), findsOneWidget); expect(find.text('고등학교'), findsOneWidget);
expect(find.text('대학원'), findsOneWidget); expect(find.text('대학원'), findsOneWidget);
}); });
testWidgets('사람 화면의 관계 정하기로 학교 › 고등학교에 넣으면 들어간 그룹이 보인다', (tester) async { testWidgets('사람 화면의 관계 버튼으로 학교 › 고등학교에 넣으면 들어간 그룹이 보인다', (tester) async {
final device = await tester.runAsync(() => AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '이미영', start: false)); final device = await tester.runAsync(() => AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '이미영', start: false));
final app = device!.app; final app = device!.app;
final friend = await tester.runAsync(() => app.friends.add('+821033334444', '김민수')); final friend = await tester.runAsync(() => app.friends.add('+821033334444', '김민수'));
@ -84,8 +112,9 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
tester.state<NavigatorState>(find.byType(Navigator).first).push(MaterialPageRoute<void>(builder: (_) => PersonScreen(personId: friend!.id))); tester.state<NavigatorState>(find.byType(Navigator).first).push(MaterialPageRoute<void>(builder: (_) => PersonScreen(personId: friend!.id)));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('010-3333-4444'), findsOneWidget);
expect(find.textContaining('관계 정하기 —'), findsOneWidget); expect(find.textContaining('관계 정하기 —'), findsOneWidget);
await tester.tap(find.byTooltip('관계 정하기')); await tester.tap(find.text('관계'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.textContaining('학교 동문')); await tester.tap(find.textContaining('학교 동문'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
test_ux/shots/01_chats.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

BIN
test_ux/shots/03_tasks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
test_ux/shots/04_cards.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

BIN
test_ux/shots/05_me.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

BIN
test_ux/shots/21_person.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
test_ux/shots/22_relate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

159
test_ux/shots_test.dart Normal file
View File

@ -0,0 +1,159 @@
// 화면 점검용 캡처 (일반 시험이 아니다). 폰 크기(360×800)로 주요 화면을 그려 test_ux/shots/*.png 로 남긴다.
// flutter test test_ux/shots_test.dart --update-goldens
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/core/core.dart';
import 'package:friendly_map/modules/platform/platform.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import 'package:friendly_map/modules/transport/transport.dart';
import 'package:friendly_map/modules/ui/src/screens/chat_room_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/contact_import_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/group_detail_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/person_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/requests_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/settings_screen.dart';
import 'package:friendly_map/modules/ui/ui.dart';
import '../test/support/app_device.dart';
import '../test/support/fake_cipher.dart';
import '../test/support/storage_contract.dart' show FakeProbe;
import '../test/widget_test.dart' show FakeBootstrap;
class _Contacts implements ContactsProvider {
@override
Future<bool> requestPermission() async => true;
@override
Future<List<DeviceContact>> all() async => const [
DeviceContact(id: '1', name: '김민수', phones: ['010-3333-4444'], labels: ['가족']),
DeviceContact(id: '2', name: '이영희', phones: ['010-5555-6666'], labels: ['고등학교 동창']),
DeviceContact(id: '3', name: '박철수', phones: ['010-7777-8888'], labels: ['회사']),
DeviceContact(id: '4', name: '최지은', phones: ['010-1212-3434']),
DeviceContact(id: '5', name: '정우성', phones: ['02-555-1234']),
];
}
Future<void> _font(String family, String path) async {
final bytes = File(path).readAsBytesSync();
await (FontLoader(family)..addFont(Future.value(ByteData.view(Uint8List.fromList(bytes).buffer)))).load();
}
void main() {
late AppDevice me, minsu;
late FriendlyApp app;
setUpAll(() async {
await _font('Roboto', 'build/ux/kr.ttf');
await _font('MaterialIcons', '/sdks/flutter/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf');
});
Future<void> seed(WidgetTester tester) async {
await tester.runAsync(() async {
final hub = MemoryHub();
me = await AppDevice.create(hub, '010-1111-2222', '+821011112222', '이미영', contacts: _Contacts());
minsu = await AppDevice.create(hub, '010-3333-4444', '+821033334444', '김민수');
app = me.app;
await app.friends.add(minsu.phone, minsu.name);
await AppDevice.settleAll();
await minsu.app.friends.accept(me.phone);
await AppDevice.settleAll();
for (final (p, n) in [('+821055556666', '이영희'), ('+821077778888', '박철수'), ('+821012123434', '최지은')]) {
await app.friends.add(p, n);
}
final people = {for (final p in await app.friends.people()) p.name: p.id};
await app.relate.into(people['이영희']!, 'school.high');
final hb = (await app.groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!;
final team = (await app.groups.add('결제팀', CardKinds.company, parent: hb.id))!;
await app.relate.into(people['박철수']!, team.id);
await app.relate.into(people['김민수']!, team.id);
final room = (await app.chats.openDirect(people['김민수']!, CardKinds.personal))!;
await app.chats.send(room.id, '안녕하세요, 내일 회의 몇 시죠?');
await app.work.assign(room.id, '결제 API 정리', assigneeId: people['김민수'], due: DateTime(2026, 10, 30));
await app.work.urgent(room.id, '결제 서버가 멈췄어요');
// 김민수가 새 폰으로 다시 연결을 청함
final again = await AppDevice.create(hub, '010-3333-4444', '+821033334444', '김민수 새폰');
await again.app.friends.add(me.phone, '이미영');
await AppDevice.settleAll();
});
}
Future<void> open(WidgetTester tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
await seed(tester);
await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap(keys: true, app: app)));
await tester.pumpAndSettle();
}
Future<void> shot(WidgetTester tester, String name) async {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await tester.pumpAndSettle();
await expectLater(find.byType(MaterialApp), matchesGoldenFile('shots/$name.png'));
}
Future<void> push(WidgetTester tester, Widget page) async {
tester.state<NavigatorState>(find.byType(Navigator).first).push(MaterialPageRoute<void>(builder: (_) => page));
await tester.pumpAndSettle();
}
Future<void> tab(WidgetTester tester, String label) async {
await tester.tap(find.descendant(of: find.byType(NavigationBar), matching: find.text(label)));
await tester.pumpAndSettle();
}
testWidgets('처음 시작', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
final fresh = FriendlyApp(AppDeps(storage: Storage.memory(), transport: MemoryTransport('+82100', MemoryHub()), cipher: FakeCipher('K'), identity: FakeIdentity('K'), probe: FakeProbe(10)));
await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap(app: fresh)));
await tester.pumpAndSettle();
await shot(tester, '00_onboarding');
});
testWidgets('아래 메뉴 다섯 칸', (tester) async {
await open(tester);
await shot(tester, '01_chats');
await tab(tester, '관계');
await shot(tester, '02_relations');
await tab(tester, '업무');
await shot(tester, '03_tasks');
await tab(tester, '명함');
await shot(tester, '04_cards');
await tab(tester, '나');
await shot(tester, '05_me');
});
testWidgets('요청 · 그룹 · 설정', (tester) async {
await open(tester);
await push(tester, const RequestsScreen());
await shot(tester, '10_requests');
await push(tester, const GroupDetailScreen(groupId: CardKinds.school));
await shot(tester, '11_group_school');
await push(tester, const GroupDetailScreen(groupId: CardKinds.company));
await shot(tester, '12_group_company');
await push(tester, const SettingsScreen());
await shot(tester, '13_settings');
});
testWidgets('주소록 · 사람 · 대화', (tester) async {
await open(tester);
await push(tester, const ContactImportScreen());
await shot(tester, '20_contacts');
final people = {for (final p in (await tester.runAsync(() => app.friends.people()))!) p.name: p.id};
await push(tester, PersonScreen(personId: people['김민수']!));
await shot(tester, '21_person');
await tester.tap(find.text('관계'));
await tester.pumpAndSettle();
await shot(tester, '22_relate');
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
final rooms = (await tester.runAsync(() => app.chats.summaries()))!;
await push(tester, ChatRoomScreen(roomId: rooms.first.room.id));
await shot(tester, '23_chat_room');
});
}

View File

@ -18,7 +18,7 @@ declare -A T=(
[inference]="inference_test.dart" [inference]="inference_test.dart"
[layout]="layout_test.dart" [layout]="layout_test.dart"
[sharing]="sharing_service_test.dart sharing_status_test.dart" [sharing]="sharing_service_test.dart sharing_status_test.dart"
[app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart group_service_test.dart contact_import_test.dart relate_service_test.dart work_service_test.dart career_service_test.dart" [app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart group_service_test.dart contact_import_test.dart relate_service_test.dart work_service_test.dart career_service_test.dart reconnect_display_test.dart"
[platform_android]="df_parser_test.dart" [platform_android]="df_parser_test.dart"
[ui]="widget_test.dart" [ui]="widget_test.dart"
) )

1
wiki/.obsidian/app.json Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,32 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}

View File

@ -0,0 +1,17 @@
# 최우선 규칙 (모든 규칙보다 위)
- **0순위: 모든 가이드·문서·결정·진행 기록·화면 점검은 이 wiki 보관함에 저장·관리한다.** 세션 시작 때 홈과 이 문서부터 읽고, 일할 때마다 진행 로그·결정 기록을 갱신한다.
- **질문은 설치 가능한 APK 를 먼저 건넨 뒤에만.** 사용자가 폰에 설치해 써 보며 답할 수 있게. 캡처도 함께 보여 주고 "이렇게 하면 / 저렇게 하면" 화면 비교로만 묻는다.
- 암호 비트 수·키 구조·함수 설계 같은 **내부 기술 질문 금지.** 내가 정하고 결과에 적는다.
- **책임 회피성 질문 금지** ("물었으니 사용자 책임").
- 방법론: 화면 먼저 → 화면 보고 사용자가 답 → 그 답에 따라 내부 아키텍처를 바꾼다.
- 묻지 말고 끝까지 진행. 목표는 APK. 멈춰서 대기하지 않는다.
## 일하는 규칙
- 모든 답변 한글. "목업" 대신 "화면 설계".
- 구조가 기능보다 먼저, 화면과 내부 분리, 모듈은 빼고 끼울 수 있게 (`tool/check_modules.py`).
- 파일 300~400줄, 최대 700줄. 모듈별 시험.
- 한 번에 한 플랫폼(안드로이드).
- 평소 Gitea 에 푸시, GitLab 은 하루 한 번 메이저 업그레이드 때만.
- 모든 기록은 이 Obsidian 보관함에도 남긴다.

9
wiki/01 제품 개요.md Normal file
View File

@ -0,0 +1,9 @@
# 제품 개요
- 기본은 **채팅**. 채팅하다 관계를 알게 되면 그룹으로 넣는다.
- 가족 → 촌수 맞춰 넣기(친가·외가·처가·시가), 모르면 "친척(촌수 모름)".
- 학교 → 초등·중등·고등·대학·대학원 하위 그룹.
- 회사 → 회사 › 프로젝트·팀, 팀 대화방, 업무 맡기기·정리, 긴급 호출.
- 이직 → 상대가 회사 명함을 다시 보내면 "이직했습니다" 알림, 확인하면 이전 회사는 흐리게·새 회사로 이동·삭제.
- 목적별 명함(개인/회사/학교/그룹·여행/가족), 공개 범위 표, "어디서 봤지?" 유추, 주소록 가져오기, 비밀번호 잠금 그룹.
- 모든 데이터는 기기 안 암호화(SQLCipher), 기기끼리 P2P, 서버는 암호화 우편함뿐.

12
wiki/02 결정 기록.md Normal file
View File

@ -0,0 +1,12 @@
# 결정 기록
| 날짜 | 결정 | 이유 |
|---|---|---|
| 2026-10-26 | 그룹·비밀번호 잠금(PBKDF2, 5번 틀리면 30초부터 두 배), 연 뒤 5분/앱 내리면 다시 잠금 | 사용자 요청: 비번 눌러야 열리는 그룹 |
| 2026-10-26 | 주소록 가져오기: 이름·초성·번호·주소록 라벨 거르기, 한 명/여러 명, 친구 요청은 선택(기본 끔) | 사용자 요청 |
| 2026-10-26 | 그룹 계층 4단, 학교·가족 기본 하위 그룹 | 채팅에서 시작하는 관계망 |
| 2026-10-26 | 동기화는 새 통신 형식 없이 명함 "다시 보내기"와 대화 메시지(📌 업무, 🚨 긴급)로 | 기존 결정(가족 데이터 기기 안, 고친 명함은 다시 보내기로만) 유지 |
| 2026-10-26 | **복구 코드·조각·보관 키 폐지.** 처음 시작은 이름·번호만, 키는 조용히 생성 | 사용자 결정: 복구 방안 없음, 탈퇴 후 재가입 없음 |
| 2026-10-26 | 새 폰에서 다시 시작 → 관계를 유지하는 사용자 각자가 **승인**(다시 연결) 또는 **제명**(번호 영구 차단) | 사용자 결정: 관리자 승인 |
| 2026-10-26 | 아래 메뉴: 채팅(첫 화면) · 관계 · 업무 · 명함 · 나. 옛 홈 타일·중복 화면 삭제 | UX 점검 결과 |
| 2026-10-26 | 질문은 APK·화면을 먼저 보여 준 뒤에만 | 사용자 최우선 지시 |

11
wiki/03 로드맵.md Normal file
View File

@ -0,0 +1,11 @@
# 로드맵
- [x] 그룹 정의·잠금·주소록 가져오기
- [x] 1단계 그룹 계층
- [x] 2단계 채팅에서 관계 정하기(촌수, 친가·외가·처가, 촌수 모름)
- [x] 3단계 업무: 팀 대화방, 업무 맡기기·정리, 긴급 호출
- [x] 4단계 이직 알림
- [x] 복구 폐지 → 다시 연결 승인·제명
- [x] UX 전면 개편 1차 (캡처 [[06 화면 점검]])
- [ ] APK 전달 → 사용자 설치·시험 → 화면 보며 피드백 반영
- [ ] 실제 기기 점검(주소록, 알림, 위치, QR, 같은 네트워크)

View File

@ -0,0 +1,17 @@
# 구조와 모듈
`lib/modules/<이름>/<이름>.dart` 가 공개 입구. 조립은 `lib/main.dart`, `lib/composition/android_bootstrap.dart`.
| 모듈 | 하는 일 |
|---|---|
| core | 모듈끼리의 약속(키·전송·암호·봉투·명함 항목) |
| platform / platform_android | 기기 기능 약속 / 안드로이드 구현(플러그인은 여기서만) |
| storage | 저장소(메모리·SQLCipher) |
| keys | 키 파생·암호 (복구 코드 화면은 폐지, 모듈은 남김) |
| sharing, sync, transport, discovery | 공유·동기화·전송·발견 |
| inference, visibility, layout | 유추·공개 범위·배치 |
| kinship, events | 촌수·쪽(친가/외가), 생일·기일 |
| groups | 그룹 나무·잠금·이전 구성원 (순수) |
| work | 📌 업무·🚨 긴급 표시, 업무 목록 (순수) |
| app | 서비스 묶음 FriendlyApp (groups, contacts, relate, work, career, blocks …) |
| ui | 화면 |

View File

@ -0,0 +1,12 @@
# 빌드 · 시험 · 저장소
```
podman run --rm -v "$PWD":/app -v fm-gradle:/root/.gradle -v fm-pub:/root/.pub-cache -w /app ghcr.io/cirruslabs/flutter:stable bash
python3 tool/android_patch.py
flutter pub get && flutter analyze && flutter test
flutter build apk --debug # build/app/outputs/flutter-apk/app-debug.apk
flutter test test_ux/shots_test.dart --update-goldens # 화면 캡처 test_ux/shots/
```
- 빌드할 때 시험을 같이 돌리지 않는다(podman 머신 8GB, Gradle 데몬이 꺼진 적 있음).
- 푸시: `git push origin main` 단독 명령(샌드박스 제외 설정). Gitea 토큰은 키체인.
- VS Code 터미널의 git 은 아이디를 창 맨 위 입력칸으로 묻는다.

29
wiki/06 화면 점검.md Normal file
View File

@ -0,0 +1,29 @@
# 화면 점검
## 2026-10-26 개편 전 (지적: "초딩 수준도 안 됨")
![[화면 점검/2026-10-26 개편 전/01_home.png|250]] ![[화면 점검/2026-10-26 개편 전/02_chats.png|250]] ![[화면 점검/2026-10-26 개편 전/23_chat_room.png|250]] ![[화면 점검/2026-10-26 개편 전/21_person.png|250]]
문제:
1. 홈이 채팅 중심이 아님, 정보 없는 타일 10개
2. 회사·학교·그룹 타일과 그룹 관리가 두 벌
3. 그룹이 저장·보안 설정 안에 숨어 있음
4. 업무·긴급이 날것의 글자, 미리보기도 같음
5. 번호 +82 형식
6. 뜻 모를 아이콘
7. 사람 화면에 상관없는 칸
8. 명함 칸 반복, 버튼이 가림
9. 글자 줄바꿈·잘림·설명문 과다
## 2026-10-26 개편 후
![[화면 점검/2026-10-26 개편 후/00_onboarding.png|220]] ![[화면 점검/2026-10-26 개편 후/01_chats.png|220]] ![[화면 점검/2026-10-26 개편 후/02_relations.png|220]] ![[화면 점검/2026-10-26 개편 후/03_tasks.png|220]] ![[화면 점검/2026-10-26 개편 후/04_cards.png|220]] ![[화면 점검/2026-10-26 개편 후/05_me.png|220]] ![[화면 점검/2026-10-26 개편 후/10_requests.png|220]] ![[화면 점검/2026-10-26 개편 후/11_group_school.png|220]] ![[화면 점검/2026-10-26 개편 후/20_contacts.png|220]] ![[화면 점검/2026-10-26 개편 후/21_person.png|220]] ![[화면 점검/2026-10-26 개편 후/22_relate.png|220]] ![[화면 점검/2026-10-26 개편 후/23_chat_room.png|220]]
바뀐 것:
- 처음 시작: 이름·번호만 (복구 코드 없음)
- 아래 메뉴 채팅(첫 화면) · 관계 · 업무 · 명함 · 나, 배지(관계=받은 요청, 업무=내 업무)
- 채팅: 다시 연결 요청 띠, 이직 알림, 미리보기를 읽는 말로(긴급은 빨간색), 시각 "오전 11:29/어제/10월 24일"
- 대화방: 날짜 구분선, 업무 카드, 긴급 호출 빨간 띠, + 버튼(업무 맡기기·긴급 호출), ⋮ 메뉴(프로필·관계·이 방 업무·보관)
- 관계: 사람 찾기, 빠른 버튼(모든 사람·가계도·주소록·흔들기), 그룹 나무, "이런 사이 아닐까요?"
- 프로필: 큰 이름·010 번호·동그란 행동 버튼, 해당 없는 칸 숨김
- 나: 설정 목록(공개 범위·요청 받기·막기·위치·잠금 시간·제명한 번호·알림·저장 공간)
- 번호는 모든 곳에서 010-0000-0000
- 캡처 글꼴 문제: 테마 글자 모양에 글꼴 이름을 명시해 캡처=폰 화면

View File

@ -0,0 +1,136 @@
# 기능 안내
> 이 문서가 기준이다. 저장소의 README.md 는 이 문서를 가리키기만 한다.
가족·친구·직장 관계를 한 앱에서. 새 친구가 "어디서 봤지?" 싶을 때 목적별 명함을 주고받아 관계를 유추한다.
안드로이드 우선, Flutter. 모든 기기가 서버 겸 클라이언트(P2P)이고, 외부 DB는 암호화된 임시 우편함일 뿐이다.
## 원칙 (1순위)
1. 구조가 기능보다 먼저다. 화면과 내부를 분리한다.
2. 모듈은 언제든 빼고 끼울 수 있다. 기능을 더하거나 빼도 다른 모듈을 고치지 않는다.
3. 파일은 300~400줄, 최대 700줄.
4. 모듈별로 시험한다. 시험이 끝난 모듈은 다시 만들지 않고 그 위에 쌓는다.
5. 한 번에 한 플랫폼. 지금은 안드로이드. 다른 플랫폼은 `platform_<이름>` 모듈만 새로 붙인다.
## 모듈과 의존 방향 (`tool/check_modules.py` 가 검사)
| 모듈 | 하는 일 | 쓸 수 있는 모듈 |
|---|---|---|
| core | 모듈끼리 주고받는 약속(키·전송·암호·봉투·명함 항목 이름) | 없음 |
| platform | 기기마다 다른 기능의 약속(보안 저장소, 저장 공간, 센서, QR, 발견, 위치, 통화 기록·전화, 알림, 주소록) | 없음 |
| platform_android | platform 의 안드로이드 구현(플러그인은 여기서만) | core, platform, storage |
| storage | 저장소 약속 + 메모리/SQLite 구현 | platform |
| keys | 키 파생, 둘만 아는 키 암호 (복구 코드·조각 기능은 화면에서 폐지, 코드는 남김) | core, platform |
| sharing | 명함·대화방이 누구와 공유되는지 | storage |
| sync | 봉투 만들기·반영, 충돌 규칙, 시계 맞추기, 재전송 | core, storage |
| transport | 메모리·직접 연결·외부 보관함·늦게 만드는 연결 | core |
| discovery | QR 내용, 흔들기 감지, 같은 네트워크 주소록 | platform |
| inference | "어디서 봤지?" 유추와 추천 | core |
| visibility | 공개 범위 표(정보 × 관계)와 명함 거르기. 순수 계산 | core |
| layout | 홈·명함 틀(템플릿) 순서/보이기/편집 | core |
| kinship | 가계도로 촌수·호칭 계산(순수) | 없음 |
| events | 생일·기일 일정, 알림 날짜 계획, 통화 기록 요약(순수) | 없음 |
| groups | 그룹 정의(이름·종류·순서·구성원·하위 그룹), 비밀번호 잠금, 여러 번 틀리면 기다리기(순수) | core |
| work | 대화 속 업무 표시(📌 업무·🚨 긴급 호출) 읽고 쓰기, 맡은 업무 목록(순수) | 없음 |
| app | 서비스 묶음(`FriendlyApp`). 화면이 쓰는 유일한 내부 입구 | 위 모듈들(키·전송 구현 제외) |
| ui | 화면. `app` 의 서비스만 쓴다 | app, core, platform, storage, discovery, inference, layout, visibility, kinship, events, groups, work |
모듈 폴더 밖의 `lib/main.dart`, `lib/composition/` 이 부품을 고르는 유일한 곳이다.
암호 구현·저장소·연결 방식을 바꾸려면 `lib/composition/android_bootstrap.dart` 의 해당 줄만 바꾼다.
## 시험
```
tool/test_module.sh # 모듈 목록
tool/test_module.sh sync # 모듈 하나만
flutter test # 전체
python3 tool/check_modules.py # 줄 수·import 방향
```
## 시작
```
flutter create --platforms=android --org com.friendlymap --project-name friendly_map .
python3 tool/android_patch.py # 권한, 평문 통신, minSdk 23
flutter pub get
flutter analyze
flutter test
flutter run
```
보관함 서버(선택): `server/README.md`. 서버 주소를 설정 `mailbox_url` 에 넣으면 같은 네트워크 밖에서도 주고받는다.
## 알려진 한계
- 보관함의 우편함 토큰은 번호별 처음 쓴 기기에 묶인다(번호 선점 가능). 나중에 문자 인증으로 막는다.
- 그룹 대화방은 사람이 나가도 키를 바꾸지 않는다.
- 위치는 지도 타일 없이 레이더(내 위치 중심, 방위·거리)로 보여 준다. 지도 SDK는 붙이지 않았다.
- 문자 인증은 만들어 뒀지만 문자 발송 주소(`--sms-webhook`)를 정해 서버에 넣어야 켜진다. 켜기 전에는 처음 쓴 기기에 묶이는 방식(번호 선점 가능)이다.
## 가족 모임 (촌수 · 통화 요약 · 생일/기일 알림)
- 관계 탭 › 가계도에서 가족을 한 분씩 추가한다. 번호가 없는 친척도 넣을 수 있다(이 기기 안에서만 쓰이고 친구 요청은 가지 않는다).
- "나의 부모/자녀/배우자"를 고르거나 부모·자녀·배우자를 이어 가계도를 만들면, 나와 내 자녀 각각의 눈으로 본 호칭과 촌수가 나온다(예: 내게는 친사촌 형 4촌, 내 아들에게는 당숙 5촌).
- 사람을 누르면 관계, 통화 횟수·시간·마지막 통화·통화 잘 되는 시간대, 주제 메모, 다가오는 생일/기일을 요약하고 전화·문자·대화 버튼이 있다.
- 돌아가신 날을 적으면 맨 위에 "故" 띠가 뜨고(설정에서 끌 수 있다) 생일 대신 기일을 알린다.
- 나 › 알림 · 저장 공간 › "생일·기일 알림"에 `7, 2, 1, 0` 처럼 적으면 그 날짜 전 오전 9시에 알림이 울린다(기본 1일 전·당일).
- 음력: 생일·기일마다 "음력"을 켤 수 있다(1951~2049년 변환표 내장, 기일은 윤달도 지원). 제사 알림이 음력 날짜로 잡힌다.
- 한계: 통화 "내용"은 기기에 남지 않으므로 주제는 직접 적는다. 통화 기록 읽기(READ_CALL_LOG)는 Play 스토어 심사 제한이 있다.
- 안드로이드: `call_log`, `url_launcher`, `flutter_local_notifications`, `timezone` 플러그인과 권한·수신기·desugaring 설정이 필요하다(`tool/android_patch.py` 가 맞춘다).
## 처음 시작 · 다시 연결 (복구 없음)
- 처음 시작은 이름과 휴대폰 번호만 넣는다. 키는 이 폰 안에서 조용히 만들고, 복구 코드·조각·보관 키는 없다.
- 폰을 바꾸거나 잃으면 그 폰의 기록은 되살리지 않는다. 탈퇴 후 재가입도 없다.
- 새 폰에서 같은 번호로 시작하면, 관계를 유지하고 있는 사용자 각자에게 "새 기기로 다시 연결" 요청이 간다(채팅 맨 위 띠, 관계 탭). **승인**하면 이전 대화·그룹·명함 기록 그대로 새 키로 이어지고, **제명**하면 그 번호는 영구히 막힌다(나 › 제명한 번호에서 풀 수 있음).
## 공개 범위
- 나 › 공개 범위(학교 · 회사·직책 · 생일 · 연락처 × 가족 · 회사 · 동문 · 그룹)에서 칸을 눌러 공개/일부/숨김을 정한다.
- 명함을 보내거나 "다시 보내기"를 할 때 받는 사람의 관계(연결된 관계 + 보내는 명함 종류)에 맞게 걸러서 보낸다. 관계를 모르는 사람에게는 내가 고른 그대로 간다.
- 표를 고쳐도 이미 보낸 명함은 "다시 보내기" 전까지 그대로다.
## 위치 공유
- 나 › 위치 공유에서 켠다. 기본은 꺼짐.
- 켜면 1분마다, 공개 범위 표의 "실시간 위치"가 공개·일부인 관계의 친구에게만 보낸다. 관계를 모르는 친구에게는 보내지 않는다. "일부"는 약 1km 단위로 뭉개서 보낸다.
- 위치는 둘만 아는 키로 잠가서 보내고, 받은 쪽에는 마지막 위치 하나만 남는다.
- 안드로이드: `geolocator` 플러그인과 위치 권한이 필요하다 (`tool/android_patch.py` 가 권한을 넣는다).
## 번호 확인 (문자 인증)
- 나 › "내 번호 확인하기": 문자로 온 6자리를 넣으면 이 기기의 토큰이 내 번호에 묶인다. 폰을 바꾼 진짜 주인은 다시 확인하면 새 기기로 넘어오고, 이전 기기는 막힌다.
- 서버: `python3 mailbox_server.py --sms-webhook <주소> --require-verify`. 문자 발송은 `SmsSender` 하나(웹훅은 `{to,text}` 를 POST)라 업체를 바꿔도 서버 나머지는 그대로다.
- `--require-verify` 를 켜면 확인을 마친 기기만 우편함을 열 수 있다 (번호 선점 차단). 켜지 않으면 예전처럼 처음 쓴 기기에 묶인다.
- 5분 만료, 번호당 1시간 3번, 5번 오답이면 새로 요청. `--sms-log` 는 시험용(문자를 보내지 않고 기록)이라 실서비스에서 쓰면 안 된다.
- 서버 시험: `cd server && python3 -m unittest test_mailbox test_verify`
## 그룹 · 주소록 가져오기
- 관계 탭. 처음 그룹은 가족·학교·회사·그룹·여행이고, 더하기·이름과 종류 고치기·순서 바꾸기·지우기가 된다(지워도 사람은 인맥에 남는다).
- 그룹 아래에 하위 그룹을 4단까지 만든다. 처음에는 학교 › 초등학교·중학교·고등학교·대학교·대학원, 가족 › 친가·외가·처가·시가·친척(촌수 모름)이 있고, 회사 › 회사 이름 › 프로젝트·팀은 직접 만든다. 같은 부모 아래에서만 이름이 겹치지 않으면 된다. 지우면 하위 그룹도 함께 지워진다(사람은 남는다).
- 그룹마다 비밀번호(4~16자)를 걸 수 있다. 잠긴 그룹은 열 때 비밀번호를 묻고, 닫혀 있으면 하위 그룹과 인맥 목록에서도 그 사람들을 숨긴다. 연 뒤 정한 시간(기본 5분, 1~60분)이 지나거나 앱을 내리면 다시 잠근다.
- 5번 틀리면 30초, 그 뒤로 틀릴 때마다 두 배(최대 1시간) 기다려야 한다. 비밀번호는 PBKDF2-SHA256 값만 남긴다. 잊으면 그 그룹은 지우고 다시 만들어야 한다.
- 관계 탭 › 주소록 불러오기: 이름·초성(ㄱㅁㅅ)·번호로 찾고, 휴대폰 주소록 그룹(라벨)으로 거르고, 이미 있는 사람은 빼고 볼 수 있다. 한 명씩 또는 보이는 사람을 모두 고른 뒤, 넣을 그룹(여러 개)을 골라 가져온다.
- 번호가 여럿이면 휴대폰(010) 번호를 먼저 쓴다. 가져온 사람은 이 기기 인맥에만 들어가고, "친구 요청도 보내기"를 켰을 때만 상대에게 요청이 간다. 주소록은 읽기만 한다.
- 그룹은 이 기기에만 저장한다. 안드로이드: `flutter_contacts` 플러그인과 READ_CONTACTS 권한이 필요하다(`tool/android_patch.py` 가 넣는다).
## 관계 정하기 (채팅에서 시작하는 관계망)
- 대화방 ⋮ 메뉴 "관계 정하기", 또는 프로필의 "관계" 버튼에서 고른다: 가족·친척 / 학교 동문 / 회사·업무·팀 / 그룹·모임·여행.
- 가족: 나(또는 가계도에 있는 다른 가족)의 부모·자녀·배우자·형제자매를 고르면 가계도에 잇고 촌수·호칭을 계산한다. 아버지 쪽은 친가, 어머니 쪽은 외가, 배우자 쪽은 처가·시가 그룹에 들어간다. 모르면 "친척(촌수 모름)"에 모아 두고, 나중에 이으면 모음에서 빠진다.
- 형제자매를 고를 때 기준 사람의 부모가 가계도에 없으면 "부모(이름 모름)"를 이 기기 안에서만 쓰는 사람으로 만들어 잇는다.
- 학교·회사·모임: 그 종류의 그룹 나무에서 골라 넣거나, + 로 그 아래 새 그룹(예: 학교 › 고등학교 › 가람고 27회, 회사 › 한빛전자 › 결제팀)을 만들어 바로 넣는다.
- 넣으면 연결 고리(가족·동문·직장 동료·같은 모임)가 하나 남아, 공개 범위 표가 그 관계로 적용된다. 같은 종류의 그룹에서 모두 빠지면 연결 고리도 지운다.
## 업무 · 팀 · 긴급 호출
- 회사 종류 그룹(예: 회사 › 한빛전자 › 결제팀) 안의 "팀 대화방"을 누르면 구성원과 회사 맥락의 대화방이 생긴다. 회사 명함을 아직 공유하지 않은 사람이 있으면 그 이름을 알려 주고 만들지 않는다.
- 대화방 입력칸 왼쪽 + 버튼: "업무 맡기기"(할 일·담당·마감), "긴급 호출". 오른쪽 위 "업무 N"과 ⋮ 메뉴 "이 방 업무". 아래 메뉴 업무 탭은 모든 방의 업무(처음은 내 업무).
- 업무와 긴급 호출은 보통 대화로 간다(`📌 업무 [김민수] 결제 API 정리 (마감 10/30)`, `🚨 긴급 호출: …`). 받는 쪽 앱이 읽어서 업무 목록에 넣고(담당 이름이 내 이름이면 "내 업무"), 긴급 호출은 바로 알림을 띄운다. 새 봉투 형식은 없다.
- 지난 대화를 길게 누르면 "업무로 정리"(내 목록에만). 업무 목록은 이 기기에만 저장하고, 끝냄 표시·밀어서 지우기가 된다.
- 안드로이드: 긴급 호출 알림은 `flutter_local_notifications` 의 높은 중요도 채널(urgent)을 쓴다.
## 이직 알림
- 상대가 회사 명함을 고쳐 "다시 보내기" 하면, 이 기기가 알던 회사와 비교해 바뀌었으면 알림이 생긴다(채팅 맨 위, 프로필). 예: "김민수님이 한빛전자에서 누리소프트로 옮겼어요", "지금은 다니지 않음"이면 "…를 떠났어요".
- "확인 · 옮기기": 이전 회사 이름이 경로에 든 회사 그룹(예: 회사 › 한빛전자 › 결제팀)에서는 이전 구성원으로 흐리게 남기고, "회사 › 새 회사" 그룹(없으면 만든다)에 넣는다. 연결 고리는 '직장 동료'가 새 회사로 바뀌고 '전 직장 동료'(이전 경로)가 남는다.
- "이전 회사에서 지우기": 흐리게 남기지 않고 뺀다. "무시": 알림만 지운다. 이전 구성원은 그룹 안에서 ✕ 로 지울 수 있다.
- 처음 받는 회사 명함, 앱을 새로 연 뒤의 첫 비교는 알림을 만들지 않는다(알던 회사로만 기억). 알던 회사·알림은 이 기기에만 저장한다.

View File

@ -0,0 +1,27 @@
# 보관함 서버
잠긴 봉투를 받는 사람이 접속할 때까지 잠시 맡아 두는 우체통입니다.
봉투 속은 기기끼리만 풀 수 있어서 서버는 내용을 읽지 못합니다.
## 실행 (파이썬만 있으면 됨)
python3 mailbox_server.py --port 8080 --db mailbox.sqlite3
## 컨테이너로 실행
podman build -t fm-mailbox -f Containerfile .
podman run -d -p 8080:8080 -v fm-data:/data fm-mailbox
## 시험
python3 test_mailbox.py
## 알아둘 점
- 저장 방식은 `mailbox_store.py` 의 `MailboxStore` 약속만 지키면 바꿀 수 있습니다 (클라우드로 옮길 때 이 파일만 교체).
- 기본: 우편함은 처음 연 기기의 비밀값에 묶입니다(번호를 먼저 선점당할 수 있음).
- 문자 확인을 켜면(`--sms-webhook <주소>` + `--require-verify`) 문자로 번호를 확인한 기기만 우편함을 열 수 있어 선점이 막힙니다.
문자 발송은 웹훅 하나(`POST {"to":"+82...","text":"..."}`, 선택적으로 `--sms-webhook-token`)라 업체에 묶이지 않습니다.
`phone_verify.py` 의 `SmsSender` 를 바꾸면 다른 방식도 쓸 수 있습니다. 시험용 `--sms-log` 는 실서비스에서 쓰지 마세요.
- 인터넷에 열 때는 앞에 HTTPS 를 붙이세요 (Caddy, nginx 등).
- 봉투는 30일 안에 못 받아 가면 정리되고, 받는 사람 한 명당 5000통까지 맡습니다.

View File

@ -0,0 +1,13 @@
# 2026-10-26
- Flutter analyze·test 통과 확인, 첫 APK 빌드(약 190MB)
- 그룹·잠금·주소록 가져오기 → 그룹 계층 → 관계 정하기 → 업무·긴급 → 이직 알림 (시험 307개)
- Gitea 푸시: 키체인에 git 토큰이 없던 문제 → 사용자가 직접 한 번 푸시해 해결
- 화면 14장 캡처 점검 → 전면 개편 시작
- 사용자 결정: 복구 코드 폐지, 다시 연결은 승인/제명, 처음 시작은 이름·번호만
- 최우선 규칙: 질문은 APK·화면 먼저
- Obsidian 보관함 FriendlyMap 만들고 기록 시작
- 화면 전면 개편: 새 탭 구조, 처음 시작 단순화, 다시 연결 승인·제명(BlockService), 표시 도우미(display.dart), 대화 말풍선·업무 카드·긴급 띠
- 옛 화면 삭제: 환영·복구 코드·조각 맡기기·맡은 조각·홈 타일·홈 편집·묶음·옛 가족·옛 찾기·옛 내 정보·그룹 관리
- 시험 313개 통과, analyze 0건, 화면 캡처 14장 → [[06 화면 점검]]
- 최우선 규칙 추가: 질문은 APK·화면 먼저 / 모든 문서는 wiki

17
wiki/홈.md Normal file
View File

@ -0,0 +1,17 @@
# Friendly Map 기록 홈
> **최우선:** 모든 가이드·문서·결정·기록은 이 보관함에 저장하고 관리한다. 다른 문서는 여기를 가리키기만 한다.
안드로이드 다크모드 인맥 앱(Flutter). 채팅에서 시작해 가족·학교·회사·모임으로 관계를 잇는 사회 관계망.
- [[00 최우선 규칙]]
- [[01 제품 개요]]
- [[02 결정 기록]]
- [[03 로드맵]]
- [[04 구조와 모듈]]
- [[05 빌드 · 시험 · 저장소]]
- [[06 화면 점검]]
- 가이드: [[가이드/기능 안내]] · [[가이드/보관함 서버]]
- 진행 로그: [[진행 로그/2026-10-26]]
저장소: http://192.168.88.15:3180/diadmin/frindlymap (Gitea) · 소스: ~/friendly_map

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB