Compare commits

...

9 Commits

Author SHA1 Message Date
diadmin c465a45c1c wiki: 5~8단계 APK 빌드 기록
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:02:09 +09:00
diadmin 906ae697e0 이직 이력과 주변 사람 (8단계)
- history 모듈(순수): 회사별 근무 기간, 이직하면 이전 회사 닫기, 기간 겹침, 기간 직접 고치기
- CareerService: 회사 명함이 바뀔 때 이력 자동 기록, 같은 회사에 있던 내 인맥, 같은 그룹·같은 회사 주변 사람
- 화면: 프로필의 이직 이력 한 줄 → 이력 화면(기간·같이 있던 인맥·주변 사람, 사람을 누르면 그 사람 이력)
- 시험 12개 추가 (전체 336개)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 21:57:29 +09:00
diadmin 1dcf1dcdb7 학교·회사 원형 보기와 단체 메시지 (6·7단계)
- 화면: 가운데에 나·둘레에 초중고대(회사들), 칸을 누르면 가까운 순 원형, 목록은 연차·직급별, 선배·동기·후배·윗사람·같은 급·아래 직원 거르기, 반·팀별 나눠 보기 설정
- 고르기 → 메시지 / 긴급 호출 (윗사람·아래 직원 전체 버튼)
- 사람이 많아 원이 화면보다 커지면 통째로 줄여 맞춤
- 관계 탭의 학교·회사 그룹은 원형 화면으로 열림

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 21:47:00 +09:00
diadmin cdfb60d745 wiki: 모델 설정 기록
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:21:14 +09:00
diadmin f860fd4db4 사용 모델은 Sonnet 5.5 만 (wiki 규칙), 6단계 진행분: circles 모듈·CircleService
- wiki/00 최우선 규칙: 특별한 지시가 없는 한 Sonnet 5.5 만 사용
- wiki/00: 사용자 작성 질문·테스트·화면 출력 규칙 반영
- circles(순수): 가까움 순위·고리 나누기, 선배·후배, 직급 순서, 원 위 자리 / CircleService: 원형 보기 자료·단체 메시지 (시험 10개)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:20:10 +09:00
diadmin e32bf36da0 가계도 화면: 나 가운데, 위 윗세대·옆 배우자·아래 자식, 친가/외가/처가 색, 기준 바꾸기
- kinship: familyTree (새 파일) — 세대 번호, 쪽 순서(친가 왼쪽·외가/처가 오른쪽), 부부 나란히(남자 왼쪽)
- app: FamilyTreeService — 이름·호칭·촌수·다가오는 생일
- ui: FamilyTreeScreen — 배우자 쪽 족보·자녀 기준 보기, 사람 눌러 기준 바꾸기·가족 더하기, 관계 탭·가족 그룹에서 열기
- 시험 4개 추가 (전체 317개)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:12:42 +09:00
diadmin e6813e39a4 문서를 wiki 로 일원화: README·CLAUDE.md·server/README 는 "무조건 wiki 참조"만
- 기능 안내를 화면 지도 + 기능별 노트 12개로 나눔
- 규칙(00)·제품(01)·바꾸지 않는 기술 결정(02)·현재 상태(03)·모듈 표(04)·빌드·시험·저장소(05)로 옮김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:58:02 +09:00
diadmin 08641630c8 wiki: 개편 APK 빌드 기록
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:38:33 +09:00
diadmin e7982c808c 화면 전면 개편, 복구 코드 폐지와 다시 연결 승인·제명, 기록을 wiki(Obsidian)로
- 처음 시작은 이름·번호만, 키는 조용히 생성. 복구 코드·조각 맡기기 화면 삭제
- BlockService: 새 기기 다시 연결 요청 승인 / 영구 제명(그 번호 인사 차단)
- 아래 메뉴 채팅(첫 화면)·관계·업무·명함·나, 배지
- 채팅 목록·대화방(날짜 구분, 업무 카드, 긴급 띠, + 버튼, ⋮ 메뉴), 관계 탭, 나 탭, 받은 요청 화면, 프로필 정리
- 표시 도우미: 010 번호, 목록 시각, 마감, 미리보기
- 테마 정비(글꼴 명시, 목록·버튼·칩·FAB), 옛 홈 타일·중복 화면 삭제
- test_ux/shots_test.dart 로 화면 캡처, wiki/ 에 모든 가이드·결정·진행 기록·화면 점검
- 시험 313개 통과, analyze 0건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:33:37 +09:00
152 changed files with 4277 additions and 1767 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,62 +1,8 @@
# Friendly Map — Claude 작업 지침 (인수인계) # Friendly Map — Claude 작업 지침
이 파일은 이전 대화(claude.ai 프로젝트 "Frindly Map")에서 정한 것을 새 세션에 넘기는 문서다. ## 무조건 wiki 를 참조한다 (최우선)
세션을 시작하면 먼저 이 파일, README.md, tool/check_modules.py 를 읽고, 작업 전에 아래 원칙을 한 번 읊고 시작한다. - 모든 가이드·규칙·결정·구조·빌드 방법·진행 기록·화면 점검은 **`wiki/` (Obsidian 보관함)에만** 있다. 이 파일과 README 에는 내용을 두지 않는다.
- 세션을 시작하면 반드시 `wiki/홈.md` → `wiki/00 최우선 규칙.md` 를 먼저 읽고, 필요한 노트(02 결정 기록, 04 구조와 모듈, 05 빌드 · 시험 · 저장소, 가이드/…)를 읽은 뒤 일한다.
## 사용자와 일하는 규칙 (1순위, 어기지 말 것) - 새로 정한 것·한 일은 wiki 에 먼저 쓴다: `wiki/진행 로그/<날짜>.md`, `wiki/02 결정 기록.md`, 화면을 바꾸면 `wiki/06 화면 점검.md`.
- 모든 답변은 한글. 고친 파일은 일부 발췌가 아니라 처음부터 끝까지 완본으로 보여 준다. - wiki 와 다른 문서가 다르면 wiki 가 맞다.
- "목업"이라는 말은 쓰지 않는다 ("화면 설계"라고 한다). - 사용 모델은 Sonnet 5.5 만 (wiki/00 최우선 규칙).
- 나는 솔루션 개발자다: 구조가 기능보다 먼저다. 화면과 내부를 분리한다.
- 모듈화 필수. 모듈은 다른 모듈을 고치지 않고 빼고 끼울 수 있어야 한다. `tool/check_modules.py` 가 의존 방향을 검사한다(새 모듈은 ALLOW 표에 등록).
- 소스 파일은 300~400줄, 최대 700줄.
- 모듈별로 시험한다(`tool/test_module.sh <모듈>`). 시험이 끝난 모듈은 다시 만들지 않고 그 위에 쌓는다.
- 한 번에 한 플랫폼. 지금은 안드로이드만. 다른 플랫폼은 사용자가 지정할 때 `platform_<이름>` 모듈만 새로 붙인다.
- 단계마다 되묻지 말고 합리적으로 정해 진행하고, 결정한 내용은 결과에 적는다. 멈춰 서서 "대기"하지 않는다. (단, 되돌릴 수 없는 파괴적 작업은 먼저 묻는다.)
## 제품
Android 다크모드 인맥 앱(Flutter/Dart). 옛 "Family MAP" + 카카오톡식 대화. 목적별 명함(개인/회사/학교/그룹·여행/가족) 교환, "어디서 봤지?" 유추, 가계도, 연결 고리, 검색, 자동 연동 추천, 내 정보 숨기기, 가족 모임(촌수·통화 요약·생일/기일 알림).
## 이미 정한 기술 결정 (바꾸지 말 것)
- 모든 데이터는 기기에 암호화 저장(안드로이드 SQLCipher). 전화번호는 식별자일 뿐 키 재료가 아니다.
- 모든 기기가 서버 겸 클라이언트(P2P). 외부 DB(server/)는 암호화된 임시 우편함 + 기준 시각 제공뿐.
- 12단어 복구 코드, Shamir 조각 기본 5/3(설정으로 조절), 사람 식별은 전화번호 1순위·이름 2순위.
- 대화는 영구 보관, 1MB/기기 사용률 80% 초과 시 별도 저장(조절 가능). 시각은 1/100초, 충돌 시 먼저 쓴 변경이 앞선다. 고친 명함은 "다시 보내기"에서만 보낸다. 그룹 대화 키는 돌리지 않는다.
- 가족 모임 데이터(가계도, 생일, 기일, 통화 주제)는 기기 안에만 저장하고 어디로도 보내지 않는다. 전화번호 없는 친척은 `local:<id>` 번호의 기기 안 전용 사람이다.
## 구조
- `lib/modules/<이름>/<이름>.dart` 가 모듈의 공개 입구(예외: platform_ui.dart, storage_sqlite.dart). 다른 모듈은 입구 파일만 import.
- 모듈: core, platform, platform_android, storage, keys, sharing, sync, transport, discovery, inference, layout, visibility, kinship, events, groups, work, app, ui.
- 조립은 모듈 밖: `lib/main.dart`, `lib/composition/android_bootstrap.dart`.
- 안드로이드 전용 플러그인은 platform_android 에서만 import (PLATFORM_ONLY 목록).
- 화면은 `AppScope.of(context).app`(FriendlyApp)의 서비스만 쓴다.
## 상태 (2026-10-26 기준)
- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 307개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과.
- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 관계망 로드맵 1단계(그룹 계층: 학교 › 초등~대학원, 가족 › 친가·외가·처가·시가·촌수 모름, 회사 › 회사 › 팀) 끝. 2단계(채팅·사람 화면의 "관계 정하기": RelateService, 촌수 맞춰 친가·외가·처가, 촌수 모름 모음, 연결 고리) 끝. 3단계(work 모듈, WorkService: 팀 대화방, 📌 업무 맡기기·정리, 🚨 긴급 호출 즉시 알림) 끝. 4단계(CareerService: 받은 회사 명함 비교로 이직 알림, 확인하면 이전 회사 그룹에 이전 구성원으로 흐리게·새 회사 그룹으로 이동, 지우기·무시) 끝. 로드맵 1~4단계 완료.
- `flutter build apk --debug` 성공(app-debug.apk 약 190MB). 컨테이너마다 SDK 34·35, CMake 를 다시 내려받아 15분 넘게 걸린다. 빌드할 때는 시험을 같이 돌리지 않는다(podman 머신 8GB 에서 Gradle 데몬이 메모리 부족으로 꺼진 적 있음).
- 빌드 경고(아직 빌드를 막지는 않음): `nsd_android`, `flutter_contacts` 가 Kotlin Gradle Plugin 을 직접 적용한다. 앞으로 Flutter 판에서 빌드가 실패할 수 있으니, 지원 판이 나오면 nsd 버전을 올린다. geolocator_android 는 deprecated API 를 쓴다는 Note 만 나온다.
- 확인 안 된 곳: 실제 기기에서 화면 실행과 플러그인 동작(geolocator, call_log, url_launcher, flutter_local_notifications, timezone, sqflite_sqlcipher, nsd, mobile_scanner, sensors_plus, flutter_secure_storage).
- pubspec.lock 은 이 컨테이너(Flutter stable)에서 풀린 판을 기준으로 올린다(2026-10-26 flutter_contacts 추가 때부터).
- 빌드 캐시: `-v fm-gradle:/root/.gradle -v fm-pub:/root/.pub-cache` 를 붙이면 두 번째 빌드부터 빨라진다.
- 알려진 한계: 윤달 생일 미지원(기일만 지원), 음력 변환 1951~2049년, 통화 기록 읽기(READ_CALL_LOG)는 Play 스토어 제한, WebRTC·앱 잠금·복구 단어 재확인 알림·공유 이미지/PDF 저장·우편함 요청 서명은 미구현. 문자 인증은 서버에 `--sms-webhook` 연결이 필요.
## 빌드·시험 방법 (Mac Pro 2013, podman)
Flutter 는 직접 설치하지 않고 컨테이너로 쓴다 (프로젝트는 홈 폴더 안에 있어야 podman machine 이 공유한다):
```
podman machine start
podman run --rm -it -v "$PWD":/app -w /app ghcr.io/cirruslabs/flutter:stable bash
flutter create --platforms=android --org com.friendlymap --project-name friendly_map .
python3 tool/android_patch.py # 권한·수신기·desugaring·minSdk 패치 (여러 번 돌려도 같은 결과)
flutter pub get && flutter analyze && flutter test
flutter build apk --debug # build/app/outputs/flutter-apk/app-debug.apk
```
- `build/`, `.dart_tool/` 는 만들어지는 폴더라 저장소에 올리지 않는다(.gitignore). `android/` 는 Gitea 에는 올라가 있다.
- 서버 시험: `cd server && python3 -m unittest test_mailbox test_verify`.
- 모듈 경계 검사: `python3 tool/check_modules.py`.
- 저장소: http://192.168.88.15:3180/diadmin/frindlymap (Gitea, remote origin). 고친 뒤 커밋해서 Gitea 에 푸시한다.
- GitLab 에는 올리지 않는다. 단, 하루 한 번 메이저 업그레이드 때만 GitLab 에 올린다(원격은 아직 등록되지 않음).
## 작업 방식
- 고치면 해당 모듈 시험을 다시 돌리고, 마지막에 전체 시험과 check_modules.py 를 돌린다.
- 새 기능은 (1) 순수 모듈 또는 서비스 + 시험 → (2) 화면 순서로, 기존 시험이 끝난 모듈은 고치지 않는다.
- 끝낼 때 한글로: 무엇이 바뀌었나, 무엇을 돌려 확인했나, 확인 못 한 것은 무엇인가.

132
README.md
View File

@ -1,133 +1,5 @@
# Friendly Map # Friendly Map
가족·친구·직장 관계를 한 앱에서. 새 친구가 "어디서 봤지?" 싶을 때 목적별 명함을 주고받아 관계를 유추한다. **모든 가이드·문서·결정·진행 기록은 무조건 [`wiki/`](wiki/홈.md) 를 참조한다.** (Obsidian 에서 `wiki/` 폴더를 "보관함으로 열기")
안드로이드 우선, Flutter. 모든 기기가 서버 겸 클라이언트(P2P)이고, 외부 DB는 암호화된 임시 우편함일 뿐이다.
## 원칙 (1순위) 이 파일에는 내용을 두지 않는다. 시작은 [wiki/홈.md](wiki/홈.md).
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 | 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,14 +5,18 @@
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/circle_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/family_tree_service.dart';
export 'src/fields.dart'; export 'src/fields.dart';
export 'src/friend_service.dart' show FriendService, NearbyPerson, NewFriendCheck; export 'src/friend_service.dart' show FriendService, NearbyPerson, NewFriendCheck;
export 'src/friendly_app.dart'; export 'src/friendly_app.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

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:friendly_map/modules/core/core.dart'; import 'package:friendly_map/modules/core/core.dart';
import 'package:friendly_map/modules/history/history.dart';
import 'package:friendly_map/modules/storage/storage.dart'; import 'package:friendly_map/modules/storage/storage.dart';
import 'change_bus.dart'; import 'change_bus.dart';
@ -34,6 +35,22 @@ class CareerNotice {
String currentCompany(Map<String, String> fields) => String currentCompany(Map<String, String> fields) =>
fields[CardKeys.former] == 'true' ? '' : (fields[CardKeys.company] ?? '').trim(); fields[CardKeys.former] == 'true' ? '' : (fields[CardKeys.company] ?? '').trim();
/// 이력 화면의 한 줄: 회사 기간과, 내 기록에서 그 회사에 있었던 다른 사람 수.
class StintView {
const StintView(this.index, this.stint, this.colleagues);
final int index; // 최근 것부터 센 번호
final Stint stint;
final List<Person> colleagues;
}
/// 주변 사람 한 명과 왜 주변인지.
class AroundPerson {
const AroundPerson(this.person, this.reasons);
final Person person;
final List<String> reasons;
int get weight => reasons.length;
}
/// 받침에 맞는 조사 ("누리소프트" + 로, "회사" + 를, "한빛" + 으로). 한글이 아니면 "(으)로"·"을(를)". /// 받침에 맞는 조사 ("누리소프트" + 로, "회사" + 를, "한빛" + 으로). 한글이 아니면 "(으)로"·"을(를)".
String withJosa(String word, String withFinal, String withoutFinal) { String withJosa(String word, String withFinal, String withoutFinal) {
if (word.isEmpty) return word; if (word.isEmpty) return word;
@ -52,6 +69,7 @@ class CareerService {
CareerService(this._storage, this._clock, this._groups, this._relate, this._network, this._bus); CareerService(this._storage, this._clock, this._groups, this._relate, this._network, this._bus);
static const _knownKey = 'career.known'; static const _knownKey = 'career.known';
static const _historyKey = 'career.history';
static const _noticesKey = 'career.notices'; static const _noticesKey = 'career.notices';
final Storage _storage; final Storage _storage;
@ -85,16 +103,95 @@ class CareerService {
_bus.notify(); _bus.notify();
} }
/// 이미 받아 둔 회사 명함으로 "알던 회사"를 채운다 (앱 시작 때). 알림은 만들지 않는다. Future<Map<String, Career>> _histories() async {
try {
final m = (jsonDecode(await _storage.settings.getText(_historyKey) ?? '{}') as Map).cast<String, String>();
return {for (final e in m.entries) e.key: Career.fromJson(e.value)};
} catch (_) {
return {};
}
}
Future<void> _saveHistories(Map<String, Career> m) =>
_storage.settings.putText(_historyKey, jsonEncode({for (final e in m.entries) e.key: e.value.toJson()}), _clock.now());
int get _year => DateTime.fromMillisecondsSinceEpoch(_clock.now() * 10).year;
/// 이미 받아 둔 회사 명함으로 "알던 회사"와 이력을 채운다 (앱 시작 때). 알림은 만들지 않는다. 시작 연도는 모른다.
Future<void> seed() async { Future<void> seed() async {
final known = await _known(); final known = await _known();
var changed = false; final hist = await _histories();
var changed = false, histChanged = false;
for (final c in await _storage.receivedCards.all()) { for (final c in await _storage.receivedCards.all()) {
if (c.kind != CardKinds.company || known.containsKey(c.personId)) continue; if (c.kind != CardKinds.company) continue;
known[c.personId] = currentCompany(decodeFields(c.fieldsJson)); final now = currentCompany(decodeFields(c.fieldsJson));
changed = true; if (!known.containsKey(c.personId)) {
known[c.personId] = now;
changed = true;
}
if (!hist.containsKey(c.personId) && now.isNotEmpty) {
hist[c.personId] = Career()..begin(now);
histChanged = true;
}
} }
if (changed) await _saveKnown(known); if (changed) await _saveKnown(known);
if (histChanged) await _saveHistories(hist);
}
/// 이 사람의 회사 이력과, 각 회사에 같이 있었던 내 인맥 (기간이 겹치는 사람).
Future<List<StintView>> history(String personId) async {
final hist = await _histories();
final mine = hist[personId];
if (mine == null) return const [];
final people = {for (final p in await _storage.people.all()) p.id: p};
final out = <StintView>[];
final list = mine.newestFirst;
for (var i = 0; i < list.length; i++) {
final s = list[i];
final mates = <Person>[];
for (final e in hist.entries) {
if (e.key == personId) continue;
if (e.value.stints.any((o) => o.overlaps(s, _year)) && people[e.key] != null) mates.add(people[e.key]!);
}
mates.sort((a, b) => a.name.compareTo(b.name));
out.add(StintView(i, s, mates));
}
return out;
}
/// 사용자가 기간을 고친다 ([index] 는 최근 것부터). 모르면 null.
Future<bool> setYears(String personId, int index, {int? from, int? to, bool? current}) async {
final hist = await _histories();
final c = hist[personId];
if (c == null || !c.setYears(index, from: from, to: to, current: current)) return false;
await _saveHistories(hist);
_bus.notify();
return true;
}
/// 내 기록 속 주변 사람: 같은 그룹에 있거나 같은 회사에 같이 있었던 사람 (겹치는 이유가 많은 순).
/// 잠긴 그룹이 닫혀 있으면 그 그룹은 보지 않는다.
Future<List<AroundPerson>> around(String personId) async {
final reasons = <String, List<String>>{};
for (final g in await _groups.groupsOf(personId)) {
final v = await _groups.view(g.id);
for (final p in await _groups.members(g.id) ?? const <Person>[]) {
if (p.id != personId) (reasons[p.id] ??= []).add('같은 그룹 · ${v?.path ?? g.name}');
}
}
for (final s in await history(personId)) {
for (final p in s.colleagues) {
(reasons[p.id] ??= []).add('같은 회사 · ${s.stint.company}');
}
}
final hidden = await _groups.hiddenPeople();
final out = <AroundPerson>[];
for (final e in reasons.entries) {
final p = await _storage.people.get(e.key);
if (p != null && !hidden.contains(p.id)) out.add(AroundPerson(p, e.value.toSet().toList()));
}
out.sort((a, b) => b.weight != a.weight ? b.weight.compareTo(a.weight) : a.person.name.compareTo(b.person.name));
return out;
} }
/// 받은 명함을 살핀다 (동기화가 명함을 반영한 뒤 부른다). 회사가 바뀌었으면 알림을 만든다. /// 받은 명함을 살핀다 (동기화가 명함을 반영한 뒤 부른다). 회사가 바뀌었으면 알림을 만든다.
@ -106,6 +203,15 @@ class CareerService {
final before = known[c.personId]; final before = known[c.personId];
known[c.personId] = now; known[c.personId] = now;
await _saveKnown(known); await _saveKnown(known);
// 이력: 회사가 바뀌었거나 처음 알게 되면 기록 (바뀐 해는 지금 해).
final hist = await _histories();
final career = hist.putIfAbsent(c.personId, Career.new);
if (before != null && before != now && now.isEmpty) {
career.leave(year: _year);
} else if (now.isNotEmpty) {
career.begin(now, year: before == null || before == now ? null : _year);
}
await _saveHistories(hist);
if (before == null || before == now) return null; if (before == null || before == now) return null;
final list = (await _all())..removeWhere((n) => n.personId == c.personId); final list = (await _all())..removeWhere((n) => n.personId == c.personId);
final n = CareerNotice(newId(), c.personId, before, now, _clock.now()); final n = CareerNotice(newId(), c.personId, before, now, _clock.now());

View File

@ -0,0 +1,180 @@
import 'dart:convert';
import 'package:friendly_map/modules/circles/circles.dart';
import 'package:friendly_map/modules/core/core.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import 'call_service.dart';
import 'change_bus.dart';
import 'chat_service.dart';
import 'fields.dart';
import 'group_service.dart';
import 'work_service.dart';
/// 원형 보기의 한 사람: 가까움 점수, 선배·후배(학교), 직급 관계(회사), 속한 하위 그룹.
class CirclePerson {
const CirclePerson(this.person, {this.score = 0, this.subgroup, this.year, this.cohortDiff, this.position, this.rank, this.rankRelation = RankRelation.unknown});
final Person person;
final int score;
final String? subgroup; // 보는 그룹 바로 아래 하위 그룹 이름 (반·팀 등)
final int? year;
final int? cohortDiff; // 음수 선배, 0 동기, 양수 후배
final String? position;
final int? rank;
final RankRelation rankRelation;
String get cohort => cohortLabel(cohortDiff);
}
/// 둘레의 한 칸 (초등학교, 한빛전자 …): 사람 수와 가까운 몇 명.
class CircleLevel {
const CircleLevel(this.view, this.count, this.top);
final GroupView view;
final int count;
final List<CirclePerson> top;
}
class CircleHub {
const CircleHub(this.root, this.levels, this.closest);
final GroupView? root;
final List<CircleLevel> levels;
final List<CirclePerson> closest; // 이 종류 전체에서 가까운 순
}
/// 학교·회사 원형 보기 자료와 단체 메시지.
///
/// 가까움 = 그 사람이 있는 대화방의 대화 수 + 통화 수 × 3. 선배·후배는 받은 학교 명함의 연도와 내 학교 명함 연도,
/// 직급은 받은 회사 명함의 직책과 내 회사 명함 직책으로 정한다. 모두 이 기기 안에서 계산한다.
class CircleService {
CircleService(this._storage, this._clock, this._groups, this._calls, this._chats, this._work, this._bus);
final Storage _storage;
final Clock _clock;
final GroupService _groups;
final CallService _calls;
final ChatService _chats;
final WorkService _work;
final ChangeBus _bus;
Future<Map<String, int>> _messageCounts() async {
final out = <String, int>{};
for (final r in await _storage.rooms.all()) {
final n = (await _storage.messages.page(r.id, limit: 1000)).length;
for (final id in (jsonDecode(r.membersJson) as List).whereType<String>()) {
out[id] = (out[id] ?? 0) + n;
}
}
return out;
}
Future<String?> _myField(String kind, String key) async {
for (final c in await _storage.myCards.byKind(kind)) {
final v = decodeFields(c.fieldsJson)[key];
if (v != null && v.trim().isNotEmpty) return v;
}
return null;
}
Future<String?> _theirField(String personId, String kind, String key) async {
for (final c in await _storage.receivedCards.byPerson(personId)) {
if (c.kind != kind) continue;
final v = decodeFields(c.fieldsJson)[key];
if (v != null && v.trim().isNotEmpty) return v;
}
return null;
}
Future<CirclePerson> _person(Person p, String kind, Map<String, int> msgs, {String? subgroup, int? myYear, int? myRank}) async {
final calls = _calls.canReadLog ? (await _calls.summary(p.id)).total : 0;
final score = Closeness(p.id, messages: msgs[p.id] ?? 0, calls: calls).score;
if (kind == CardKinds.school) {
final y = parseYear(await _theirField(p.id, CardKinds.school, CardKeys.year));
return CirclePerson(p, score: score, subgroup: subgroup, year: y, cohortDiff: yearsApart(myYear, y));
}
if (kind == CardKinds.company) {
final pos = await _theirField(p.id, CardKinds.company, CardKeys.position);
final r = rankOf(pos);
return CirclePerson(p, score: score, subgroup: subgroup, position: pos, rank: r, rankRelation: compareRank(myRank, r));
}
return CirclePerson(p, score: score, subgroup: subgroup);
}
Future<int?> _myYear() async => parseYear(await _myField(CardKinds.school, CardKeys.year));
Future<int?> _myRank() async => rankOf(await _myField(CardKinds.company, CardKeys.position));
/// [groupId] 와 그 아래 모든 사람 (가까운 순). 각 사람에게 바로 아래 하위 그룹 이름을 붙인다. 닫힌 잠긴 그룹이면 빈 목록.
Future<List<CirclePerson>> members(String groupId) async {
final v = await _groups.view(groupId);
if (v == null || !v.open) return const [];
final kind = v.group.kind;
final msgs = await _messageCounts();
final myYear = await _myYear(), myRank = await _myRank();
final sub = <String, String>{};
for (final c in await _groups.children(groupId)) {
for (final p in await _deep(c.group.id)) {
sub.putIfAbsent(p, () => c.group.name);
}
}
final out = <CirclePerson>[];
for (final id in await _deep(groupId)) {
final p = await _storage.people.get(id);
if (p != null) out.add(await _person(p, kind, msgs, subgroup: sub[id], myYear: myYear, myRank: myRank));
}
out.sort((a, b) => b.score != a.score ? b.score.compareTo(a.score) : a.person.name.compareTo(b.person.name));
return out;
}
/// 그룹과 열린 하위 그룹의 구성원 id.
Future<Set<String>> _deep(String groupId) async {
final out = <String>{};
Future<void> walk(String id) async {
final ms = await _groups.members(id);
if (ms == null) return;
out.addAll(ms.map((p) => p.id));
for (final c in await _groups.children(id)) {
await walk(c.group.id);
}
}
await walk(groupId);
return out;
}
/// 원형 보기 가운데: [rootId](학교·회사) 아래 칸들과 전체에서 가까운 사람.
Future<CircleHub> hub(String rootId, {int topPerLevel = 3, int closest = 8}) async {
final root = await _groups.view(rootId);
if (root == null || !root.open) return CircleHub(root, const [], const []);
final levels = <CircleLevel>[];
for (final c in await _groups.children(rootId)) {
final ms = await members(c.group.id);
levels.add(CircleLevel(c, ms.length, ms.take(topPerLevel).toList()));
}
final all = await members(rootId);
return CircleHub(root, levels, all.take(closest).toList());
}
static const _splitKey = 'view.split.';
/// 하위 그룹(반·팀)별로 나눠 볼지 (종류마다 따로, 기본 합쳐 보기).
Future<bool> splitView(String kind) async => (await _storage.settings.getText('$_splitKey$kind')) == 'true';
Future<void> setSplitView(String kind, bool on) async {
await _storage.settings.putText('$_splitKey$kind', on ? 'true' : 'false', _clock.now());
_bus.notify();
}
/// 단체 메시지: 고른 사람 각자에게 1:1 대화로 보낸다 ([urgent] 면 긴급 호출로). 보낸 사람 수.
Future<int> broadcast(Iterable<String> personIds, String text, {bool urgent = false}) async {
if (text.trim().isEmpty && !urgent) return 0;
var n = 0;
for (final id in personIds.toSet()) {
final p = await _storage.people.get(id);
if (p == null || p.phone.startsWith('local:')) continue;
final room = await _chats.openDirect(id, CardKinds.personal);
if (room == null) continue;
final m = urgent ? await _work.urgent(room.id, text) : await _chats.send(room.id, text);
if (m != null) n++;
}
return n;
}
}

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

@ -0,0 +1,95 @@
import 'package:friendly_map/modules/kinship/kinship.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import 'event_service.dart';
import 'facts_service.dart';
import 'kin_service.dart';
/// 가계도 칸 하나 (화면용): 이름, 기준 사람이 본 호칭·촌수, 다가오는 생일.
class TreeNodeView {
const TreeNodeView({required this.id, required this.name, required this.title, required this.focus, this.chon, this.spouseOf, this.birthday, this.deceased = false, required this.side});
final String id;
final String name;
final String title; // "아버지", "외삼촌" …
final int? chon;
final bool focus;
final String? spouseOf;
final String? birthday; // "생일 3월 14일 · D-12"
final bool deceased;
final FamilySide side;
}
class TreeRowView {
const TreeRowView(this.gen, this.label, this.nodes);
final int gen;
final String label;
final List<TreeNodeView> nodes;
}
/// 가계도 한 화면: 기준 사람, 세대 줄들, 기준을 바꿀 수 있는 사람들(배우자·자녀).
class FamilyTreeView {
const FamilyTreeView(this.focusId, this.focusName, this.rows, this.spouses, this.children);
final String focusId;
final String focusName;
final List<TreeRowView> rows;
final List<(String, String)> spouses; // (id, 이름) — 처가·시가 족보 보기
final List<(String, String)> children; // (id, 이름) — 자녀 기준 보기
bool get empty => rows.length <= 1 && (rows.isEmpty || rows.first.nodes.length <= 1);
}
/// 가계도 화면 자료를 만든다. 계산은 kinship 모듈, 이름·생일은 저장소에서.
class FamilyTreeService {
FamilyTreeService(this._storage, this._kin, this._facts, this._events);
final Storage _storage;
final KinService _kin;
final FactsService _facts;
final EventService _events;
Future<String> _name(String id, String meName) async => id == KinGraph.meId ? meName : (await _storage.people.get(id))?.name ?? '?';
Future<FamilyTreeView> tree({String focus = KinGraph.meId}) async {
final g = await _kin.graph();
final meName = (await _storage.settings.loadProfile())?.name ?? '나';
final f = g.has(focus) ? focus : KinGraph.meId;
final rows = <TreeRowView>[];
final focusName = await _name(f, meName);
for (final r in familyTree(g, f)) {
final nodes = <TreeNodeView>[];
for (final c in r.cells) {
String? bday;
var gone = false;
if (c.id != KinGraph.meId) {
final facts = await _facts.get(c.id);
gone = facts.deceased;
final next = await _events.forPerson(c.id);
final b = next.where((e) => e.upcoming.event.kind.name == 'birthday').firstOrNull;
if (b != null) {
final d = b.upcoming.date;
bday = '생일 ${d.month}월 ${d.day}일 · ${b.upcoming.daysLeft == 0 ? '오늘' : 'D-${b.upcoming.daysLeft}'}';
}
}
nodes.add(TreeNodeView(
id: c.id,
name: await _name(c.id, meName),
title: c.isFocus ? '기준' : c.relation.title,
chon: c.isFocus ? null : c.relation.chon,
focus: c.isFocus,
spouseOf: c.spouseOf,
birthday: bday,
deceased: gone,
side: c.side,
));
}
rows.add(TreeRowView(r.gen, f == KinGraph.meId && r.gen == 0 ? '나' : r.label(focusName), nodes));
}
return FamilyTreeView(
f,
focusName,
rows,
[for (final s in g.spousesOf(f)) (s, await _name(s, meName))],
[for (final c in g.childrenOf(f)) (c, await _name(c, meName))],
);
}
}

View File

@ -4,14 +4,17 @@ 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';
import 'call_service.dart'; import 'call_service.dart';
import 'chat_service.dart'; import 'chat_service.dart';
import 'circle_service.dart';
import 'contact_import_service.dart'; import 'contact_import_service.dart';
import 'event_service.dart'; import 'event_service.dart';
import 'facts_service.dart'; import 'facts_service.dart';
import 'family_tree_service.dart';
import 'friend_service.dart'; import 'friend_service.dart';
import 'group_service.dart'; import 'group_service.dart';
import 'kin_service.dart'; import 'kin_service.dart';
@ -64,6 +67,9 @@ 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);
familyTree = FamilyTreeService(storage, kin, facts, events);
circles = CircleService(storage, clock, groups, calls, chats, work, bus);
} }
final AppDeps deps; final AppDeps deps;
@ -89,6 +95,9 @@ 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;
late final FamilyTreeService familyTree;
late final CircleService circles;
StreamSubscription<SyncEvent>? _events; StreamSubscription<SyncEvent>? _events;
final Set<Future<void>> _tasks = {}; final Set<Future<void>> _tasks = {};
@ -100,7 +109,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

@ -0,0 +1,10 @@
/// 원형 보기: 가까움 순위, 선배·후배(기수), 직급 순서, 원 위의 자리 계산
///
/// 학교·회사 화면이 같이 쓰는 순수 계산이다(저장·화면 없음). 어떤 모듈도 쓰지 않는다.
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
library;
export 'src/closeness.dart';
export 'src/cohort.dart';
export 'src/rank.dart';
export 'src/ring.dart';

View File

@ -0,0 +1,31 @@
/// 한 사람과 얼마나 가까운지: 나눈 대화 수와 통화 수. 통화는 대화보다 무겁게 친다.
class Closeness {
const Closeness(this.id, {this.messages = 0, this.calls = 0});
final String id;
final int messages;
final int calls;
static const callWeight = 3;
int get score => messages + calls * callWeight;
}
/// 가까운 순서 (점수가 같으면 id 순으로 늘 같은 순서).
List<Closeness> rankByCloseness(Iterable<Closeness> list) => list.toList()
..sort((a, b) {
final s = b.score.compareTo(a.score);
return s != 0 ? s : a.id.compareTo(b.id);
});
/// 원 안쪽부터 바깥쪽으로 나눈다: 첫 고리 [first] 명, 다음 고리마다 [step] 명씩 더.
List<List<T>> rings<T>(List<T> ordered, {int first = 6, int step = 6}) {
final out = <List<T>>[];
var i = 0, size = first;
while (i < ordered.length) {
final end = (i + size).clamp(0, ordered.length);
out.add(ordered.sublist(i, end));
i = end;
size += step;
}
return out;
}

View File

@ -0,0 +1,26 @@
/// 같은 학교에서 나와 몇 년 차이인지. 연도는 입학(또는 졸업) 연도로, 먼저인 쪽이 선배다.
/// 음수면 선배, 0 이면 동기, 양수면 후배. 둘 중 하나라도 모르면 null.
int? yearsApart(int? mine, int? theirs) => (mine == null || theirs == null) ? null : theirs - mine;
/// "3년 선배", "동기", "1년 후배", "연도 모름"
String cohortLabel(int? diff) {
if (diff == null) return '연도 모름';
if (diff == 0) return '동기';
return diff < 0 ? '${-diff}년 선배' : '$diff년 후배';
}
/// 연차별 묶음 순서: 선배(먼 쪽부터) → 동기 → 후배 → 연도 모름.
int cohortOrder(int? diff) => diff ?? 1 << 20;
/// 글자에서 연도를 읽는다 ("2009", "09학번", "27회" 는 회차라 못 읽음). 못 읽으면 null.
int? parseYear(String? text) {
if (text == null) return null;
final four = RegExp(r'(19|20)\d{2}').firstMatch(text);
if (four != null) return int.parse(four.group(0)!);
final two = RegExp(r'(\d{2})\s*학번').firstMatch(text);
if (two != null) {
final y = int.parse(two.group(1)!);
return y >= 50 ? 1900 + y : 2000 + y;
}
return null;
}

View File

@ -0,0 +1,25 @@
/// 회사 직급 순서. 숫자가 클수록 윗사람. 모르는 직급은 null.
const _ranks = <String, int>{
'인턴': 0, '사원': 1, '주임': 2, '선임': 2, '대리': 3, '과장': 4, '책임': 4, '차장': 5, '수석': 5,
'팀장': 6, '부장': 6, '실장': 7, '이사': 8, '상무': 9, '전무': 10, '부사장': 11, '사장': 12, '대표': 13, '부회장': 13, '회장': 14,
};
/// 직책 글자에서 직급을 찾는다 ("결제팀 과장" → 4). 여러 개면 가장 높은 것.
int? rankOf(String? position) {
if (position == null || position.trim().isEmpty) return null;
int? best;
for (final e in _ranks.entries) {
if (position.contains(e.key) && (best == null || e.value > best)) best = e.value;
}
return best;
}
enum RankRelation { above, same, below, unknown }
/// [mine] 직급에서 본 [theirs]: 윗사람·같은 급·아래 직원.
RankRelation compareRank(int? mine, int? theirs) {
if (mine == null || theirs == null) return RankRelation.unknown;
if (theirs > mine) return RankRelation.above;
if (theirs < mine) return RankRelation.below;
return RankRelation.same;
}

View File

@ -0,0 +1,10 @@
import 'dart:math';
/// 원 위에 [n] 개를 고르게 놓는 자리 (가운데가 0,0). 12시 방향부터 시계 방향.
List<(double, double)> ringPositions(int n, double radius, {double startDeg = -90}) => [
for (var i = 0; i < n; i++)
(
radius * cos((startDeg + 360 * i / max(n, 1)) * pi / 180),
radius * sin((startDeg + 360 * i / max(n, 1)) * pi / 180),
),
];

View File

@ -0,0 +1,7 @@
/// 이력: 사람마다 회사별 근무 기간(몇 년부터 몇 년까지)과 겹치는 기간 계산
///
/// 순수 계산만 한다(저장·화면 없음). 어떤 모듈도 쓰지 않는다.
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
library;
export 'src/career.dart';

View File

@ -0,0 +1,98 @@
import 'dart:convert';
/// 한 회사에서 일한 기간. 연도를 모르면 null, [to] 가 null 이고 [current] 면 지금도 다님.
class Stint {
const Stint(this.company, {this.from, this.to, this.current = false});
final String company;
final int? from;
final int? to;
final bool current;
/// "2019 ~ 2024", "2024 ~ 현재", "~ 2024", "기간 모름"
String get period {
if (from == null && to == null && !current) return '기간 모름';
final a = from?.toString() ?? '';
final b = current ? '현재' : (to?.toString() ?? '');
return '$a ~ $b'.trim();
}
/// 몇 년 일했는지 (모르면 null).
int? years(int thisYear) {
final a = from;
if (a == null) return null;
final b = current ? thisYear : to;
return b == null ? null : (b - a < 0 ? 0 : b - a);
}
Stint copyWith({int? from, int? to, bool? current, bool clearFrom = false, bool clearTo = false}) => Stint(company,
from: clearFrom ? null : (from ?? this.from), to: clearTo ? null : (to ?? this.to), current: current ?? this.current);
Map<String, Object?> toJson() => {'c': company, if (from != null) 'f': from, if (to != null) 't': to, if (current) 'now': true};
static Stint? fromJson(Object? j) {
if (j is! Map || j['c'] is! String) return null;
return Stint(j['c'] as String, from: j['f'] is int ? j['f'] as int : null, to: j['t'] is int ? j['t'] as int : null, current: j['now'] == true);
}
/// 두 사람이 같은 회사에서 겹쳐 일했을 가능성. 연도를 모르면 겹친다고 본다.
bool overlaps(Stint other, int thisYear) {
if (company != other.company) return false;
final aFrom = from, bFrom = other.from;
final aTo = current ? thisYear : to, bTo = other.current ? thisYear : other.to;
if (aFrom == null || bFrom == null || aTo == null || bTo == null) return true;
return aFrom <= bTo && bFrom <= aTo;
}
}
/// 한 사람의 회사 이력 (오래된 것부터 저장, 보여 줄 때는 최근 것부터).
class Career {
Career([List<Stint>? stints]) : _stints = [...?stints];
final List<Stint> _stints;
List<Stint> get stints => List.unmodifiable(_stints);
/// 최근 것부터.
List<Stint> get newestFirst => _stints.reversed.toList();
Stint? get now => _stints.isNotEmpty && _stints.last.current ? _stints.last : null;
/// [company] 에서 일하기 시작함. 지금 다니는 다른 회사가 있으면 [year] 에 끝낸 것으로 닫는다. 같은 회사면 그대로.
/// 시작 연도를 모르면 [year] 를 null 로 둔다.
void begin(String company, {int? year}) {
final c = company.trim();
if (c.isEmpty) return;
final cur = now;
if (cur != null && cur.company == c) return;
if (cur != null) _stints[_stints.length - 1] = cur.copyWith(to: year, current: false);
_stints.add(Stint(c, from: year, current: true));
}
/// 지금 다니던 회사를 [year] 에 그만둠.
void leave({int? year}) {
final cur = now;
if (cur != null) _stints[_stints.length - 1] = cur.copyWith(to: year, current: false);
}
/// [index](최근 것부터 센 번호)의 기간을 사용자가 고친다. null 이면 "모름".
bool setYears(int index, {int? from, int? to, bool? current}) {
final i = _stints.length - 1 - index;
if (i < 0 || i >= _stints.length) return false;
final s = _stints[i];
_stints[i] = Stint(s.company, from: from, to: to, current: current ?? s.current);
return true;
}
String toJson() => jsonEncode([for (final s in _stints) s.toJson()]);
factory Career.fromJson(String? text) {
if (text == null || text.isEmpty) return Career();
try {
final j = jsonDecode(text);
return Career([if (j is List) for (final x in j) if (Stint.fromJson(x) case final s?) s]);
} on FormatException {
return Career();
}
}
}

View File

@ -7,3 +7,4 @@ library;
export 'src/graph.dart'; export 'src/graph.dart';
export 'src/relation.dart' show Relation, relate; export 'src/relation.dart' show Relation, relate;
export 'src/side.dart'; export 'src/side.dart';
export 'src/tree.dart';

View File

@ -0,0 +1,113 @@
import 'graph.dart';
import 'relation.dart';
import 'side.dart';
/// 가계도 한 칸: 사람과, 기준 사람이 본 관계.
class TreeCell {
const TreeCell(this.id, this.relation, this.side, {this.spouseOf});
final String id;
final Relation relation;
final FamilySide side;
/// 이 칸이 옆 사람의 배우자로 붙어 있으면 그 사람 id.
final String? spouseOf;
bool get isFocus => relation.kind == 'self';
}
/// 세대 한 줄. [gen] 은 기준 사람에서 위(-)·아래(+)로 몇 세대인지.
class TreeRow {
const TreeRow(this.gen, this.cells);
final int gen;
final List<TreeCell> cells;
/// "조부모 세대", "부모 세대", "나", "자녀 세대" …
String label(String focusName) => switch (gen) {
0 => focusName,
-1 => '부모 세대',
-2 => '조부모 세대',
-3 => '증조부모 세대',
1 => '자녀 세대',
2 => '손주 세대',
_ => gen < 0 ? '${-gen}대 위' : '$gen대 아래',
};
}
/// [focus] 를 가운데 둔 가계도: 위에는 윗세대, 같은 줄 옆에 배우자, 아래에는 자식.
///
/// 같은 줄에서는 아버지 쪽(친가)을 왼쪽, 가까운 가족을 가운데, 어머니 쪽(외가)과 배우자 쪽(처가·시가)을 오른쪽에 둔다.
/// 부부는 나란히 붙인다. [up]·[down] 세대 밖은 뺀다.
List<TreeRow> familyTree(KinGraph g, String focus, {int up = 3, int down = 2}) {
if (!g.has(focus)) return const [];
// 세대 번호: 부모 -1, 자식 +1, 배우자 같은 세대.
final gen = <String, int>{focus: 0};
final queue = [focus];
while (queue.isNotEmpty) {
final x = queue.removeAt(0);
final gx = gen[x]!;
void visit(String y, int gy) {
if (gen.containsKey(y)) return;
gen[y] = gy;
queue.add(y);
}
for (final p in g.parentsOf(x)) {
visit(p, gx - 1);
}
for (final c in g.childrenOf(x)) {
visit(c, gx + 1);
}
for (final s in g.spousesOf(x)) {
visit(s, gx);
}
}
final rows = <int, List<TreeCell>>{};
for (final e in gen.entries) {
if (e.value < -up || e.value > down) continue;
final rel = relate(g, focus, e.key);
if (!rel.related) continue;
rows.putIfAbsent(e.value, () => []).add(TreeCell(e.key, rel, e.key == focus ? FamilySide.close : familySide(g, focus, e.key)));
}
int sideRank(FamilySide s) => switch (s) {
FamilySide.paternal => 0,
FamilySide.close || FamilySide.unknown || FamilySide.none => 1,
FamilySide.maternal => 2,
FamilySide.inlaw => 3,
};
int born(String id) => g.person(id)?.birthYear ?? 9999;
final out = <TreeRow>[];
for (final k in rows.keys.toList()..sort()) {
final cells = rows[k]!
..sort((a, b) {
final s = sideRank(a.side).compareTo(sideRank(b.side));
if (s != 0) return s;
if (a.isFocus != b.isFocus) return a.isFocus ? -1 : 1;
final ga = g.person(a.id)?.gender == Gender.male ? 0 : 1, gb = g.person(b.id)?.gender == Gender.male ? 0 : 1;
if (ga != gb) return ga.compareTo(gb); // 부부·형제는 남자를 왼쪽에
final y = born(a.id).compareTo(born(b.id));
return y != 0 ? y : a.id.compareTo(b.id);
});
out.add(TreeRow(k, _pairCouples(g, cells)));
}
return out;
}
/// 부부를 나란히: 배우자를 짝 바로 오른쪽으로 옮기고 spouseOf 를 적는다.
List<TreeCell> _pairCouples(KinGraph g, List<TreeCell> cells) {
final out = <TreeCell>[];
final used = <String>{};
for (final c in cells) {
if (used.contains(c.id)) continue;
// 배우자가 먼저 나올 차례라면, 혈연 쪽(배우자가 아닌 쪽)을 앞에 둔다.
out.add(c);
used.add(c.id);
for (final s in g.spousesOf(c.id)) {
final i = cells.indexWhere((x) => x.id == s);
if (i < 0 || used.contains(s)) continue;
final sc = cells[i];
out.add(TreeCell(sc.id, sc.relation, sc.side, spouseOf: c.id));
used.add(s);
}
}
return out;
}

View File

@ -0,0 +1,167 @@
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 'person_screen.dart';
class _Data {
const _Data(this.person, this.history, this.around);
final Person? person;
final List<StintView> history;
final List<AroundPerson> around;
}
/// 이직 이력: 회사마다 몇 년부터 몇 년까지 다녔는지, 그 회사에 같이 있던 내 인맥,
/// 그리고 이 사람의 주변 사람(같은 그룹·같은 회사, 내 기록 기준). 사람을 누르면 그 사람의 이력으로 넘어간다.
class CareerHistoryScreen extends StatelessWidget {
const CareerHistoryScreen({super.key, required this.personId});
final String personId;
Future<_Data> _load(FriendlyApp a) async => _Data(await a.friends.person(personId), await a.career.history(personId), await a.career.around(personId));
Future<void> _edit(BuildContext context, StintView s) async {
final app = AppScope.of(context).app;
final from = TextEditingController(text: s.stint.from?.toString() ?? '');
final to = TextEditingController(text: s.stint.to?.toString() ?? '');
var current = s.stint.current;
final ok = await showDialog<bool>(
context: context,
builder: (c) => StatefulBuilder(
builder: (c, set) => AlertDialog(
title: Text('${s.stint.company} 근무 기간'),
content: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(controller: from, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '들어간 해 (모르면 비워 두기)', hintText: '예: 2019')),
const SizedBox(height: 10),
TextField(controller: to, enabled: !current, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '나온 해 (모르면 비워 두기)', hintText: '예: 2024')),
SwitchListTile(contentPadding: EdgeInsets.zero, title: const Text('지금도 다니는 중'), value: current, onChanged: (v) => set(() => current = v)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('취소')),
TextButton(onPressed: () => Navigator.pop(c, true), child: const Text('저장')),
],
),
),
);
final f = int.tryParse(from.text.trim()), t = current ? null : int.tryParse(to.text.trim());
from.dispose();
to.dispose();
if (ok == true) await app.career.setYears(personId, s.index, from: f, to: t, current: current);
}
void _mates(BuildContext context, StintView s) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (c) => SafeArea(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.6,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Text('${s.stint.company}에 같이 있던 사람', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
),
Padding(padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), child: Text('${s.stint.period} · 내 기록 기준', style: const TextStyle(fontSize: 12, color: Pal.muted))),
Expanded(
child: ListView(children: [
for (final p in s.colleagues)
ListTile(
leading: Avatar(p.name, size: 40, color: Pal.company),
title: Text(p.name),
subtitle: const Text('누르면 이 사람의 이력과 주변 사람'),
onTap: () {
Navigator.pop(c);
pushPage(context, CareerHistoryScreen(personId: p.id));
},
),
]),
),
]),
),
),
);
}
@override
Widget build(BuildContext context) {
return AsyncView<_Data>(
load: _load,
builder: (context, d) {
final p = d.person;
if (p == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('찾을 수 없는 사람이에요.'));
final now = d.history.where((h) => h.stint.current).firstOrNull;
return Scaffold(
appBar: AppBar(title: const Text('이직 이력'), actions: [
IconButton(tooltip: '프로필', onPressed: () => pushPage(context, PersonScreen(personId: p.id)), icon: const Icon(Icons.person_outline)),
]),
body: ListView(padding: const EdgeInsets.only(bottom: 32), children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Row(children: [
Avatar(p.name, size: 56, color: Pal.company),
const SizedBox(width: 14),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(p.name, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800)),
Text(now == null ? (d.history.isEmpty ? '이력이 없어요' : '지금은 다니는 곳이 없어요') : '지금 ${now.stint.company}', style: const TextStyle(color: Pal.muted)),
]),
),
]),
),
const SectionHeader('다닌 회사'),
if (d.history.isEmpty) const EmptyNote('회사 명함을 받으면 이력이 쌓여요.'),
for (final s in d.history)
Panel(
onTap: s.colleagues.isEmpty ? null : () => _mates(context, s),
child: Row(children: [
Container(
width: 4,
height: 56,
decoration: BoxDecoration(color: s.stint.current ? Pal.accent : Pal.line, borderRadius: BorderRadius.circular(2)),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Flexible(child: Text(s.stint.company, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: s.stint.current ? Pal.text : Pal.muted))),
if (s.stint.current)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
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: 2),
Text([s.stint.period, if (s.stint.years(DateTime.now().year) case final y?) '$y년'].join(' · '), style: const TextStyle(fontSize: 13, color: Pal.muted)),
const SizedBox(height: 2),
Text(s.colleagues.isEmpty ? '같이 있던 내 인맥 없음' : '같이 있던 내 인맥 ${s.colleagues.length}명 ›', style: TextStyle(fontSize: 12, color: s.colleagues.isEmpty ? Pal.muted : Pal.company)),
]),
),
IconButton(tooltip: '기간 고치기', onPressed: () => _edit(context, s), icon: const Icon(Icons.edit_outlined, size: 20, color: Pal.muted)),
]),
),
const SectionHeader('주변 사람'),
const Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, 6),
child: Text('내 기록에서 같은 그룹이거나 같은 회사에 있던 사람이에요.', style: TextStyle(fontSize: 12, color: Pal.muted)),
),
if (d.around.isEmpty) const EmptyNote('아직 주변 사람이 없어요.'),
for (final a in d.around)
ListTile(
leading: Avatar(a.person.name, size: 40),
title: Text(a.person.name),
subtitle: Text(a.reasons.join('\n')),
isThreeLine: a.reasons.length > 1,
onTap: () => pushPage(context, CareerHistoryScreen(personId: a.person.id)),
),
]),
);
},
);
}
}

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

@ -0,0 +1,282 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/circles/circles.dart';
import 'package:friendly_map/modules/core/core.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
import '../widgets/pin_dialog.dart';
import '../widgets/ring_view.dart';
import '../widgets/work_dialogs.dart';
import 'group_detail_screen.dart';
import 'person_screen.dart';
class _Data {
const _Data(this.view, this.people, this.me, this.split);
final GroupView? view;
final List<CirclePerson> people;
final String me;
final bool split; // 반·팀별로 나눠 보기
}
enum _Mode { ring, list }
/// 한 칸(초등학교, 한빛전자 …)의 사람들: 가까운 순으로 원 위에 펼치거나, 연차·직급·반별 목록으로 본다.
/// 사람을 골라 단체 메시지나 긴급 호출을 보낼 수 있다. 보는 방식은 설정으로 바꾼다.
class CircleGroupScreen extends StatefulWidget {
const CircleGroupScreen({super.key, required this.groupId});
final String groupId;
@override
State<CircleGroupScreen> createState() => _CircleGroupScreenState();
}
class _CircleGroupScreenState extends State<CircleGroupScreen> {
_Mode _mode = _Mode.ring;
String _filter = 'all'; // all | senior·same·junior (학교) | above·same·below (회사)
bool _selecting = false;
final Set<String> _picked = {};
Future<_Data> _load(FriendlyApp a) async {
final v = await a.groups.view(widget.groupId);
final kind = v?.group.kind ?? CardKinds.group;
return _Data(v, await a.circles.members(widget.groupId), (await a.profile.me())?.name ?? '나', await a.circles.splitView(kind));
}
bool _match(CirclePerson p, bool school) {
if (_filter == 'all') return true;
if (school) {
final d = p.cohortDiff;
return switch (_filter) { 'senior' => d != null && d < 0, 'same' => d == 0, 'junior' => d != null && d > 0, _ => true };
}
return switch (_filter) {
'above' => p.rankRelation == RankRelation.above,
'same' => p.rankRelation == RankRelation.same,
'below' => p.rankRelation == RankRelation.below,
_ => true,
};
}
void _toggle(String id) => setState(() => _picked.contains(id) ? _picked.remove(id) : _picked.add(id));
void _tap(CirclePerson p) {
if (_selecting) {
_toggle(p.person.id);
} else {
pushPage(context, PersonScreen(personId: p.person.id));
}
}
Future<void> _send({required bool urgent}) async {
final app = AppScope.of(context).app;
final text = urgent ? await askUrgent(context) : await _askText();
if (text == null || !mounted) return;
final n = await app.circles.broadcast(_picked, text, urgent: urgent);
if (!mounted) return;
showNote(context, n == 0 ? '보내지 못했어요 (번호가 없는 사람은 제외돼요)' : '$n명에게 ${urgent ? '긴급 호출을' : '메시지를'} 보냈어요');
setState(() {
_picked.clear();
_selecting = false;
});
}
Future<String?> _askText() async {
final c = TextEditingController();
final r = await showDialog<String>(
context: context,
builder: (d) => AlertDialog(
title: Text('${_picked.length}명에게 보내기'),
content: TextField(controller: c, autofocus: true, minLines: 2, maxLines: 5, decoration: const InputDecoration(hintText: '예: 동창회 이번 토요일 7시예요')),
actions: [
TextButton(onPressed: () => Navigator.pop(d), child: const Text('취소')),
TextButton(onPressed: () => Navigator.pop(d, c.text), child: const Text('보내기')),
],
),
);
c.dispose();
return (r == null || r.trim().isEmpty) ? null : r;
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context).app;
return AsyncView<_Data>(
load: _load,
builder: (context, d) {
final v = d.view;
if (v == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('지워진 그룹이에요.'));
final kind = v.group.kind;
final school = kind == CardKinds.school;
final company = kind == CardKinds.company;
final color = Pal.ofKind(kind);
if (!v.open) {
return Scaffold(
appBar: AppBar(title: Text(v.group.name)),
body: Center(child: FilledButton(onPressed: () => openGroup(context, v), child: const Text('비밀번호로 열기'))),
);
}
final shown = [for (final p in d.people) if (_match(p, school)) p];
return Scaffold(
appBar: AppBar(
title: Text(v.path.replaceAll(' › ', ' · ')),
actions: [
IconButton(tooltip: '목록으로 관리', onPressed: () => pushPage(context, GroupDetailScreen(groupId: widget.groupId)), icon: const Icon(Icons.tune)),
],
),
body: Column(children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: Row(children: [
Expanded(
child: SegmentedButton<_Mode>(
showSelectedIcon: false,
segments: const [
ButtonSegment(value: _Mode.ring, icon: Icon(Icons.radar, size: 18), label: Text('원형')),
ButtonSegment(value: _Mode.list, icon: Icon(Icons.view_list, size: 18), label: Text('목록')),
],
selected: {_mode},
onSelectionChanged: (s) => setState(() => _mode = s.first),
),
),
const SizedBox(width: 8),
FilterChip(
label: Text(_selecting ? '고르는 중 ${_picked.length}' : '고르기'),
selected: _selecting,
onSelected: (on) => setState(() {
_selecting = on;
if (!on) _picked.clear();
}),
),
]),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: SizedBox(width: double.infinity, child: Wrap(spacing: 6, runSpacing: 6, children: [
for (final (k, t) in school
? const [('all', '전체'), ('senior', '선배'), ('same', '동기'), ('junior', '후배')]
: company
? const [('all', '전체'), ('above', '윗사람'), ('same', '같은 급'), ('below', '아래 직원')]
: const [('all', '전체')])
ChoiceChip(label: Text(t), selected: _filter == k, onSelected: (_) => setState(() => _filter = k)),
FilterChip(
label: Text(school ? '반별로 나눠 보기' : '팀별로 나눠 보기'),
selected: d.split,
onSelected: (v2) => app.circles.setSplitView(kind, v2),
),
])),
),
if (_selecting)
Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 2),
child: Row(children: [
TextButton(onPressed: () => setState(() => _picked.addAll(shown.map((p) => p.person.id))), child: Text('보이는 ${shown.length}명 모두')),
if (company) ...[
TextButton(onPressed: () => setState(() => _picked.addAll(d.people.where((p) => p.rankRelation == RankRelation.above).map((p) => p.person.id))), child: const Text('윗사람 전체')),
TextButton(onPressed: () => setState(() => _picked.addAll(d.people.where((p) => p.rankRelation == RankRelation.below).map((p) => p.person.id))), child: const Text('아래 직원 전체')),
],
const Spacer(),
if (_picked.isNotEmpty) TextButton(onPressed: () => setState(_picked.clear), child: const Text('풀기')),
]),
),
Expanded(
child: shown.isEmpty
? EmptyNote(d.people.isEmpty ? '아직 사람이 없어요.\n목록으로 관리에서 사람을 넣어 보세요.' : '맞는 사람이 없어요.')
: _mode == _Mode.ring
? ListView(children: [_ring(shown, d, color, school), const SizedBox(height: 24)])
: _list(shown, d, color, school, company),
),
if (_picked.isNotEmpty)
Container(
color: Pal.surface,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(children: [
Expanded(child: OutlinedButton.icon(onPressed: () => _send(urgent: false), icon: const Icon(Icons.send, size: 18), label: Text('메시지 ${_picked.length}명'))),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: Pal.urgent, foregroundColor: Colors.white),
onPressed: () => _send(urgent: true),
icon: const Icon(Icons.campaign, size: 18),
label: const Text('긴급 호출'),
),
),
]),
),
),
),
]),
);
},
);
}
String _sub(CirclePerson p, bool school) => school ? (p.year != null ? p.cohort : '') : (p.position ?? '');
Widget _ring(List<CirclePerson> shown, _Data d, Color color, bool school) {
final ordered = rankByCloseness([for (final p in shown) Closeness(p.person.id, messages: p.score)]);
final byId = {for (final p in shown) p.person.id: p};
final rs = rings([for (final c in ordered) byId[c.id]!]);
return RingView(
center: MeDot(d.me),
rings: [
for (final r in rs)
[
for (final p in r)
RingDot(
label: p.person.name,
sub: _sub(p, school),
color: color,
selected: _picked.contains(p.person.id),
onTap: () => _tap(p),
),
],
],
);
}
Widget _list(List<CirclePerson> shown, _Data d, Color color, bool school, bool company) {
// 묶는 기준: 나눠 보기면 반·팀, 아니면 학교는 연차, 회사는 직급 관계.
String key(CirclePerson p) {
if (d.split) return p.subgroup ?? '기타';
if (school) return p.cohortDiff == null ? '연도 모름' : p.cohort;
return switch (p.rankRelation) { RankRelation.above => '윗사람', RankRelation.same => '같은 급', RankRelation.below => '아래 직원', RankRelation.unknown => '직급 모름' };
}
final groups = <String, List<CirclePerson>>{};
for (final p in shown) {
groups.putIfAbsent(key(p), () => []).add(p);
}
final keys = groups.keys.toList();
if (!d.split && school) {
keys.sort((a, b) => cohortOrder(groups[a]!.first.cohortDiff).compareTo(cohortOrder(groups[b]!.first.cohortDiff)));
} else if (!d.split && company) {
const order = ['윗사람', '같은 급', '아래 직원', '직급 모름'];
keys.sort((a, b) => order.indexOf(a).compareTo(order.indexOf(b)));
}
return ListView(padding: const EdgeInsets.only(bottom: 24), children: [
for (final k in keys) ...[
SectionHeader('$k ${groups[k]!.length}명'),
for (final p in groups[k]!)
ListTile(
leading: _selecting
? Checkbox(value: _picked.contains(p.person.id), onChanged: (_) => _toggle(p.person.id))
: Avatar(p.person.name, size: 40, color: color),
title: Text(p.person.name),
subtitle: Text([
if (company && p.position != null) p.position!,
if (school && d.split && p.year != null) p.cohort,
if (!d.split && p.subgroup != null) p.subgroup!,
].join(' · ')),
trailing: p.score > 0 ? Text('${p.score}', style: const TextStyle(color: Pal.accent, fontWeight: FontWeight.w700)) : null,
onTap: () => _tap(p),
),
],
]);
}
}

View File

@ -0,0 +1,92 @@
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 '../widgets/pin_dialog.dart';
import '../widgets/ring_view.dart';
import 'circle_group_screen.dart';
import 'group_detail_screen.dart';
import 'person_screen.dart';
class _Data {
const _Data(this.hub, this.me);
final CircleHub hub;
final String me;
}
/// 학교·회사 원형 보기: 나를 가운데 두고 둘레에 초·중·고·대(또는 회사들)를 놓고,
/// 그 옆에 대화·통화가 많은 가까운 사람을 보여 준다. 칸을 누르면 그 칸 사람들이 원 모양으로 펼쳐진다.
class CircleHubScreen extends StatelessWidget {
const CircleHubScreen({super.key, required this.rootId});
final String rootId;
@override
Widget build(BuildContext context) {
return AsyncView<_Data>(
load: (a) async => _Data(await a.circles.hub(rootId), (await a.profile.me())?.name ?? '나'),
builder: (context, d) {
final root = d.hub.root;
if (root == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('지워진 그룹이에요.'));
final kind = root.group.kind;
final color = Pal.ofKind(kind);
if (!root.open) {
return Scaffold(
appBar: AppBar(title: Text(root.group.name)),
body: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.lock, size: 48, color: Pal.muted),
const SizedBox(height: 12),
FilledButton(onPressed: () => openGroup(context, root), child: const Text('비밀번호로 열기')),
]),
),
);
}
final levels = d.hub.levels;
return Scaffold(
appBar: AppBar(
title: Text(root.group.name),
actions: [IconButton(tooltip: '목록으로 보기', onPressed: () => pushPage(context, GroupDetailScreen(groupId: rootId)), icon: const Icon(Icons.list))],
),
body: ListView(padding: const EdgeInsets.only(bottom: 32), children: [
if (levels.isEmpty)
const EmptyNote('아래에 칸이 없어요.\n목록으로 보기에서 하위 그룹을 만들어 보세요.')
else
RingView(
center: MeDot(d.me),
rings: [
[
for (final l in levels)
RingDot(
label: l.view.group.name,
sub: '${l.count}명',
color: l.count == 0 ? Pal.muted : color,
faded: l.count == 0,
onTap: () => pushPage(context, CircleGroupScreen(groupId: l.view.group.id)),
),
],
],
),
const SectionHeader('가까운 사람'),
if (d.hub.closest.isEmpty) const EmptyNote('대화나 통화가 쌓이면 가까운 사람이 여기 보여요.'),
for (final c in d.hub.closest)
ListTile(
leading: Avatar(c.person.name, size: 40, color: color),
title: Text(c.person.name),
subtitle: Text([
if (c.subgroup != null) c.subgroup!,
if (kind == CardKinds.school && c.cohortDiff != null) c.cohort,
if (kind == CardKinds.company && c.position != null) c.position!,
].join(' · ')),
trailing: c.score > 0 ? Text('${c.score}', style: const TextStyle(color: Pal.accent, fontWeight: FontWeight.w700)) : null,
onTap: () => pushPage(context, PersonScreen(personId: c.person.id)),
),
]),
);
},
);
}
}

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

@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/kinship/kinship.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
import 'gathering_screen.dart';
import 'person_screen.dart';
Color _sideColor(FamilySide s) => switch (s) {
FamilySide.paternal => Pal.company,
FamilySide.maternal => Pal.group,
FamilySide.inlaw => Pal.school,
_ => Pal.accent,
};
/// 가계도: 기준 사람을 가운데 두고 위로 윗세대, 옆에 배우자, 아래로 자식.
/// 사람을 누르면 그 사람 기준으로 다시 보거나, 그 사람의 부모·배우자·자녀·형제자매를 더한다.
class FamilyTreeScreen extends StatefulWidget {
const FamilyTreeScreen({super.key});
@override
State<FamilyTreeScreen> createState() => _FamilyTreeScreenState();
}
class _FamilyTreeScreenState extends State<FamilyTreeScreen> {
String _focus = KinGraph.meId;
Future<void> _addTo(String toId, String toName, KinLink link) async {
final app = AppScope.of(context).app;
final r = await showDialog<(String, Gender)>(context: context, builder: (_) => _NewRelative(toName: toName, link: link));
if (r == null || !mounted) return;
final p = await app.kin.addRelative(r.$1, gender: r.$2);
if (p == null) return;
final placed = await app.relate.asFamily(p.id, link: link, to: toId, gender: r.$2);
if (mounted && placed == null) showNote(context, '이을 수 없어요. 부모는 두 분까지예요.');
}
Future<void> _tap(TreeNodeView n) async {
final me = n.id == KinGraph.meId;
final pick = await showModalBottomSheet<String>(
context: context,
builder: (c) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Text(n.focus ? n.name : '${n.name} · ${n.title}${n.chon == null || n.chon == 0 ? '' : ' (${n.chon}촌)'}', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
),
if (!n.focus) ListTile(leading: const Icon(Icons.center_focus_strong), title: Text('${n.name} 기준으로 보기'), subtitle: const Text('촌수와 호칭이 이 분 기준으로 바뀌어요'), onTap: () => Navigator.pop(c, 'focus')),
if (!me) ListTile(leading: const Icon(Icons.person_outline), title: const Text('프로필 · 생일 적기'), onTap: () => Navigator.pop(c, 'profile')),
const Divider(),
for (final (k, t) in const [('parent', '부모 더하기'), ('spouse', '배우자 더하기'), ('child', '자녀 더하기'), ('sibling', '형제자매 더하기')])
ListTile(leading: const Icon(Icons.add), title: Text('${n.name}의 $t'), onTap: () => Navigator.pop(c, k)),
]),
),
);
if (!mounted || pick == null) return;
switch (pick) {
case 'focus':
setState(() => _focus = n.id);
case 'profile':
pushPage(context, PersonScreen(personId: n.id));
default:
await _addTo(n.id, n.name, KinLink.values.byName(pick));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('가계도'),
actions: [IconButton(tooltip: '목록으로 보기', onPressed: () => pushPage(context, const GatheringScreen()), icon: const Icon(Icons.list))],
),
body: AsyncView<FamilyTreeView>(
key: ValueKey(_focus),
load: (a) => a.familyTree.tree(focus: _focus),
builder: (context, t) => ListView(padding: const EdgeInsets.only(bottom: 40), children: [
_FocusBar(t, current: _focus, onFocus: (id) => setState(() => _focus = id)),
const _Legend(),
if (t.empty)
Panel(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('아직 가족이 없어요', style: TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
const Text('나를 누르거나 아래 버튼으로 부모·배우자·자녀를 더해 보세요.', style: TextStyle(fontSize: 13, color: Pal.muted)),
const SizedBox(height: 10),
Wrap(spacing: 8, children: [
for (final (l, t2) in const [(KinLink.parent, '부모'), (KinLink.spouse, '배우자'), (KinLink.child, '자녀')])
OutlinedButton(onPressed: () => _addTo(t.focusId, t.focusName, l), child: Text('$t2 더하기')),
]),
]),
),
for (final r in t.rows) _GenerationRow(r, onTap: _tap),
]),
),
);
}
}
class _FocusBar extends StatelessWidget {
const _FocusBar(this.t, {required this.current, required this.onFocus});
final FamilyTreeView t;
final String current;
final ValueChanged<String> onFocus;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(current == KinGraph.meId ? '나를 기준으로 보는 중' : '${t.focusName}님을 기준으로 보는 중', style: const TextStyle(fontSize: 13, color: Pal.muted)),
const SizedBox(height: 6),
Wrap(spacing: 6, runSpacing: 6, children: [
if (current != KinGraph.meId) ActionChip(avatar: const Icon(Icons.undo, size: 16), label: const Text('나로 돌아가기'), onPressed: () => onFocus(KinGraph.meId)),
for (final (id, name) in t.spouses) ActionChip(avatar: const Icon(Icons.favorite_border, size: 16), label: Text('$name 쪽 족보'), onPressed: () => onFocus(id)),
for (final (id, name) in t.children) ActionChip(avatar: const Icon(Icons.child_care, size: 16), label: Text('$name 기준'), onPressed: () => onFocus(id)),
]),
]),
);
}
class _Legend extends StatelessWidget {
const _Legend();
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Row(children: [
for (final (s, t) in const [(FamilySide.paternal, '친가'), (FamilySide.close, '가까운 가족'), (FamilySide.maternal, '외가'), (FamilySide.inlaw, '처가·시가')]) ...[
Container(width: 10, height: 10, decoration: BoxDecoration(color: _sideColor(s), shape: BoxShape.circle)),
const SizedBox(width: 4),
Text(t, style: const TextStyle(fontSize: 11, color: Pal.muted)),
const SizedBox(width: 12),
],
]),
);
}
class _GenerationRow extends StatelessWidget {
const _GenerationRow(this.r, {required this.onTap});
final TreeRowView r;
final ValueChanged<TreeNodeView> onTap;
@override
Widget build(BuildContext context) {
// 부부는 한 테두리 안에 나란히.
final units = <List<TreeNodeView>>[];
for (final n in r.nodes) {
if (n.spouseOf != null && units.isNotEmpty && units.last.first.id == n.spouseOf) {
units.last.add(n);
} else {
units.add([n]);
}
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 6),
child: Text(r.label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: r.gen == 0 ? Pal.accent : Pal.muted)),
),
LayoutBuilder(
builder: (context, box) => SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: box.maxWidth - 24),
child: Row(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final u in units)
Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.all(4),
decoration: u.length > 1 ? BoxDecoration(borderRadius: BorderRadius.circular(16), border: Border.all(color: Pal.line)) : null,
child: Row(children: [for (final n in u) _Node(n, onTap: () => onTap(n))]),
),
]),
),
),
),
]);
}
}
class _Node extends StatelessWidget {
const _Node(this.n, {required this.onTap});
final TreeNodeView n;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final color = n.focus ? Pal.accent : _sideColor(n.side);
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: SizedBox(
width: 92,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(children: [
Opacity(opacity: n.deceased ? 0.5 : 1, child: Avatar(n.name, size: n.focus ? 56 : 48, color: color)),
const SizedBox(height: 6),
Text('${n.deceased ? '故 ' : ''}${n.name}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 13, fontWeight: n.focus ? FontWeight.w800 : FontWeight.w600)),
Text(n.focus ? '기준' : '${n.title}${n.chon == null || n.chon == 0 ? '' : ' ${n.chon}촌'}',
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: n.focus ? Pal.accent : Pal.muted)),
if (n.birthday != null)
Text(n.birthday!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 10, color: Pal.accent)),
]),
),
),
);
}
}
/// 새 가족 한 분: 이름과 성별 (번호 없이 이 기기 안에서만 쓰는 사람).
class _NewRelative extends StatefulWidget {
const _NewRelative({required this.toName, required this.link});
final String toName;
final KinLink link;
@override
State<_NewRelative> createState() => _NewRelativeState();
}
class _NewRelativeState extends State<_NewRelative> {
final _name = TextEditingController();
Gender _g = Gender.unknown;
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final what = switch (widget.link) { KinLink.parent => '부모', KinLink.child => '자녀', KinLink.spouse => '배우자', KinLink.sibling => '형제자매' };
return AlertDialog(
title: Text('${widget.toName}의 $what'),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
TextField(controller: _name, autofocus: true, decoration: const InputDecoration(labelText: '이름 (예: 홍길동, 큰아버지)')),
const SizedBox(height: 12),
Wrap(spacing: 6, children: [
for (final (g, t) in const [(Gender.male, '남'), (Gender.female, '여'), (Gender.unknown, '모름')])
ChoiceChip(label: Text(t), selected: _g == g, onSelected: (_) => setState(() => _g = g)),
]),
const SizedBox(height: 8),
const Text('번호는 나중에 프로필에서 적을 수 있어요. 번호가 없으면 이 폰 안에서만 쓰여요.', style: TextStyle(fontSize: 12, color: Pal.muted)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('취소')),
TextButton(onPressed: () => _name.text.trim().isEmpty ? null : Navigator.pop(context, (_name.text.trim(), _g)), child: const Text('더하기')),
],
);
}
}

View File

@ -11,7 +11,10 @@ import '../widgets/group_dialogs.dart';
import '../widgets/group_menu.dart'; import '../widgets/group_menu.dart';
import '../widgets/pin_dialog.dart'; import '../widgets/pin_dialog.dart';
import 'chat_room_screen.dart'; import 'chat_room_screen.dart';
import 'circle_group_screen.dart';
import 'circle_hub_screen.dart';
import 'contact_import_screen.dart'; import 'contact_import_screen.dart';
import 'family_tree_screen.dart';
import 'person_screen.dart'; import 'person_screen.dart';
class _Data { class _Data {
@ -81,8 +84,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)),
]), ]),
@ -114,6 +119,26 @@ class GroupDetailScreen extends StatelessWidget {
final app = AppScope.of(context).app; final app = AppScope.of(context).app;
final g = v.group; final g = v.group;
return ListView(padding: const EdgeInsets.only(top: 4, bottom: 96), children: [ return ListView(padding: const EdgeInsets.only(top: 4, bottom: 96), children: [
if (g.kind == CardKinds.school || g.kind == CardKinds.company)
Panel(
onTap: () => pushPage(context, v.depth == 1 ? CircleHubScreen(rootId: g.id) : CircleGroupScreen(groupId: g.id)),
child: const Row(children: [
Icon(Icons.radar, color: Pal.accent),
SizedBox(width: 12),
Expanded(child: Text('원형으로 보기 — 가까운 사람·선후배·단체 메시지', style: TextStyle(fontWeight: FontWeight.w600))),
Icon(Icons.chevron_right, color: Pal.muted),
]),
),
if (g.kind == CardKinds.family)
Panel(
onTap: () => pushPage(context, const FamilyTreeScreen()),
child: const Row(children: [
Icon(Icons.account_tree_outlined, color: Pal.accent),
SizedBox(width: 12),
Expanded(child: Text('가계도로 보기 — 촌수·호칭·생일', style: TextStyle(fontWeight: FontWeight.w600))),
Icon(Icons.chevron_right, color: Pal.muted),
]),
),
if (d.children.isNotEmpty || v.canAddChild) if (d.children.isNotEmpty || v.canAddChild)
SectionHeader('하위 그룹', SectionHeader('하위 그룹',
trailing: v.canAddChild trailing: v.canAddChild
@ -144,7 +169,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,52 @@ 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))), CareerHistoryTile(personId: p.id),
CallPanel(d.info), if (d.info.relations.any((v) => v.relation.related))
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 +116,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 +123,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 +172,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,177 @@
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 'package:friendly_map/modules/core/core.dart';
import 'add_friend_screen.dart';
import 'circle_hub_screen.dart';
import 'contact_import_screen.dart';
import 'family_tree_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 FamilyTreeScreen())),
_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, _opens(v))),
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('맞아요'),
),
),
],
];
}
}
/// 학교·회사는 원형 보기로, 나머지는 목록 화면으로.
Widget _opens(GroupView v) =>
(v.group.kind == CardKinds.school || v.group.kind == CardKinds.company) ? CircleHubScreen(rootId: v.group.id) : GroupDetailScreen(groupId: v.group.id);
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

@ -5,6 +5,40 @@ import '../app_scope.dart';
import '../theme.dart'; import '../theme.dart';
import 'async_view.dart'; import 'async_view.dart';
import 'common.dart'; import 'common.dart';
import '../screens/career_history_screen.dart';
/// 프로필의 "이직 이력" 한 줄: 지금 회사와 다닌 회사 수. 이력이 없으면 아무것도 그리지 않는다.
class CareerHistoryTile extends StatelessWidget {
const CareerHistoryTile({super.key, required this.personId});
final String personId;
@override
Widget build(BuildContext context) {
return AsyncView<List<StintView>>(
load: (a) => a.career.history(personId),
builder: (context, h) {
if (h.isEmpty) return const SizedBox.shrink();
final now = h.where((s) => s.stint.current).firstOrNull;
return Panel(
onTap: () => pushPage(context, CareerHistoryScreen(personId: personId)),
child: Row(children: [
const Icon(Icons.timeline, color: Pal.company),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('이직 이력', style: TextStyle(fontSize: 13, color: Pal.muted)),
Text(now == null ? '지금은 다니는 곳 없음' : '${now.stint.company} · ${now.stint.period}', style: const TextStyle(fontWeight: FontWeight.w600)),
if (h.length > 1) Text('이전 회사 ${h.length - 1}곳', style: const TextStyle(fontSize: 12, color: Pal.muted)),
]),
),
const Icon(Icons.chevron_right, color: Pal.muted),
]),
);
},
);
}
}
class _Item { class _Item {
const _Item(this.notice, this.text); const _Item(this.notice, this.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/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,124 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/circles/circles.dart';
import '../theme.dart';
import 'common.dart';
/// 원 위의 한 점: 이름표와 색, 눌렀을 때 할 일.
class RingDot {
const RingDot({required this.label, required this.color, this.sub, this.selected = false, this.faded = false, this.onTap});
final String label;
final String? sub;
final Color color;
final bool selected;
final bool faded;
final VoidCallback? onTap;
}
/// 가운데에 [center] 를 두고, [rings] 의 고리마다 안쪽에서 바깥쪽 원 위에 점을 고르게 놓는다.
/// 고리 하나가 6명을 넘으면 바깥 고리가 하나 더 생긴다 (rings() 로 미리 나눠서 넘긴다).
class RingView extends StatelessWidget {
const RingView({super.key, required this.center, required this.rings, this.centerLabel = '나', this.dotSize = 44});
final Widget center;
final List<List<RingDot>> rings;
final String centerLabel;
final double dotSize;
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, box) {
final w = box.maxWidth;
// 첫 고리는 가운데 "나"와 이름표가 겹치지 않을 만큼 떨어뜨리고, 고리 사이는 점 + 이름표 두 줄 높이만큼 둔다.
final first = dotSize + 52;
final gap = dotSize + 52;
final radius = [for (var i = 0; i < rings.length; i++) first + gap * i];
final reach = (radius.isEmpty ? first : radius.last) + 46;
final canvasW = reach * 2 > w ? reach * 2 : w;
final h = reach * 2 + 16;
// 사람이 많아 원이 화면보다 커지면 통째로 줄여서 화면 안에 맞춘다.
final scale = canvasW > w ? w / canvasW : 1.0;
final cx = canvasW / 2, cy = h / 2;
return SizedBox(
width: w,
height: h * scale,
child: FittedBox(
fit: BoxFit.scaleDown,
child: SizedBox(
width: canvasW,
height: h,
child: Stack(children: [
for (var i = 0; i < rings.length; i++)
Positioned(
left: cx - radius[i],
top: cy - radius[i],
child: Container(width: radius[i] * 2, height: radius[i] * 2, decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: Pal.line))),
),
Positioned(left: cx - 30, top: cy - 30, child: SizedBox(width: 60, height: 60, child: center)),
for (var i = 0; i < rings.length; i++)
for (final e in _spots(rings[i], radius[i]).indexed)
Positioned(
left: cx + e.$2.$1 - 42,
top: cy + e.$2.$2 - dotSize / 2,
child: _Dot(rings[i][e.$1], dotSize),
),
]),
),
),
);
});
}
List<(double, double)> _spots(List<RingDot> ring, double r) => ringPositions(ring.length, r);
}
class _Dot extends StatelessWidget {
const _Dot(this.d, this.size);
final RingDot d;
final double size;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: d.onTap,
child: SizedBox(
width: 84,
child: Column(mainAxisSize: MainAxisSize.min, children: [
Opacity(
opacity: d.faded ? 0.4 : 1,
child: Stack(clipBehavior: Clip.none, children: [
Avatar(d.label, size: size, color: d.color),
if (d.selected)
Positioned(
right: -4,
top: -4,
child: Container(
decoration: const BoxDecoration(color: Pal.accent, shape: BoxShape.circle),
padding: const EdgeInsets.all(2),
child: const Icon(Icons.check, size: 14, color: Colors.black),
),
),
]),
),
const SizedBox(height: 3),
Text(d.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
if (d.sub != null) Text(d.sub!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 10, color: Pal.muted)),
]),
),
);
}
}
/// 가운데 "나" 동그라미.
class MeDot extends StatelessWidget {
const MeDot(this.name, {super.key});
final String name;
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(shape: BoxShape.circle, color: Pal.accent.withValues(alpha: 0.2), border: Border.all(color: Pal.accent, width: 2)),
alignment: Alignment.center,
child: Text(name.isEmpty ? '나' : name.characters.first, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800)),
);
}

View File

@ -1,27 +1,3 @@
# 보관함 서버 # 보관함 서버
잠긴 봉투를 받는 사람이 접속할 때까지 잠시 맡아 두는 우체통입니다. **무조건 wiki 를 참조한다:** [wiki/가이드/보관함 서버.md](../wiki/가이드/보관함%20서버.md)
봉투 속은 기기끼리만 풀 수 있어서 서버는 내용을 읽지 못합니다.
## 실행 (파이썬만 있으면 됨)
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

@ -107,4 +107,58 @@ void main() {
expect(withJosa('ABC', '으로', '로'), 'ABC(으)로'); expect(withJosa('ABC', '으로', '로'), 'ABC(으)로');
expect(withJosa('ABC', '을', '를'), 'ABC을(를)'); expect(withJosa('ABC', '을', '를'), 'ABC을(를)');
}); });
group('이직 이력', () {
final year = DateTime.now().year;
Future<Person> mate(String name, String phone, String company) async {
final p = (await b.app.friends.add(phone, name))!;
final id = 'mate-${p.id}';
await b.app.deps.storage.receivedCards.put(ReceivedCard(
id: id, personId: p.id, kind: CardKinds.company, fieldsJson: encodeFields({'company': company}), receivedAt: 1, updatedAt: 1));
await b.app.career.onCard(id);
return p;
}
test('처음엔 지금 회사만 "~ 현재", 이직하면 이전 회사가 올해로 닫히고 새 회사가 올해부터', () async {
var h = await b.app.career.history(aOfB.id);
expect(h.map((s) => '${s.stint.company} ${s.stint.period}'), ['한빛전자 ~ 현재']);
await moveTo('누리소프트');
h = await b.app.career.history(aOfB.id);
expect(h.map((s) => '${s.stint.company} ${s.stint.period}'), ['누리소프트 $year ~ 현재', '한빛전자 ~ $year']);
});
test('기간을 직접 고치면 반영되고, 같은 회사에 겹쳐 있던 내 인맥이 나온다', () async {
final old = await mate('옛동료', '+821066667777', '한빛전자');
await moveTo('누리소프트');
expect(await b.app.career.setYears(aOfB.id, 1, from: 2015, to: 2020), isTrue);
expect(await b.app.career.setYears(aOfB.id, 9, from: 1), isFalse);
var h = await b.app.career.history(aOfB.id);
expect(h.last.stint.period, '2015 ~ 2020');
expect(h.last.colleagues.map((p) => p.name), ['옛동료'], reason: '옛동료는 기간을 몰라 겹친 것으로 본다');
await b.app.career.setYears(old.id, 0, from: 2022, current: true);
h = await b.app.career.history(aOfB.id);
expect(h.last.colleagues, isEmpty, reason: '2022년부터 다닌 사람은 2015~2020 과 겹치지 않는다');
expect(h.first.colleagues, isEmpty);
});
test('주변 사람: 같은 그룹, 같은 회사에 같이 있었던 사람 (이유가 많은 순)', () async {
final x = await mate('같은팀', '+821055550001', '한빛전자');
final y = await mate('그냥동료', '+821055550002', '한빛전자');
final hb = (await b.app.groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!;
await b.app.groups.addMembers(hb.id, [aOfB.id, x.id]);
final around = await b.app.career.around(aOfB.id);
expect(around.map((e) => e.person.name), ['같은팀', '그냥동료']);
expect(around.first.reasons, contains('같은 그룹 · 회사 › 한빛전자'));
expect(around.first.reasons, contains('같은 회사 · 한빛전자'));
expect(around.last.reasons, ['같은 회사 · 한빛전자']);
expect(y.name, '그냥동료');
});
test('퇴사(지금은 다니지 않음)하면 기간이 올해로 닫히고 지금 다니는 곳이 없다', () async {
await moveTo('한빛전자', former: true);
final h = await b.app.career.history(aOfB.id);
expect(h.single.stint.period, '~ $year');
});
});
} }

View File

@ -0,0 +1,82 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/circles/circles.dart';
import 'package:friendly_map/modules/core/core.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import 'package:friendly_map/modules/transport/transport.dart';
import 'package:friendly_map/modules/work/work.dart';
import 'support/app_device.dart';
void main() {
late AppDevice me;
late FriendlyApp app;
late Person a, b, c;
Future<void> card(Person p, String kind, Map<String, String> f) => app.deps.storage.receivedCards.put(
ReceivedCard(id: 'rc-${p.id}-$kind', personId: p.id, kind: kind, fieldsJson: encodeFields(f), receivedAt: 1, updatedAt: 1));
setUp(() async {
me = await AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '나');
app = me.app;
a = (await app.friends.add('+821033334444', '김선배'))!;
b = (await app.friends.add('+821055556666', '이동기'))!;
c = (await app.friends.add('+821077778888', '박후배'))!;
final mine = (await app.cards.mine());
await app.cards.edit(mine.firstWhere((x) => x.kind == CardKinds.school).id, fields: {'name': '나', 'school': '가람고', 'year': '2006'});
await app.cards.edit(mine.firstWhere((x) => x.kind == CardKinds.company).id, fields: {'name': '나', 'company': '한빛', 'position': '과장'});
await card(a, CardKinds.school, {'year': '2004'});
await card(b, CardKinds.school, {'year': '2006'});
await card(c, CardKinds.school, {'year': '2007년 입학'});
final cls = (await app.groups.add('27회 3반', CardKinds.school, parent: 'school.high'))!;
await app.groups.addMembers('school.high', [b.id, c.id]);
await app.groups.addMembers(cls.id, [a.id]);
final room = (await app.chats.openDirect(c.id, CardKinds.personal))!;
for (var i = 0; i < 3; i++) {
await app.chats.send(room.id, '안녕 $i');
}
});
test('구성원은 가까운 순(대화 많은 사람 먼저), 선배·동기·후배와 하위 그룹(반)이 붙는다', () async {
final ms = await app.circles.members('school.high');
expect(ms.first.person.id, c.id, reason: '대화 3번');
final by = {for (final m in ms) m.person.name: m};
expect(by['김선배']!.cohort, '2년 선배');
expect(by['이동기']!.cohort, '동기');
expect(by['박후배']!.cohort, '1년 후배');
expect(by['김선배']!.subgroup, '27회 3반');
expect(by['이동기']!.subgroup, isNull);
});
test('학교 가운데: 초·중·고·대·대학원 칸과 사람 수, 전체에서 가까운 사람', () async {
final hub = await app.circles.hub(CardKinds.school);
expect(hub.levels.map((l) => l.view.group.name), ['초등학교', '중학교', '고등학교', '대학교', '대학원']);
expect(hub.levels.firstWhere((l) => l.view.group.name == '고등학교').count, 3);
expect(hub.closest.first.person.id, c.id);
});
test('회사: 직책으로 윗사람·같은 급·아래 직원', () async {
await card(a, CardKinds.company, {'position': '부장'});
await card(b, CardKinds.company, {'position': '과장'});
await card(c, CardKinds.company, {'position': '사원'});
final hb = (await app.groups.add('한빛', CardKinds.company, parent: CardKinds.company))!;
await app.groups.addMembers(hb.id, [a.id, b.id, c.id]);
final rel = {for (final m in await app.circles.members(hb.id)) m.person.name: m.rankRelation};
expect(rel, {'김선배': RankRelation.above, '이동기': RankRelation.same, '박후배': RankRelation.below});
});
test('단체 메시지: 고른 사람 각자에게, 긴급이면 긴급 호출로', () async {
expect(await app.circles.broadcast([a.id, b.id, a.id], '동창회 오늘 7시'), 2);
expect(await app.circles.broadcast([b.id], '모임 장소 바뀜', urgent: true), 1);
final rooms = await app.chats.summaries();
final last = rooms.firstWhere((r) => r.room.title == '이동기').last!;
expect(WorkNote.parse(last.body), isA<UrgentNote>());
});
test('나눠 보기 설정은 종류마다 따로', () async {
expect(await app.circles.splitView(CardKinds.school), isFalse);
await app.circles.setSplitView(CardKinds.school, true);
expect(await app.circles.splitView(CardKinds.school), isTrue);
expect(await app.circles.splitView(CardKinds.company), isFalse);
});
}

45
test/circles_test.dart Normal file
View File

@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:friendly_map/modules/circles/circles.dart';
void main() {
test('가까움: 통화는 대화의 3배, 같은 점수는 id 순', () {
final r = rankByCloseness(const [Closeness('a', messages: 5), Closeness('b', calls: 2), Closeness('c', messages: 6), Closeness('d')]);
expect(r.map((c) => c.id), ['b', 'c', 'a', 'd']);
expect(const Closeness('b', calls: 2).score, 6);
});
test('고리 나누기: 6명, 12명, 18명 …', () {
final g = rings(List.generate(20, (i) => i));
expect(g.map((x) => x.length), [6, 12, 2]);
expect(rings(<int>[]), isEmpty);
});
test('선배·동기·후배와 연도 읽기', () {
expect(cohortLabel(yearsApart(2006, 2004)), '2년 선배');
expect(cohortLabel(yearsApart(2006, 2006)), '동기');
expect(cohortLabel(yearsApart(2006, 2007)), '1년 후배');
expect(cohortLabel(yearsApart(null, 2007)), '연도 모름');
expect(parseYear('2009년 입학'), 2009);
expect(parseYear('09학번'), 2009);
expect(parseYear('98학번'), 1998);
expect(parseYear('27회'), isNull);
expect([3, null, -2, 0].map(cohortOrder).toList()..sort(), [-2, 0, 3, 1 << 20]);
});
test('직급: 글자에서 찾고 윗사람·아래 직원 가르기', () {
expect(rankOf('결제팀 과장'), 4);
expect(rankOf('팀장 겸 부장'), 6);
expect(rankOf('개발자'), isNull);
expect(compareRank(4, 6), RankRelation.above);
expect(compareRank(4, 3), RankRelation.below);
expect(compareRank(4, 4), RankRelation.same);
expect(compareRank(null, 4), RankRelation.unknown);
});
test('원 위 자리: 12시부터 시계 방향', () {
final p = ringPositions(4, 10);
expect(p[0].$1, closeTo(0, 1e-9));
expect(p[0].$2, closeTo(-10, 1e-9));
expect(p[1].$1, closeTo(10, 1e-9));
});
}

View File

@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:friendly_map/modules/kinship/kinship.dart';
const M = Gender.male, F = Gender.female;
KinGraph family() {
final g = KinGraph();
for (final (id, gen, y) in [
('me', M, 1990), ('wife', F, 1991), ('son', M, 2020), ('daughter', F, 2022),
('dad', M, 1960), ('mom', F, 1962), ('gf', M, 1930), ('gm', F, 1932), ('mgf', M, 1935),
('uncle', M, 1958), ('sis', F, 1993), ('wifeDad', M, 1963), ('wifeMom', F, 1964),
]) {
g.put(KinPerson(id, gender: gen, birthYear: y));
}
g.addParent('me', 'dad');
g.addParent('me', 'mom');
g.addParent('sis', 'dad');
g.addParent('sis', 'mom');
g.addParent('dad', 'gf');
g.addParent('dad', 'gm');
g.addParent('uncle', 'gf');
g.addParent('mom', 'mgf');
g.addSpouse('dad', 'mom');
g.addSpouse('gf', 'gm');
g.addSpouse('me', 'wife');
g.addParent('son', 'me');
g.addParent('son', 'wife');
g.addParent('daughter', 'me');
g.addParent('wife', 'wifeDad');
g.addParent('wife', 'wifeMom');
g.addSpouse('wifeDad', 'wifeMom');
return g;
}
List<String> ids(TreeRow r) => [for (final c in r.cells) c.id];
void main() {
test('나 가운데: 위로 부모·조부모, 옆에 아내, 아래 자식', () {
final rows = familyTree(family(), 'me');
expect(rows.map((r) => r.gen), [-2, -1, 0, 1]);
final me = rows.firstWhere((r) => r.gen == 0);
expect(ids(me).indexOf('wife'), ids(me).indexOf('me') + 1, reason: '부부는 나란히');
expect(me.cells.firstWhere((c) => c.id == 'wife').spouseOf, 'me');
expect(ids(rows.firstWhere((r) => r.gen == 1)), ['son', 'daughter']);
});
test('친가는 왼쪽, 외가·처가는 오른쪽', () {
final rows = familyTree(family(), 'me');
final parents = ids(rows.firstWhere((r) => r.gen == -1));
expect(parents.indexOf('uncle'), lessThan(parents.indexOf('dad')), reason: '큰아버지(친가)가 왼쪽');
expect(parents.indexOf('wifeDad'), greaterThan(parents.indexOf('mom')), reason: '장인(처가)이 오른쪽');
expect(parents.indexOf('dad'), lessThan(parents.indexOf('mom')), reason: '부부는 남자가 왼쪽');
final gp = ids(rows.firstWhere((r) => r.gen == -2));
expect(gp.indexOf('gf'), lessThan(gp.indexOf('mgf')));
});
test('호칭은 기준 사람 눈으로: 아들을 기준으로 바꾸면 나는 아버지, 내 아버지는 할아버지', () {
final g = family();
final rows = familyTree(g, 'son');
TreeCell cell(String id) => rows.expand((r) => r.cells).firstWhere((c) => c.id == id);
expect(cell('son').isFocus, isTrue);
expect(cell('me').relation.title, '아버지');
expect(cell('dad').relation.title, contains('할아버지'));
expect(cell('daughter').relation.chon, 2);
expect(rows.first.gen, -3, reason: '아들 기준으로는 증조부모까지');
});
test('세대 범위 밖은 뺀다, 없는 사람은 빈 가계도', () {
expect(familyTree(family(), 'me', up: 1, down: 0).map((r) => r.gen), [-1, 0]);
expect(familyTree(family(), '없음'), isEmpty);
expect(const TreeRow(-1, []).label('나'), '부모 세대');
expect(const TreeRow(0, []).label('나'), '나');
});
}

54
test/history_test.dart Normal file
View File

@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:friendly_map/modules/history/history.dart';
void main() {
test('이직하면 이전 회사는 끝난 해로 닫히고 새 회사가 지금 다니는 곳이 된다', () {
final c = Career()
..begin('한빛전자', year: 2019)
..begin('누리소프트', year: 2024);
expect(c.newestFirst.map((s) => s.company), ['누리소프트', '한빛전자']);
expect(c.newestFirst.last.period, '2019 ~ 2024');
expect(c.now!.period, '2024 ~ 현재');
expect(c.now!.years(2026), 2);
});
test('같은 회사를 다시 알려도 그대로, 빈 회사 이름은 무시, 그만두면 닫힘', () {
final c = Career()..begin('한빛', year: 2020);
c.begin('한빛', year: 2025);
c.begin(' ');
expect(c.stints.length, 1);
expect(c.stints.single.from, 2020);
c.leave(year: 2026);
expect(c.now, isNull);
expect(c.stints.single.period, '2020 ~ 2026');
});
test('연도를 모르면 "기간 모름" · "~ 현재", 사용자가 기간을 고칠 수 있다', () {
final c = Career()..begin('한빛');
expect(c.now!.period, '~ 현재');
expect(Career().stints, isEmpty);
expect(const Stint('x').period, '기간 모름');
expect(c.setYears(0, from: 2018, current: true), isTrue);
expect(c.now!.period, '2018 ~ 현재');
expect(c.setYears(5, from: 1), isFalse);
});
test('같은 회사에서 기간이 겹치는지 (연도를 모르면 겹침으로)', () {
const a = Stint('한빛', from: 2015, to: 2020);
expect(a.overlaps(const Stint('한빛', from: 2019, to: 2023), 2026), isTrue);
expect(a.overlaps(const Stint('한빛', from: 2021, to: 2023), 2026), isFalse);
expect(a.overlaps(const Stint('한빛', from: 2021, current: true), 2026), isFalse);
expect(a.overlaps(const Stint('한빛'), 2026), isTrue);
expect(a.overlaps(const Stint('누리', from: 2015, to: 2020), 2026), isFalse);
});
test('글자로 저장했다 되살리기, 깨진 글자는 빈 이력', () {
final c = Career()
..begin('한빛', year: 2019)
..begin('누리', year: 2024);
final d = Career.fromJson(c.toJson());
expect(d.newestFirst.map((s) => s.period), ['2024 ~ 현재', '2019 ~ 2024']);
expect(Career.fromJson('{').stints, isEmpty);
expect(Career.fromJson('[{"x":1}]').stints, isEmpty);
});
}

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: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 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: 171 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: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 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: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

246
test_ux/shots_test.dart Normal file
View File

@ -0,0 +1,246 @@
// 화면 점검용 캡처 (일반 시험이 아니다). 폰 크기(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/kinship/kinship.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/career_history_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/circle_group_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/circle_hub_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/family_tree_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, '결제 서버가 멈췄어요');
// 가족: 나(남) · 아내 · 아들 · 부모 · 할아버지 · 장인
await app.kin.setMe(gender: Gender.male, birthYear: 1990);
Future<String> rel(String n, Gender g) async => (await app.kin.addRelative(n, gender: g))!.id;
final dad = await rel('이정훈', Gender.male);
await app.relate.asFamily(dad, link: KinLink.parent, gender: Gender.male);
final mom = await rel('박순자', Gender.female);
await app.relate.asFamily(mom, link: KinLink.parent, gender: Gender.female);
await app.kin.setSpouse(dad, mom);
await app.relate.asFamily(await rel('이만복', Gender.male), link: KinLink.parent, to: dad, gender: Gender.male);
final wife = await rel('최유진', Gender.female);
await app.relate.asFamily(wife, link: KinLink.spouse, gender: Gender.female);
await app.relate.asFamily(await rel('최상철', Gender.male), link: KinLink.parent, to: wife, gender: Gender.male);
final son = await rel('이도윤', Gender.male);
await app.relate.asFamily(son, link: KinLink.child, gender: Gender.male);
await app.kin.setParent(son, wife);
await app.facts.save(son, (await app.facts.get(son)).copyWith(birthYear: 2020, birthMonth: 11, birthDay: 3));
// 학교: 고등학교 동문 여럿 (연도·반·대화)
final mineCards = await app.cards.mine();
await app.cards.edit(mineCards.firstWhere((c) => c.kind == CardKinds.school).id, fields: {'name': '이미영', 'school': '가람고', 'year': '2006'});
final cls = (await app.groups.add('27회 3반', CardKinds.school, parent: 'school.high'))!;
var seq = 0;
for (final (nm, yr) in [('정수빈', 2004), ('한지민', 2005), ('오세훈', 2006), ('윤아름', 2006), ('강동원', 2007), ('송혜교', 2007), ('유재석', 2008), ('조인성', 2003)]) {
final p = (await app.friends.add('+82107000${(1000 + seq++)}', nm))!;
await app.deps.storage.receivedCards.put(ReceivedCard(id: 'sc$seq', personId: p.id, kind: CardKinds.school, fieldsJson: encodeFields({'year': '$yr'}), receivedAt: 1, updatedAt: 1));
await app.groups.addMembers('school.high', [p.id]);
if (yr == 2006) await app.groups.addMembers(cls.id, [p.id]);
final room = (await app.chats.openDirect(p.id, CardKinds.personal))!;
for (var i = 0; i < (seq * 2) % 7; i++) {
await app.chats.send(room.id, '안녕 $i');
}
}
// 회사: 한빛전자 직원들 (직책·대화)
await app.cards.edit(mineCards.firstWhere((c) => c.kind == CardKinds.company).id, fields: {'name': '이미영', 'company': '한빛전자', 'position': '과장'});
final all = await app.groups.all();
final hb2 = all.firstWhere((v) => v.group.name == '한빛전자').group;
final pay = all.firstWhere((v) => v.group.name == '결제팀').group;
for (final (nm, pos, g2) in [('김부장', '부장', hb2.id), ('박차장', '차장', pay.id), ('최과장', '과장', pay.id), ('정대리', '대리', pay.id), ('한사원', '사원', pay.id), ('오사원', '사원', hb2.id)]) {
final p = (await app.friends.add('+82108000${(1000 + seq++)}', nm))!;
await app.deps.storage.receivedCards.put(ReceivedCard(id: 'cc$seq', personId: p.id, kind: CardKinds.company, fieldsJson: encodeFields({'company': '한빛전자', 'position': pos}), receivedAt: 1, updatedAt: 1));
await app.groups.addMembers(g2, [p.id]);
final room = (await app.chats.openDirect(p.id, CardKinds.personal))!;
for (var i = 0; i < (seq * 3) % 6; i++) {
await app.chats.send(room.id, '안녕 $i');
}
}
// 김민수가 새 폰으로 다시 연결을 청함
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');
await push(tester, const FamilyTreeScreen());
await shot(tester, '14_family_tree');
await push(tester, const CircleHubScreen(rootId: CardKinds.school));
await shot(tester, '15_school_hub');
await push(tester, const CircleGroupScreen(groupId: 'school.high'));
await shot(tester, '16_school_ring');
await tester.tap(find.text('목록'));
await tester.pumpAndSettle();
await shot(tester, '17_school_list');
await tester.tap(find.text('고르기'));
await tester.pumpAndSettle();
await tester.tap(find.textContaining('모두'));
await tester.pumpAndSettle();
await shot(tester, '18_school_pick');
await push(tester, const CircleHubScreen(rootId: CardKinds.company));
await shot(tester, '19_company_hub');
final minsuId = (await tester.runAsync(() async => (await app.friends.people()).firstWhere((p) => p.name == '김민수').id))!;
await tester.runAsync(() async {
await app.career.seed(); // 위에서 넣은 직원들의 이력
// 김민수는 한빛전자(2019~2024) → 누리소프트로 옮김
Future<void> card(String company) async {
await app.deps.storage.receivedCards.put(ReceivedCard(
id: 'mc', personId: minsuId, kind: CardKinds.company, fieldsJson: encodeFields({'company': company, 'position': '팀장'}), receivedAt: 1, updatedAt: 9));
await app.career.onCard('mc');
}
await card('한빛전자');
await card('누리소프트');
await app.career.setYears(minsuId, 0, from: 2024, current: true);
await app.career.setYears(minsuId, 1, from: 2019, to: 2024);
});
await push(tester, CareerHistoryScreen(personId: minsuId));
await shot(tester, '19c_career_history');
await push(tester, const CircleGroupScreen(groupId: 'g1'));
await tester.tap(find.text('목록'));
await tester.pumpAndSettle();
await shot(tester, '19b_company_list');
});
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

@ -20,9 +20,11 @@ ALLOW = {
"kinship": set(), "kinship": set(),
"groups": {"core"}, "groups": {"core"},
"work": set(), "work": set(),
"circles": set(),
"history": set(),
"events": set(), "events": set(),
"app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work"}, "app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work", "circles", "history"},
"ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work"}, "ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work", "circles", "history"},
} }
# 모듈 입구가 둘 이상인 경우: 입구 파일이 모듈 이름.dart 외에 허용되는 것들. # 모듈 입구가 둘 이상인 경우: 입구 파일이 모듈 이름.dart 외에 허용되는 것들.
EXTRA_BARRELS = {"platform": {"platform_ui.dart"}, "storage": {"storage_sqlite.dart"}} EXTRA_BARRELS = {"platform": {"platform_ui.dart"}, "storage": {"storage_sqlite.dart"}}

View File

@ -6,9 +6,11 @@ cd "$(dirname "$0")/.."
declare -A T=( declare -A T=(
[keys]="keys_test.dart keys_crypto_test.dart shamir_test.dart recovery_shares_test.dart" [keys]="keys_test.dart keys_crypto_test.dart shamir_test.dart recovery_shares_test.dart"
[visibility]="visibility_test.dart" [visibility]="visibility_test.dart"
[kinship]="kinship_test.dart kinship_side_test.dart" [kinship]="kinship_test.dart kinship_side_test.dart family_tree_test.dart"
[groups]="groups_test.dart" [groups]="groups_test.dart"
[work]="work_test.dart" [work]="work_test.dart"
[circles]="circles_test.dart"
[history]="history_test.dart"
[events]="events_test.dart lunar_test.dart" [events]="events_test.dart lunar_test.dart"
[storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart" [storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart"
[transport]="transport_test.dart" [transport]="transport_test.dart"
@ -18,7 +20,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 history_test.dart reconnect_display_test.dart circle_service_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,40 @@
# 최우선 규칙 (모든 규칙보다 위)
- **사용 모델: Claude Sonnet 5.5(`claude-sonnet-5-5`)만 쓴다.** 사용자가 따로 지시하지 않는 한 다른 모델(Opus·Haiku 등)로 바꾸거나 하위 작업에 쓰지 않는다. (2026-10-26 사용자 지시)
지시한 모델이 다른경 우 사용자에게 모델 변경 팝업/메시지가 나타나고 이후 선택된 모델로 진행
- **0순위: 모든 가이드·문서·결정·진행 기록·화면 점검은 이 wiki 보관함에 저장·관리한다.** 세션 시작 때 홈과 이 문서부터 읽고, 일할 때마다 진행 로그·결정 기록을 갱신한다.
- **질문은 설치 가능한 APK 를 먼저 건넨 뒤에만.** 사용자가 폰에 설치해 써 보며 답할 수 있게. 캡처도 함께 보여 주고 "이렇게 하면 / 저렇게 하면" 화면 비교로만 묻는다.
- 암호 비트 수·키 구조·함수 설계 같은 **내부 기술 질문 금지.** 내가 정하고 결과에 적는다.
- **책임 회피성 질문 금지** ("물었으니 사용자 책임").
- 방법론: 화면 먼저 → 화면 보고 사용자가 답 → 그 답에 따라 내부 아키텍처를 바꾼다.
- 묻지 말고 끝까지 진행. 목표는 APK. 멈춰서 대기하지 않는다.
- "목업" 같이 일반사용자들이 사용하지 않는 단어는 일체사용하지 않는다 특희 일본 에서 넘어온 알지못하는 이상한 단어 그리고 중국에서 만드 이상한 단은 일체 사용하지 않는다
-
## 일하는 규칙
- 모든 답변 한글. 고친 파일은 일부 발췌가 아니라 처음부터 끝까지 완본으로 보여 준다. "목업" 대신 "화면 설계".
- 사용자는 솔루션 개발자: 구조가 기능보다 먼저, 화면과 내부 분리.
- 모듈화 필수. 모듈은 다른 모듈을 고치지 않고 빼고 끼울 수 있어야 한다. `tool/check_modules.py` 가 의존 방향 검사(새 모듈은 ALLOW 표에 등록).
- 소스 파일 300~400줄, 최대 700줄.
- 모듈별 시험(`tool/test_module.sh <모듈>`). 시험이 끝난 모듈은 다시 만들지 않고 그 위에 쌓는다.
- 한 번에 한 플랫폼(지금은 안드로이드). 다른 플랫폼은 사용자가 지정할 때 `platform_<이름>` 모듈만 새로 붙인다.
- 단계마다 되묻지 말고 정해서 진행, 결정은 결과와 [[02 결정 기록]]에 적는다. 멈춰서 대기하지 않는다. (되돌릴 수 없는 파괴적 작업만 먼저 묻는다.)
- 고치면 해당 모듈 시험 → 마지막에 전체 시험과 check_modules.py.
- 새 기능은 (1) 순수 모듈 또는 서비스 + 시험 → (2) 화면. 화면을 바꾸면 캡처해서 [[06 화면 점검]]에 붙인다.
- 끝낼 때 한글로: 무엇이 바뀌었나, 무엇을 돌려 확인했나, 확인 못 한 것은 무엇인가. 그리고 [[진행 로그/2026-10-26|진행 로그]] 갱신.
- 저장소: 평소 Gitea 에 커밋·푸시, GitLab 은 하루 한 번 메이저 업그레이드 때만(원격 미등록).
## 질문의 규칙
- 모든 질문은 출력물을 가지고한다.
- 출력물은 개발의 경우 실행파일 등이 실행가능해야된 이를 실행하여 보여주거나 사용자에게 직접 실행햐서 보여줄수 있어야한다.
- 산출물일경우 PPT등을 보여주고 상황 설명을 하고 지시를 받아야 한다.
## 테스트 규칙
- 위 모듈화가 정확하게 되었으면 모듈에 대한 테스트가 완료되면 2번이상 테스트 할필요가 없아야함
- 즉 2번이상 테스트 할필요 없이 모듈화가 되어야 함
## 화면 출력
- 모든 명령의 처음에는 모듈에 대한 몇 수정 릭스트
- 전체 항목애 대한 완료 유무 및 및 작업 시작시간 종료시간 표시
- 산출물 위치 (실행파일일 경우 실행파일 위치 출력물일 경우 출력물 위치 및 목록을 보여줌)

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

@ -0,0 +1,18 @@
# 제품 개요
Android 다크모드 인맥 앱(Flutter/Dart). 옛 "Family MAP" + 카카오톡식 대화.
- 기본은 **채팅**. 채팅하다 관계를 알게 되면 그룹으로 넣는다.
- 가족 → 촌수 맞춰 넣기(친가·외가·처가·시가), 모르면 "친척(촌수 모름)".
촌수에 맞게 나를 기준으로 좌우로 분리가혹 나의 위에는 위 촌사 아래는 아래 촌수 나의 옆에는 부부가 나오게 하고 아래에는 당연하게 자식이 나와야 함 결혼했으면 단영하게 와이프가 나오고 자식이 있으면 자식이 나와야하고 당연하게 생일등이 들어가고 생일 관련 알림 기능 남자를 기준으로 친가가 족보처럼 나오고 여자를 치군을 처가 족보가 조회되어 보여줘야지. 이때 자식을 선택하면 자식 기준으로 촌수 등이 일괄 변경해서 보여주고
- 학교 → 초등·중등·고등·대학·대학원 하위 그룹.
나를 주면으로 초등 중등 고등 대학교 등이 뺑 둘려 보여주고 그옆에 가까운 사람이 보여주고 (많은 대화 전화등을 기준으로) 초등학교를 선택하면 나를 기준으로 초등학교 선택되고 원모양으로 가까운 사람이 부여주고 (동일하게 많은 대화 전화등의 기준으로) 선배 후배 가보여주기 1년차 2년차 도는 년도로 보여주고 선택하면 그 대상의 일괄 보여줘주던지 (또는 사람이 많을 경우 설정에서 반별로 보여주기 또는합처서 보기 등
긴급으로 동문 모임 동창 모임등을 일괄 선택후 그룹으로 보낼수 있어야함
- 회사 → 회사 › 프로젝트·팀, 팀 대화방, 업무 맡기기·정리, 긴급 호출.
학교와 동일 패턴이겟지 이부분은 전부 설정에게 가능해야됨
같은 프로젝트의 경우 내프로제긑 인력에대한 전체 메시지 기능 또는 하위직원 전체 메시지 상위 직원 전체 메시지 등이가능함
- 이직 → 상대가 회사 명함을 다시 보내면 "이직했습니다" 알림, 확인하면 이전 회사는 흐리게·새 회사로 이동·삭제.
회사직했으면 이력 포함조회하면 그전에 있던 회사에 몇년부터 몇년까지 직원 또는 회시에 근무 등으로 보여주면 그사람을 선택하면 그사람의 주변 사람들(내 기록에 있는 사람 기준으로 보여줌 )
- 목적별 명함(개인/회사/학교/그룹·여행/가족), 공개 범위 표, "어디서 봤지?" 유추, 주소록 가져오기, 비밀번호 잠금 그룹.
- 모든 데이터는 기기 안 암호화(SQLCipher), 기기끼리 P2P, 서버는 암호화 우편함뿐.

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

@ -0,0 +1,28 @@
# 결정 기록
## 바꾸지 않는 기술 결정
- 모든 데이터는 기기에 암호화 저장(안드로이드 SQLCipher). 전화번호는 식별자일 뿐 키 재료가 아니다.
- 모든 기기가 서버 겸 클라이언트(P2P). 외부 DB(server/)는 암호화된 임시 우편함 + 기준 시각 제공뿐.
- 복구 코드·조각·보관 키 없음(2026-10-26 변경). 처음 시작은 이름·번호만, 키는 조용히 만든다. 폰을 잃으면 데이터는 끝. 새 폰은 관계를 유지하는 사용자 각자의 승인(다시 연결) 또는 제명(번호 영구 차단). 탈퇴 후 재가입 없음.
- 사람 식별은 전화번호 1순위·이름 2순위.
- 대화는 영구 보관, 1MB/기기 사용률 80% 초과 시 별도 보관(조절 가능). 시각은 1/100초, 충돌 시 먼저 쓴 변경이 앞선다.
- 고친 명함은 "다시 보내기"에서만 보낸다. 그룹 대화 키는 돌리지 않는다.
- 가족 모임 데이터(가계도, 생일, 기일, 통화 주제)는 기기 안에만, 어디로도 보내지 않는다. 번호 없는 친척은 `local:<id>` 번호의 기기 안 전용 사람.
- 그룹·업무 목록·이직 알림·제명 목록도 기기 안에만.
## 날짜별 결정
| 날짜 | 결정 | 이유 |
|---|---|---|
| 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·화면을 먼저 보여 준 뒤에만 | 사용자 최우선 지시 |
| 2026-10-26 | 가계도: 세대 줄 방식(위 조상·옆 배우자·아래 자식), 친가 왼쪽·외가/처가 오른쪽, 부부는 남자 왼쪽, 처가 족보 = 배우자 기준으로 다시 보기 | 사용자 요구(01 제품 개요) |
| 2026-10-26 | 사용 모델은 Sonnet 5.5 만(특별한 지시가 없는 한) | 사용자 지시 |
| 2026-10-26 | 가까움 점수 = 대화 수 + 통화 수 × 3. 선배·후배 = 학교 명함 연도 차이, 윗사람·아래 직원 = 직책 글자의 직급 순서(사원<대리<과장<차장<부장<…). 단체 메시지는 각자에게 1:1 | 사용자 요구(01 제품 개요), 새 통신 형식 없이 기존 대화로 |
| 2026-10-26 | 이직 이력은 회사 명함이 바뀔 때 자동 기록(바뀐 해=받은 해), 처음 안 회사의 들어간 해는 모름, 기간은 사용자가 고칠 수 있음. 주변 사람 = 내 기록의 같은 그룹 + 같은 회사 겹침(연도를 모르면 겹침으로 봄) | 사용자 요구(01 제품 개요) |

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

@ -0,0 +1,25 @@
# 로드맵
- [x] 그룹 정의·잠금·주소록 가져오기
- [x] 1단계 그룹 계층
- [x] 2단계 채팅에서 관계 정하기(촌수, 친가·외가·처가, 촌수 모름)
- [x] 3단계 업무: 팀 대화방, 업무 맡기기·정리, 긴급 호출
- [x] 4단계 이직 알림
- [x] 복구 폐지 → 다시 연결 승인·제명
- [x] UX 전면 개편 1차 (캡처 [[06 화면 점검]])
- [x] 5~8단계 끝 (시험 336개)
- [ ] APK 전달 → 사용자 설치·시험 → 화면 보며 피드백 반영
- [ ] 실제 기기 점검(주소록, 알림, 위치, QR, 같은 네트워크)
## 2026-10-26 사용자 추가 요구 ([[01 제품 개요]])
- [x] 5. 가계도 화면: 나 가운데, 위 윗세대(촌수), 옆 배우자, 아래 자식, 생일·알림, 친가/처가 족보, 자식 누르면 그 자식 기준으로 촌수 일괄 변경
- [x] 6. 학교 원형 화면: 나 가운데, 초·중·고·대 둘레, 가까운 사람(대화·통화 많은 순), 선배·후배(연차/연도), 반별/합쳐 보기 설정, 동문·동창 일괄 선택 → 긴급 단체 메시지
- [x] 7. 회사 원형 화면: 학교와 같은 방식(설정 가능), 프로젝트 전체·아래 직원 전체·윗사람 전체 메시지(직급 순서)
- [x] 8. 이직 이력: 회사별 근무 기간(몇 년~몇 년), 그 사람을 누르면 내 기록 속 주변 사람
## 현재 상태 (2026-10-26)
- podman Flutter(stable)에서 `flutter analyze` 0건, `flutter test` 313개 통과, 서버(Python) 18개 통과, check_modules.py 통과.
- `flutter build apk --debug` 성공(약 190MB, 개편 화면).
- 확인 안 된 곳: 실제 기기의 화면 실행과 플러그인(geolocator, call_log, url_launcher, flutter_local_notifications, timezone, sqflite_sqlcipher, nsd, mobile_scanner, sensors_plus, flutter_secure_storage, flutter_contacts).
- 빌드 경고(막지는 않음): `nsd_android`, `flutter_contacts` 가 Kotlin Gradle Plugin 을 직접 적용 → 지원 판이 나오면 올린다. geolocator_android 는 deprecated API Note.
- 한계: [[가이드/알려진 한계]]

View File

@ -0,0 +1,41 @@
# 구조와 모듈
- `lib/modules/<이름>/<이름>.dart` 가 모듈의 공개 입구(예외: platform_ui.dart, storage_sqlite.dart). 다른 모듈은 입구 파일만 import.
- 조립은 모듈 밖 `lib/main.dart`, `lib/composition/android_bootstrap.dart` 에서만. 암호·저장소·연결 방식을 바꾸려면 bootstrap 의 해당 줄만 바꾼다.
- 안드로이드 전용 플러그인은 platform_android 에서만 import (check_modules.py 의 PLATFORM_ONLY).
- 화면은 `AppScope.of(context).app`(FriendlyApp)의 서비스만 쓴다.
## 모듈과 의존 방향 (`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 | 대화 속 업무 표시(📌 업무·🚨 긴급 호출) 읽고 쓰기, 맡은 업무 목록(순수) | 없음 |
| circles | 원형 보기 계산: 가까움 순위·고리 나누기, 선배·후배(연도), 직급 순서, 원 위의 자리(순수) | 없음 |
| history | 사람별 회사 근무 기간(몇 년~몇 년), 기간 겹침 계산(순수) | 없음 |
| 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` 의 해당 줄만 바꾼다.
## 화면(ui) 주요 파일
- 탭: chat_list · relations · tasks · card_wallet · me (home_shell.dart)
- 처음 시작: onboarding_screen.dart, startup_gate.dart
- 대화방: chat_room_screen.dart + widgets/message_bubble.dart
- 그룹: group_detail_screen.dart + widgets/group_menu.dart, pin_dialog.dart
- 요청: requests_screen.dart (다시 연결 승인·제명)

View File

@ -0,0 +1,37 @@
# 빌드 · 시험 · 저장소
Mac Pro 2013. Flutter 는 직접 설치하지 않고 podman 컨테이너로 쓴다(프로젝트는 홈 폴더 안에 있어야 podman machine 이 공유). 먼저 `podman machine start`.
```
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 은 아이디를 창 맨 위 입력칸으로 묻는다.
## 처음 준비 (android/ 가 없을 때)
```
flutter create --platforms=android --org com.friendlymap --project-name friendly_map .
python3 tool/android_patch.py # 권한·수신기·desugaring·minSdk 23 (여러 번 돌려도 같음)
```
## 시험
```
tool/test_module.sh # 모듈 목록
tool/test_module.sh groups # 모듈 하나만
flutter test # 전체
python3 tool/check_modules.py # 줄 수·import 방향
cd server && python3 -m unittest test_mailbox test_verify # 보관함 서버
```
- 보관함 서버(선택): [[보관함 서버]]. 서버 주소를 설정 `mailbox_url` 에 넣으면 같은 네트워크 밖에서도 주고받는다.
## 저장소 규칙
- `build/`, `.dart_tool/`, `.claude/settings.local.json`, `wiki/.obsidian/workspace*.json` 은 올리지 않는다(.gitignore). `android/` 는 Gitea 에 올라가 있다.
- pubspec.lock 은 이 컨테이너(Flutter stable)에서 풀린 판 기준.
- 빌드 캐시 `-v fm-gradle:/root/.gradle -v fm-pub:/root/.pub-cache`. 컨테이너마다 SDK 34·35·CMake 를 다시 받아 15분 넘게 걸린다.
- 저장소: http://192.168.88.15:3180/diadmin/frindlymap (Gitea, remote origin).
- 화면 캡처: `flutter test test_ux/shots_test.dart --update-goldens` → `test_ux/shots/*.png` (한글 글꼴 `build/ux/kr.ttf` 필요: 맥의 `/Library/Fonts/Arial Unicode.ttf` 복사).

Some files were not shown because too many files have changed in this diff Show More