diff --git a/.gitignore b/.gitignore index 0d74121..3770acc 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,7 @@ app.*.map.json # 보관함 서버 실행 데이터 server/*.sqlite3 __pycache__/ + +# Obsidian 개인 화면 상태 +wiki/.obsidian/workspace*.json +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index 4bcdee8..d0a9b0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,18 @@ # Friendly Map — Claude 작업 지침 (인수인계) +## 0. 가장 먼저 (이 지시가 모든 것보다 최우선) +- **모든 가이드·문서·결정·진행 기록·화면 점검은 프로젝트의 `wiki/` Obsidian 보관함에 저장하고 관리한다.** 새 내용은 wiki 에 먼저 쓰고, 다른 문서(README 등)는 wiki 를 가리키기만 한다. +- 세션을 시작하면 `wiki/홈.md` 와 `wiki/00 최우선 규칙.md` 부터 읽는다. 일할 때마다 `wiki/진행 로그/<날짜>.md`, `wiki/02 결정 기록.md` 를 갱신한다. + 이 파일은 이전 대화(claude.ai 프로젝트 "Frindly Map")에서 정한 것을 새 세션에 넘기는 문서다. -세션을 시작하면 먼저 이 파일, README.md, tool/check_modules.py 를 읽고, 작업 전에 아래 원칙을 한 번 읊고 시작한다. +세션을 시작하면 먼저 이 파일, wiki/홈.md, tool/check_modules.py 를 읽고, 작업 전에 아래 원칙을 한 번 읊고 시작한다. + +## 최우선 규칙 (모든 규칙보다 위) +- 질문은 반드시 최종 화면이 담긴 설치 가능한 APK 를 먼저 건넨 뒤(사용자가 설치해 써 보며 답할 수 있게), 캡처도 함께 보여 주고, 그 화면을 기준으로 "이렇게 하면 / 저렇게 하면" 비교로만 묻는다. 화면 없는 질문 금지. +- 암호 비트 수·키 구조·함수 설계 같은 내부 기술 질문은 하지 않는다. 내가 정하고 결과에 적는다. 책임 회피성 질문 금지. +- 방법론: 화면 먼저 → 화면 보고 사용자가 답 → 그 답에 따라 내부 아키텍처를 바꾼다. +- 모든 기록(결정·진행 로그·로드맵·화면 점검)은 프로젝트의 `wiki/` Obsidian 보관함에 남긴다. 일할 때마다 갱신. +- 화면 캡처: `flutter test test_ux/shots_test.dart --update-goldens` → `test_ux/shots/*.png` (한글 글꼴 `build/ux/kr.ttf` 필요, 맥의 /Library/Fonts/Arial Unicode.ttf 복사). ## 사용자와 일하는 규칙 (1순위, 어기지 말 것) - 모든 답변은 한글. 고친 파일은 일부 발췌가 아니라 처음부터 끝까지 완본으로 보여 준다. @@ -19,7 +30,7 @@ Android 다크모드 인맥 앱(Flutter/Dart). 옛 "Family MAP" + 카카오톡 ## 이미 정한 기술 결정 (바꾸지 말 것) - 모든 데이터는 기기에 암호화 저장(안드로이드 SQLCipher). 전화번호는 식별자일 뿐 키 재료가 아니다. - 모든 기기가 서버 겸 클라이언트(P2P). 외부 DB(server/)는 암호화된 임시 우편함 + 기준 시각 제공뿐. -- 12단어 복구 코드, Shamir 조각 기본 5/3(설정으로 조절), 사람 식별은 전화번호 1순위·이름 2순위. +- (2026-10-26 사용자 결정으로 바뀜) 복구 코드·조각·보관 키 없음. 처음 시작은 이름·번호만, 키는 조용히 만든다. 폰을 잃으면 데이터는 끝. 새 폰에서 다시 시작하면 관계를 유지하는 사용자 각자가 승인(다시 연결) 또는 제명(그 번호 영구 차단). 탈퇴 후 재가입 없음. 사람 식별은 전화번호 1순위·이름 2순위. - 대화는 영구 보관, 1MB/기기 사용률 80% 초과 시 별도 저장(조절 가능). 시각은 1/100초, 충돌 시 먼저 쓴 변경이 앞선다. 고친 명함은 "다시 보내기"에서만 보낸다. 그룹 대화 키는 돌리지 않는다. - 가족 모임 데이터(가계도, 생일, 기일, 통화 주제)는 기기 안에만 저장하고 어디로도 보내지 않는다. 전화번호 없는 친척은 `local:` 번호의 기기 안 전용 사람이다. diff --git a/README.md b/README.md index b867cbc..29c3e16 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,12 @@ # Friendly Map -가족·친구·직장 관계를 한 앱에서. 새 친구가 "어디서 봤지?" 싶을 때 목적별 명함을 주고받아 관계를 유추한다. -안드로이드 우선, Flutter. 모든 기기가 서버 겸 클라이언트(P2P)이고, 외부 DB는 암호화된 임시 우편함일 뿐이다. +가족·친구·직장 관계를 채팅에서 시작해 이어 가는 안드로이드 앱 (Flutter). -## 원칙 (1순위) +**모든 가이드·문서·결정·진행 기록은 [`wiki/`](wiki/홈.md) (Obsidian 보관함)에서 관리한다.** -1. 구조가 기능보다 먼저다. 화면과 내부를 분리한다. -2. 모듈은 언제든 빼고 끼울 수 있다. 기능을 더하거나 빼도 다른 모듈을 고치지 않는다. -3. 파일은 300~400줄, 최대 700줄. -4. 모듈별로 시험한다. 시험이 끝난 모듈은 다시 만들지 않고 그 위에 쌓는다. -5. 한 번에 한 플랫폼. 지금은 안드로이드. 다른 플랫폼은 `platform_<이름>` 모듈만 새로 붙인다. +- 기능 안내: [wiki/가이드/기능 안내.md](wiki/가이드/기능%20안내.md) +- 빌드·시험·저장소: [wiki/05 빌드 · 시험 · 저장소.md](wiki/05%20빌드%20·%20시험%20·%20저장소.md) +- 결정 기록: [wiki/02 결정 기록.md](wiki/02%20결정%20기록.md) +- 보관함 서버: [wiki/가이드/보관함 서버.md](wiki/가이드/보관함%20서버.md) -## 모듈과 의존 방향 (`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)을 쓴다. - -## 이직 알림 -- 상대가 회사 명함을 고쳐 "다시 보내기" 하면, 이 기기가 알던 회사와 비교해 바뀌었으면 알림이 생긴다(채팅 목록 맨 위, 사람 화면, 그룹 관리). 예: "김민수님이 한빛전자에서 누리소프트로 옮겼어요", "지금은 다니지 않음"이면 "…를 떠났어요". -- "확인 · 옮기기": 이전 회사 이름이 경로에 든 회사 그룹(예: 회사 › 한빛전자 › 결제팀)에서는 이전 구성원으로 흐리게 남기고, "회사 › 새 회사" 그룹(없으면 만든다)에 넣는다. 연결 고리는 '직장 동료'가 새 회사로 바뀌고 '전 직장 동료'(이전 경로)가 남는다. -- "이전 회사에서 지우기": 흐리게 남기지 않고 뺀다. "무시": 알림만 지운다. 이전 구성원은 그룹 안에서 ✕ 로 지울 수 있다. -- 처음 받는 회사 명함, 앱을 새로 연 뒤의 첫 비교는 알림을 만들지 않는다(알던 회사로만 기억). 알던 회사·알림은 이 기기에만 저장한다. +Obsidian 에서 `wiki/` 폴더를 "보관함으로 열기" 하면 된다. diff --git a/lib/modules/app/app.dart b/lib/modules/app/app.dart index a3134b8..bfb1bb1 100644 --- a/lib/modules/app/app.dart +++ b/lib/modules/app/app.dart @@ -5,12 +5,14 @@ library; export 'src/app_deps.dart'; +export 'src/block_service.dart'; export 'src/call_service.dart'; export 'src/card_service.dart'; export 'src/career_service.dart'; export 'src/change_bus.dart'; export 'src/chat_service.dart'; export 'src/contact_import_service.dart'; +export 'src/display.dart'; export 'src/event_service.dart'; export 'src/facts_service.dart'; export 'src/fields.dart'; diff --git a/lib/modules/app/src/block_service.dart b/lib/modules/app/src/block_service.dart new file mode 100644 index 0000000..81495f6 --- /dev/null +++ b/lib/modules/app/src/block_service.dart @@ -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> blocked() async { + try { + return {...(jsonDecode(await _storage.settings.getText(_key) ?? '[]') as List).whereType()}; + } catch (_) { + return {}; + } + } + + Future isBlocked(String phone) async => (await blocked()).contains(phone); + + /// 받은 요청들 (다시 연결 요청이 위로). + Future> incoming() async { + final out = []; + 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 approve(String phone) => _friends.accept(phone); + + /// 영구 제명: 요청을 거절하고 이 번호를 막는다. + Future 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 unblock(String phone) async { + final set = await blocked()..remove(phone); + await _storage.settings.putText(_key, jsonEncode(set.toList()..sort()), _clock.now()); + _bus.notify(); + } +} diff --git a/lib/modules/app/src/display.dart b/lib/modules/app/src/display.dart new file mode 100644 index 0000000..dca4840 --- /dev/null +++ b/lib/modules/app/src/display.dart @@ -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', ' '), + }; diff --git a/lib/modules/app/src/friendly_app.dart b/lib/modules/app/src/friendly_app.dart index d780ce7..32887b3 100644 --- a/lib/modules/app/src/friendly_app.dart +++ b/lib/modules/app/src/friendly_app.dart @@ -4,6 +4,7 @@ import 'package:friendly_map/modules/sharing/sharing.dart'; import 'package:friendly_map/modules/sync/sync.dart'; import 'app_deps.dart'; +import 'block_service.dart'; import 'card_service.dart'; import 'career_service.dart'; import 'change_bus.dart'; @@ -64,6 +65,7 @@ class FriendlyApp { relate = RelateService(groups, kin, network, bus); work = WorkService(storage, clock, share, chats, groups, bus, deps.instant); career = CareerService(storage, clock, groups, relate, network, bus); + blocks = BlockService(storage, clock, friends, bus); } final AppDeps deps; @@ -89,6 +91,7 @@ class FriendlyApp { late final RelateService relate; late final WorkService work; late final CareerService career; + late final BlockService blocks; StreamSubscription? _events; final Set> _tasks = {}; @@ -100,7 +103,10 @@ class FriendlyApp { Future start() async { _events ??= sync.events.listen((e) { 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); unawaited(f.whenComplete(() => _tasks.remove(f))); } else { diff --git a/lib/modules/ui/src/screens/chat_list_screen.dart b/lib/modules/ui/src/screens/chat_list_screen.dart index bdea2a2..305398a 100644 --- a/lib/modules/ui/src/screens/chat_list_screen.dart +++ b/lib/modules/ui/src/screens/chat_list_screen.dart @@ -2,6 +2,7 @@ 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/storage/storage.dart'; +import 'package:friendly_map/modules/work/work.dart'; import '../app_scope.dart'; import '../theme.dart'; @@ -10,66 +11,134 @@ import '../widgets/career_panel.dart'; import '../widgets/common.dart'; import 'add_friend_screen.dart'; import 'chat_room_screen.dart'; -import 'tasks_screen.dart'; +import 'contact_import_screen.dart'; +import 'requests_screen.dart'; -/// 채팅 목록. -class ChatListScreen extends StatelessWidget { +class _Data { + const _Data(this.rooms, this.requests, this.reconnects); + final List rooms; + final int requests; + final int reconnects; +} + +/// 채팅 (처음 화면): 받은 요청·이직 알림 띠, 대화방 목록, 새 대화. +class ChatListScreen extends StatefulWidget { const ChatListScreen({super.key}); @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('채팅', style: TextStyle(fontWeight: FontWeight.w700)), - actions: [ - IconButton(tooltip: '맡은 업무', onPressed: () => pushPage(context, const TasksScreen()), icon: const Icon(Icons.checklist)), - IconButton( - tooltip: '새 친구', - onPressed: () => pushPage(context, const AddFriendScreen()), - 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>( - 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), - ], - ); - }, - ), - ); + State createState() => _ChatListScreenState(); +} + +class _ChatListScreenState extends State { + String? _query; // null 이면 찾기 칸을 닫음 + + Future<_Data> _load(FriendlyApp app) async { + final inc = await app.blocks.incoming(); + return _Data(await app.chats.summaries(), inc.length, inc.where((r) => r.reconnect).length); } Future _newChat(BuildContext context) async { 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 (people.isEmpty) { - showNote(context, '먼저 친구를 추가해 주세요.'); pushPage(context, const AddFriendScreen()); return; } - await showModalBottomSheet( - context: context, - showDragHandle: true, - isScrollControlled: true, - builder: (_) => _NewChatSheet(people), + await showModalBottomSheet(context: context, isScrollControlled: true, builder: (_) => _NewChatSheet(people)); + } + + @override + 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 { const _RoomTile(this.s); @@ -78,32 +147,34 @@ class _RoomTile extends StatelessWidget { @override Widget build(BuildContext context) { 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)), - child: Row(children: [ - Avatar(r.title, color: Pal.ofKind(r.contextKind)), - const SizedBox(width: 12), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Flexible(child: Text(r.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700))), - if (r.contextKind != CardKinds.personal) ...[ - 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)), + leading: r.kind == 'group' + ? CircleAvatar(radius: 22, backgroundColor: Pal.ofKind(r.contextKind).withValues(alpha: 0.2), child: Icon(Icons.groups, color: Pal.ofKind(r.contextKind))) + : Avatar(r.title, color: Pal.ofKind(r.contextKind)), + title: Row(children: [ + Flexible(child: Text(r.title, maxLines: 1, overflow: TextOverflow.ellipsis)), + if (r.contextKind != CardKinds.personal) ...[ + const SizedBox(width: 6), + KindChip(r.contextKind, CardKinds.label(r.contextKind)), + ], ]), + 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 { const _NewChatSheet(this.people); @@ -115,6 +186,7 @@ class _NewChatSheet extends StatefulWidget { class _NewChatSheetState extends State<_NewChatSheet> { String _context = CardKinds.personal; + String _q = ''; Future _open(Person p) async { final nav = Navigator.of(context); @@ -122,53 +194,41 @@ class _NewChatSheetState extends State<_NewChatSheet> { final room = await app.chats.openDirect(p.id, _context); if (!mounted) return; if (room == null) { - showNote(context, '${CardKinds.label(_context)} 대화는 ${CardKinds.label(_context)} 명함을 먼저 서로 공유해야 해요.'); + showNote(context, '${CardKinds.label(_context)} 대화는 ${p.name}님께 ${CardKinds.label(_context)} 명함을 먼저 보내야 열려요.'); return; } nav.pop(); - if (!nav.mounted) return; nav.push(MaterialPageRoute(builder: (_) => ChatRoomScreen(roomId: room.id))); } @override 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( child: SizedBox( - height: MediaQuery.of(context).size.height * 0.6, - child: Column(children: [ + height: MediaQuery.of(context).size.height * 0.75, + 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( - padding: EdgeInsets.fromLTRB(20, 0, 20, 8), - child: Align(alignment: Alignment.centerLeft, child: Text('어떤 대화인가요?', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700))), + padding: EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Text('회사·학교·가족 대화는 그 명함을 보낸 사람과만 열려요.', style: TextStyle(fontSize: 12, color: Pal.muted)), ), - SizedBox( - height: 48, - child: ListView( - 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), - ), - ), - ], - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField(decoration: const InputDecoration(hintText: '이름으로 찾기', prefixIcon: Icon(Icons.search), isDense: true), onChanged: (v) => setState(() => _q = v.trim())), ), - const Divider(), + const SizedBox(height: 4), Expanded( child: ListView(children: [ - for (final p in widget.people) - ListTile( - 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), - ), + for (final p in shown) + ListTile(leading: Avatar(p.name, size: 40), title: Text(p.name), subtitle: Text(displayPhone(p.phone)), onTap: () => _open(p)), ]), ), ]), diff --git a/lib/modules/ui/src/screens/chat_room_screen.dart b/lib/modules/ui/src/screens/chat_room_screen.dart index a588305..9b31ec3 100644 --- a/lib/modules/ui/src/screens/chat_room_screen.dart +++ b/lib/modules/ui/src/screens/chat_room_screen.dart @@ -10,21 +10,26 @@ import '../app_scope.dart'; import '../theme.dart'; import '../widgets/async_view.dart'; import '../widgets/common.dart'; +import '../widgets/message_bubble.dart'; import '../widgets/relate_sheet.dart'; import '../widgets/work_dialogs.dart'; import 'archive_screen.dart'; +import 'person_screen.dart'; import 'tasks_screen.dart'; -enum _Work { assign, urgent, tasks } +enum _Menu { relate, person, tasks, archive } 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 List messages; final ArchiveAdvice advice; + final Map names; // 사람 id → 이름 + final Set taskMessages; // 업무 목록에 들어간 대화 id + final int openTasks; } -/// 대화방. 맥락(개인·회사 …)을 이름표로 보여 주고, 별도 저장이 필요하면 알려 준다. +/// 대화방: 날짜 구분, 말풍선·업무 카드·긴급 호출 띠, + 버튼(업무 맡기기·긴급 호출), ⋮ 메뉴(관계 정하기·업무 보기). class ChatRoomScreen extends StatefulWidget { const ChatRoomScreen({super.key, required this.roomId}); @@ -43,6 +48,28 @@ class _ChatRoomScreenState extends State { super.dispose(); } + Future<_RoomData> _load(FriendlyApp app) async { + final room = await app.deps.storage.rooms.get(widget.roomId); + final names = {}; + 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().toList(); + return room.kind == 'direct' && ids.isNotEmpty ? ids.first : null; + } + Future _send() async { final text = _input.text; if (text.trim().isEmpty) return; @@ -50,52 +77,90 @@ class _ChatRoomScreenState extends State { await AppScope.of(context).app.chats.send(widget.roomId, text); } - Future _work(_Work w, ChatRoom room) async { + Future _plus(ChatRoom room) async { final app = AppScope.of(context).app; - switch (w) { - case _Work.assign: - final people = await app.work.roomPeople(room.id); - if (!mounted) return; - final r = await askAssign(context, people); - if (r == null) return; - await app.work.assign(room.id, r.title, assigneeId: r.assigneeId, due: r.due); - case _Work.urgent: - final text = await askUrgent(context); - if (text == null) return; - await app.work.urgent(room.id, text); - case _Work.tasks: - pushPage(context, TasksScreen(roomId: room.id, title: '${room.title} 업무')); + final pick = await showModalBottomSheet( + context: context, + builder: (c) => SafeArea( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + ListTile(leading: const Icon(Icons.task_alt, color: Pal.accent), title: const Text('업무 맡기기'), subtitle: const Text('할 일·담당·마감을 정해 보내요'), onTap: () => Navigator.pop(c, 'task')), + ListTile(leading: const Icon(Icons.campaign, color: Pal.urgent), title: const Text('긴급 호출'), subtitle: const Text('상대 폰에 바로 알림이 떠요'), onTap: () => Navigator.pop(c, 'urgent')), + const SizedBox(height: 8), + ]), + ), + ); + if (!mounted || pick == null) return; + if (pick == 'task') { + 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 _toTask(Message m) async { final app = AppScope.of(context).app; - final ok = await showModalBottomSheet( + final mineTask = await showModalBottomSheet( context: context, - showDragHandle: true, builder: (c) => SafeArea( child: Column(mainAxisSize: MainAxisSize.min, children: [ Padding( 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)), + const SizedBox(height: 8), ]), ), ); - if (ok == null) return; - final t = await app.work.fromMessage(m.id, assigneeId: ok ? Task.me : null); + if (mineTask == null) return; + final t = await app.work.fromMessage(m.id, assigneeId: mineTask ? Task.me : null); if (mounted) showNote(context, t == null ? '이미 업무로 정리한 대화예요' : '업무 목록에 넣었어요'); } - Future<_RoomData> _load(FriendlyApp app) async { - final rooms = await app.chats.summaries(); - ChatRoom? room; - for (final s in rooms) { - if (s.room.id == widget.roomId) room = s.room; + Future _menu(_Menu w, ChatRoom room) async { + final peer = _peer(room); + switch (w) { + case _Menu.relate: + 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 _items(_RoomData d, ChatRoom? room) { + final out = []; + 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 @@ -104,8 +169,10 @@ class _ChatRoomScreenState extends State { load: _load, builder: (context, d) { final room = d.room; + final items = _items(d, room).reversed.toList(); return Scaffold( appBar: AppBar( + titleSpacing: 0, title: Row(children: [ Flexible(child: Text(room?.title ?? '대화', overflow: TextOverflow.ellipsis)), if (room != null && room.contextKind != CardKinds.personal) ...[ @@ -114,24 +181,17 @@ class _ChatRoomScreenState extends State { ], ]), actions: [ - if (room != null && room.kind == 'direct') - IconButton( - tooltip: '관계 정하기', - icon: const Icon(Icons.hub_outlined), - onPressed: () { - final ids = (jsonDecode(room.membersJson) as List).whereType(); - if (ids.isNotEmpty) showRelateSheet(context, ids.first, room.title); - }, - ), + if (room != null && d.openTasks > 0) + TextButton.icon(onPressed: () => _menu(_Menu.tasks, room), icon: const Icon(Icons.task_alt, size: 18), label: Text('업무 ${d.openTasks}')), if (room != null) - PopupMenuButton<_Work>( - tooltip: '업무', - icon: const Icon(Icons.work_outline), - onSelected: (w) => _work(w, room), - itemBuilder: (_) => const [ - PopupMenuItem(value: _Work.assign, child: Text('📌 업무 맡기기')), - PopupMenuItem(value: _Work.urgent, child: Text('🚨 긴급 호출')), - PopupMenuItem(value: _Work.tasks, child: Text('이 방 업무 보기')), + PopupMenuButton<_Menu>( + tooltip: '메뉴', + onSelected: (w) => _menu(w, room), + itemBuilder: (_) => [ + if (_peer(room) != null) const PopupMenuItem(value: _Menu.person, child: ListTile(leading: Icon(Icons.person_outline), title: Text('프로필 보기'))), + if (_peer(room) != null) const PopupMenuItem(value: _Menu.relate, child: ListTile(leading: Icon(Icons.hub_outlined), title: Text('관계 정하기'))), + const PopupMenuItem(value: _Menu.tasks, child: ListTile(leading: Icon(Icons.task_alt), title: Text('이 방 업무'))), + const PopupMenuItem(value: _Menu.archive, child: ListTile(leading: Icon(Icons.inventory_2_outlined), title: Text('대화 보관'))), ], ), ], @@ -141,16 +201,11 @@ class _ChatRoomScreenState extends State { Panel( margin: const EdgeInsets.fromLTRB(16, 4, 16, 8), onTap: () => pushPage(context, ArchiveScreen(roomId: widget.roomId)), - child: Row(children: [ - const Icon(Icons.inventory_2_outlined, color: Pal.accent), - const SizedBox(width: 12), - Expanded( - child: Text( - d.advice.overText ? '이 방의 대화가 많아요. 오래된 대화를 별도 저장으로 나눌까요?' : '기기 저장 공간이 부족해져요. 대화를 별도 저장으로 나눌까요?', - style: const TextStyle(fontSize: 13), - ), - ), - const Icon(Icons.chevron_right, color: Pal.muted), + child: const Row(children: [ + Icon(Icons.inventory_2_outlined, color: Pal.accent), + SizedBox(width: 12), + Expanded(child: Text('대화가 많아요. 오래된 대화를 보관함으로 옮길까요?', style: TextStyle(fontSize: 13))), + Icon(Icons.chevron_right, color: Pal.muted), ]), ), Expanded( @@ -158,31 +213,32 @@ class _ChatRoomScreenState extends State { ? const EmptyNote('첫 메시지를 보내 보세요.') : ListView.builder( reverse: true, - padding: const EdgeInsets.all(12), - itemCount: d.messages.length, - itemBuilder: (_, i) { - final m = d.messages[d.messages.length - 1 - i]; - return GestureDetector(onLongPress: () => _toTask(m), child: _Bubble(m)); - }, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + itemCount: items.length, + itemBuilder: (_, i) => items[i], ), ), - SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 6, 8, 8), - child: Row(children: [ - Expanded( - child: TextField( - controller: _input, - minLines: 1, - maxLines: 4, - textInputAction: TextInputAction.newline, - decoration: const InputDecoration(hintText: '메시지', contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 10)), + Container( + color: Pal.surface, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 6, 8, 6), + child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + IconButton(tooltip: '업무·긴급 호출', onPressed: room == null ? null : () => _plus(room), icon: const Icon(Icons.add_circle_outline)), + Expanded( + child: TextField( + controller: _input, + minLines: 1, + maxLines: 5, + textInputAction: TextInputAction.newline, + decoration: const InputDecoration(hintText: '메시지 보내기', isDense: true, fillColor: Pal.bg), + ), ), - ), - const SizedBox(width: 6), - IconButton.filled(onPressed: _send, tooltip: '보내기', icon: const Icon(Icons.send)), - ]), + const SizedBox(width: 6), + IconButton.filled(onPressed: _send, tooltip: '보내기', icon: const Icon(Icons.arrow_upward)), + ]), + ), ), ), ]), @@ -191,36 +247,3 @@ class _ChatRoomScreenState extends State { ); } } - -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)), - ]), - ), - ); - } -} diff --git a/lib/modules/ui/src/screens/cluster_screen.dart b/lib/modules/ui/src/screens/cluster_screen.dart deleted file mode 100644 index de65097..0000000 --- a/lib/modules/ui/src/screens/cluster_screen.dart +++ /dev/null @@ -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>( - 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)), - ), - ], - ), - ), - ), - ]); - }, - ), - ); - } -} diff --git a/lib/modules/ui/src/screens/contact_import_screen.dart b/lib/modules/ui/src/screens/contact_import_screen.dart index 2abd5e7..f076f69 100644 --- a/lib/modules/ui/src/screens/contact_import_screen.dart +++ b/lib/modules/ui/src/screens/contact_import_screen.dart @@ -22,7 +22,7 @@ class _ContactImportScreenState extends State { bool _denied = false; String _query = ''; String? _label; - bool _hideKnown = true; + bool _hideKnown = false; final Set _picked = {}; // 주소록 번호표 bool _busy = false; @@ -120,33 +120,23 @@ class _ContactImportScreenState extends State { onChanged: (v) => setState(() => _query = v), ), ), - SizedBox( - height: 44, - child: ListView(scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: 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: const EdgeInsets.symmetric(horizontal: 16), + child: Wrap(spacing: 6, runSpacing: 6, children: [ + ChoiceChip(label: const Text('전체'), selected: _label == null, onSelected: (_) => setState(() => _label = null)), + for (final l in labels) ChoiceChip(label: Text(l), selected: _label == l, onSelected: (_) => setState(() => _label = _label == l ? null : l)), + FilterChip(label: const Text('새 사람만'), selected: _hideKnown, onSelected: (v) => setState(() => _hideKnown = v)), ]), ), Padding( padding: const EdgeInsets.fromLTRB(20, 4, 8, 0), 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( onPressed: pickable.isEmpty ? null : () => setState(() => allPicked ? _picked.removeAll(pickable) : _picked.addAll(pickable)), - child: Text(allPicked ? '보이는 사람 모두 풀기' : '보이는 사람 모두 고르기'), + child: Text(allPicked ? '모두 풀기' : '모두 고르기'), ), ]), ), @@ -162,8 +152,8 @@ class _ContactImportScreenState extends State { : c.phone == null ? '쓸 수 있는 번호가 없어요' : c.known != null - ? '${c.phone} · 이미 인맥에 있어요' - : c.phone!; + ? '${displayPhone(c.phone!)} · 이미 인맥에 있어요' + : displayPhone(c.phone!); return CheckboxListTile( value: _picked.contains(c.contact.id), onChanged: c.importable ? (v) => setState(() => v == true ? _picked.add(c.contact.id) : _picked.remove(c.contact.id)) : null, diff --git a/lib/modules/ui/src/screens/discover_screen.dart b/lib/modules/ui/src/screens/discover_screen.dart deleted file mode 100644 index 6d36ec5..0000000 --- a/lib/modules/ui/src/screens/discover_screen.dart +++ /dev/null @@ -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 requests; - final List 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 _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('확정'), - ), - ]), - ), - ]), - ), - ); - } -} diff --git a/lib/modules/ui/src/screens/family_screen.dart b/lib/modules/ui/src/screens/family_screen.dart deleted file mode 100644 index fd40a34..0000000 --- a/lib/modules/ui/src/screens/family_screen.dart +++ /dev/null @@ -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 _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( - 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>( - load: (app) => app.network.family(), - builder: (context, members) { - if (members.isEmpty) { - return const EmptyNote('가족 명함을 받거나, 친구를 가족으로 추가하면\n여기에 가계도가 만들어져요.'); - } - final rows = [[], [], []]; - 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 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)), - ]), - ), - ); - } -} diff --git a/lib/modules/ui/src/screens/group_detail_screen.dart b/lib/modules/ui/src/screens/group_detail_screen.dart index 672c0ab..95818be 100644 --- a/lib/modules/ui/src/screens/group_detail_screen.dart +++ b/lib/modules/ui/src/screens/group_detail_screen.dart @@ -81,8 +81,10 @@ class GroupDetailScreen extends StatelessWidget { title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Flexible(child: Text(g.name, overflow: TextOverflow.ellipsis)), - const SizedBox(width: 8), - KindChip(g.kind, CardKinds.label(g.kind)), + if (g.name != 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)), ]), @@ -144,7 +146,7 @@ class GroupDetailScreen extends StatelessWidget { ListTile( leading: Avatar(p.name, size: 40, color: Pal.ofKind(g.kind)), 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)), trailing: IconButton( tooltip: '그룹에서 빼기', diff --git a/lib/modules/ui/src/screens/groups_screen.dart b/lib/modules/ui/src/screens/groups_screen.dart deleted file mode 100644 index 42814e5..0000000 --- a/lib/modules/ui/src/screens/groups_screen.dart +++ /dev/null @@ -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 groups; // 펼친 전체 (보이는 것만) - final int relock; -} - -/// 설정 > 그룹 관리: 맨 위 그룹 목록. 누르면 그 그룹 안(하위 그룹·구성원)으로 들어간다. -class GroupsScreen extends StatelessWidget { - const GroupsScreen({super.key}); - - Future _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)), - ]), - ), - ]), - ), - ); - } -} diff --git a/lib/modules/ui/src/screens/held_shares_screen.dart b/lib/modules/ui/src/screens/held_shares_screen.dart deleted file mode 100644 index 24f5aa2..0000000 --- a/lib/modules/ui/src/screens/held_shares_screen.dart +++ /dev/null @@ -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 _show(BuildContext context, HeldShare h) { - return showDialog( - 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>( - 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), - ), - ]); - }, - ), - ); - } -} diff --git a/lib/modules/ui/src/screens/home_edit_screen.dart b/lib/modules/ui/src/screens/home_edit_screen.dart deleted file mode 100644 index 5a13f67..0000000 --- a/lib/modules/ui/src/screens/home_edit_screen.dart +++ /dev/null @@ -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 _rename(BuildContext context, HomeItem item) async { - final c = TextEditingController(text: item.title); - final name = await showDialog( - 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>( - 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), - ]), - ), - ], - ); - }, - ), - ); - } -} diff --git a/lib/modules/ui/src/screens/home_shell.dart b/lib/modules/ui/src/screens/home_shell.dart index b519660..20cc7be 100644 --- a/lib/modules/ui/src/screens/home_shell.dart +++ b/lib/modules/ui/src/screens/home_shell.dart @@ -1,13 +1,23 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../app_scope.dart'; import 'card_wallet_screen.dart'; import 'chat_list_screen.dart'; -import 'discover_screen.dart'; -import 'network_home_screen.dart'; -import 'privacy_screen.dart'; +import 'me_screen.dart'; +import 'relations_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 { const HomeShell({super.key}); @@ -17,6 +27,23 @@ class HomeShell extends StatefulWidget { class _HomeShellState extends State with WidgetsBindingObserver { int _index = 0; + _Badges _b = const _Badges(0, 0, 0); + StreamSubscription? _sub; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _sub ??= AppScope.of(context).app.changes.listen((_) => _count()); + _count(); + } + + Future _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 void initState() { @@ -27,10 +54,10 @@ class _HomeShellState extends State with WidgetsBindingObserver { @override void dispose() { WidgetsBinding.instance.removeObserver(this); + _sub?.cancel(); super.dispose(); } - /// 앱을 내리면 연 그룹을 모두 다시 잠근다. @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) { @@ -38,28 +65,28 @@ class _HomeShellState extends State with WidgetsBindingObserver { } } - static const _tabs = <(IconData, String)>[ - (Icons.home_outlined, '홈'), - (Icons.chat_bubble_outline, '채팅'), - (Icons.travel_explore, '찾기'), - (Icons.badge_outlined, '명함'), - (Icons.person_outline, '내 정보'), - ]; + static Widget _icon(IconData i, int n) => n == 0 ? Icon(i) : Badge(label: Text('$n'), child: Icon(i)); @override Widget build(BuildContext context) { return Scaffold( body: IndexedStack(index: _index, children: const [ - NetworkHomeScreen(), ChatListScreen(), - DiscoverScreen(), + RelationsScreen(), + TasksScreen(asTab: true), CardWalletScreen(), - PrivacyScreen(), + MeScreen(), ]), bottomNavigationBar: NavigationBar( selectedIndex: _index, 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: '나'), + ], ), ); } diff --git a/lib/modules/ui/src/screens/me_screen.dart b/lib/modules/ui/src/screens/me_screen.dart new file mode 100644 index 0000000..4687b69 --- /dev/null +++ b/lib/modules/ui/src/screens/me_screen.dart @@ -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 _rename(BuildContext context, String current) async { + final app = AppScope.of(context).app; + final c = TextEditingController(text: current); + final name = await showDialog( + 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 _exchange(BuildContext context, String current) async { + final app = AppScope.of(context).app; + final v = await showModalBottomSheet( + 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 _relock(BuildContext context, int current) async { + final app = AppScope.of(context).app; + final v = await showModalBottomSheet( + 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>( + 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('제명 풀기')), + ), + ]), + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/network_home_screen.dart b/lib/modules/ui/src/screens/network_home_screen.dart deleted file mode 100644 index 9b1564a..0000000 --- a/lib/modules/ui/src/screens/network_home_screen.dart +++ /dev/null @@ -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 _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>( - 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)), - ]), - ), - ), - ], - ); - }, - ), - ); - } -} diff --git a/lib/modules/ui/src/screens/onboarding_screen.dart b/lib/modules/ui/src/screens/onboarding_screen.dart new file mode 100644 index 0000000..f0e204f --- /dev/null +++ b/lib/modules/ui/src/screens/onboarding_screen.dart @@ -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 Function(String phone, String name) onStart; + + @override + State createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends State { + 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 _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)), + ), + ), + ]), + ), + ), + ), + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/people_screen.dart b/lib/modules/ui/src/screens/people_screen.dart index 584647f..1e85c09 100644 --- a/lib/modules/ui/src/screens/people_screen.dart +++ b/lib/modules/ui/src/screens/people_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; import 'package:friendly_map/modules/storage/storage.dart'; import '../theme.dart'; @@ -48,7 +49,7 @@ class PeopleScreen extends StatelessWidget { ListTile( leading: Avatar(p.name, size: 40), 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), onTap: () => pushPage(context, PersonScreen(personId: p.id)), ), diff --git a/lib/modules/ui/src/screens/person_screen.dart b/lib/modules/ui/src/screens/person_screen.dart index eb355e8..1316f67 100644 --- a/lib/modules/ui/src/screens/person_screen.dart +++ b/lib/modules/ui/src/screens/person_screen.dart @@ -63,84 +63,51 @@ class PersonScreen extends StatelessWidget { final p = d.person; if (p == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('찾을 수 없는 사람이에요.')); return Scaffold( - appBar: AppBar(title: Text(p.name), 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), - ), + appBar: AppBar(title: const Text('프로필'), actions: [ + IconButton(tooltip: '정보 고치기', onPressed: () => pushPage(context, PersonFactsScreen(personId: p.id)), icon: const Icon(Icons.edit_outlined)), ]), body: ListView(padding: const EdgeInsets.only(bottom: 32), children: [ if (d.info.markDeceased) DeceasedBanner(d.info.facts), CareerNoticesPanel(personId: p.id), Padding( - padding: const EdgeInsets.all(16), - child: Row(children: [ - Avatar(p.name, size: 56), - const SizedBox(width: 14), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(p.name, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), - const SizedBox(height: 2), - 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)), - ]), - ), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column(children: [ + Avatar(p.name, size: 84), + const SizedBox(height: 12), + Text(p.name, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800)), + if (d.info.reachable) ...[ + const SizedBox(height: 4), + Text(displayPhone(p.phone), style: const TextStyle(fontSize: 15, color: Pal.muted)), + ], + 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: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 12), child: Row(children: [ - Expanded( - child: FilledButton.icon( - onPressed: () => _chat(context, CardKinds.personal), - icon: const Icon(Icons.chat_bubble_outline), - label: const Text('대화'), - ), - ), - const SizedBox(width: 10), - Expanded( - child: OutlinedButton.icon( - onPressed: () => pushPage(context, InferScreen(personId: p.id)), - icon: const Icon(Icons.travel_explore), - label: const Text('어디서 봤지?'), - ), - ), + _Action(Icons.chat_bubble_outline, '대화', () => _chat(context, CardKinds.personal)), + if (d.info.reachable && d.info.canCall) _Action(Icons.call_outlined, '전화', () => _call(context, false)), + if (d.info.reachable && d.info.canCall) _Action(Icons.sms_outlined, '문자', () => _call(context, true)), + _Action(Icons.hub_outlined, '관계', () => showRelateSheet(context, p.id, p.name)), + _Action(Icons.travel_explore, '어디서 봤지', () => pushPage(context, InferScreen(personId: p.id))), ]), ), - if (d.info.reachable && d.info.canCall) - 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), + const SizedBox(height: 16), PlacesPanel(personId: p.id, name: p.name), - RelationPanel(d.info.relations, onEdit: () => pushPage(context, PersonFactsScreen(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), - if (d.links.isNotEmpty) ...[ + if (d.links.any((l) => !_groupWords.contains(l.relation))) ...[ const SectionHeader('연결 고리'), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), 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}')), ]), ), @@ -148,14 +115,6 @@ class PersonScreen extends StatelessWidget { const SectionHeader('받은 명함'), if (d.cards.isEmpty) const EmptyNote('받은 명함이 아직 없어요.'), for (final c in d.cards) _ReceivedCardTile(c, d.labels), - const SectionHeader('이 사람과 대화하기'), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Wrap(spacing: 8, runSpacing: 8, children: [ - for (final k in CardKinds.all.where((k) => k != CardKinds.personal)) - ActionChip(label: Text('${CardKinds.label(k)} 대화'), onPressed: () => _chat(context, k)), - ]), - ), ]), ); }, @@ -163,6 +122,37 @@ class PersonScreen extends StatelessWidget { } } +/// 그룹에 넣을 때 자동으로 생기는 연결 고리 (그룹 칸에 이미 보이므로 따로 보이지 않는다). +const _groupWords = {'가족', '동문', '직장 동료', '같은 모임'}; + +class _Action extends StatelessWidget { + const _Action(this.icon, this.label, this.onTap); + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column(children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration(color: Pal.surface, shape: BoxShape.circle, border: Border.all(color: Pal.line)), + child: Icon(icon, color: Pal.accent), + ), + const SizedBox(height: 6), + Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), + ]), + ), + ), + ); +} + class _ReceivedCardTile extends StatelessWidget { const _ReceivedCardTile(this.c, this.labels); @@ -181,7 +171,7 @@ class _ReceivedCardTile extends StatelessWidget { Row(children: [ KindChip(c.kind, CardKinds.label(c.kind)), 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), if (lines.isEmpty) const Text('비어 있는 명함', style: TextStyle(color: Pal.muted)), diff --git a/lib/modules/ui/src/screens/privacy_screen.dart b/lib/modules/ui/src/screens/privacy_screen.dart deleted file mode 100644 index 9adb40d..0000000 --- a/lib/modules/ui/src/screens/privacy_screen.dart +++ /dev/null @@ -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 _rename(BuildContext context, String current) async { - final app = AppScope.of(context).app; - final c = TextEditingController(text: current); - final name = await showDialog( - 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( - groupValue: d.settings.exchangeMode, - onChanged: (v) { - if (v != null) app.settings.update(exchangeMode: v); - }, - child: const Column(children: [ - RadioListTile( - value: 'request', - activeColor: Pal.accent, - title: Text('요청 후 상대 수락'), - subtitle: Text('요청이 오면 내가 확인하고 받아요', style: TextStyle(fontSize: 11, color: Pal.muted)), - ), - RadioListTile( - 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), - ), - ), - ]), - ), - ); - } -} diff --git a/lib/modules/ui/src/screens/profile_setup_screen.dart b/lib/modules/ui/src/screens/profile_setup_screen.dart deleted file mode 100644 index dd0f409..0000000 --- a/lib/modules/ui/src/screens/profile_setup_screen.dart +++ /dev/null @@ -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 createState() => _ProfileSetupScreenState(); -} - -class _ProfileSetupScreenState extends State { - final _phone = TextEditingController(); - final _name = TextEditingController(); - String? _error; - bool _busy = false; - - @override - void dispose() { - _phone.dispose(); - _name.dispose(); - super.dispose(); - } - - Future _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('저장하고 시작')), - ), - ], - ), - ); - } -} diff --git a/lib/modules/ui/src/screens/qr_screens.dart b/lib/modules/ui/src/screens/qr_screens.dart index 960191f..8bea190 100644 --- a/lib/modules/ui/src/screens/qr_screens.dart +++ b/lib/modules/ui/src/screens/qr_screens.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; import 'package:friendly_map/modules/discovery/discovery.dart'; import 'package:qr_flutter/qr_flutter.dart' show QrImageView; @@ -29,7 +30,7 @@ class MyQrScreen extends StatelessWidget { const SizedBox(height: 20), Text(q.name, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), 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 Padding( padding: EdgeInsets.symmetric(horizontal: 32), diff --git a/lib/modules/ui/src/screens/recovery_entrust_screen.dart b/lib/modules/ui/src/screens/recovery_entrust_screen.dart deleted file mode 100644 index fc14093..0000000 --- a/lib/modules/ui/src/screens/recovery_entrust_screen.dart +++ /dev/null @@ -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 people; - final RecoverySplit? split; - final AppSettings settings; -} - -/// 복구 조각을 친구에게 맡긴다. 친구마다 조각 한 장, 설정한 수만큼의 친구에게 나눠 맡긴다. -class RecoveryEntrustScreen extends StatefulWidget { - const RecoveryEntrustScreen({super.key}); - - @override - State createState() => _RecoveryEntrustScreenState(); -} - -class _RecoveryEntrustScreenState extends State { - bool _busy = false; - - Future<_Data> _load(FriendlyApp app) async => - _Data(await app.friends.people(), await app.recovery.split(), await app.settings.load()); - - Future _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 _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('맡기기')), - ), - ]); - }, - ), - ); - } -} diff --git a/lib/modules/ui/src/screens/relations_screen.dart b/lib/modules/ui/src/screens/relations_screen.dart new file mode 100644 index 0000000..7c4ebd6 --- /dev/null +++ b/lib/modules/ui/src/screens/relations_screen.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import '../widgets/group_dialogs.dart'; +import '../widgets/group_menu.dart'; +import 'add_friend_screen.dart'; +import 'contact_import_screen.dart'; +import 'gathering_screen.dart'; +import 'group_detail_screen.dart'; +import 'people_screen.dart'; +import 'person_screen.dart'; +import 'requests_screen.dart'; +import 'shake_screen.dart'; + +class _Data { + const _Data(this.groups, this.people, this.hidden, this.requests, this.suggestions); + final List groups; + final List people; // 보이는 사람 (닫힌 잠긴 그룹 사람 제외) + final int hidden; + final int requests; + final List suggestions; +} + +/// 관계: 사람 찾기, 빠른 추가, 그룹(가족·학교·회사·모임과 하위 그룹), 이런 사이 아닐까요. +class RelationsScreen extends StatefulWidget { + const RelationsScreen({super.key}); + + @override + State createState() => _RelationsScreenState(); +} + +class _RelationsScreenState extends State { + 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 _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 _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 _home(BuildContext context, _Data d) { + final roots = [for (final v in d.groups) if (v.depth == 1) v]; + int kids(GroupView v) => d.groups.where((c) => c.group.parent == v.group.id).length; + return [ + if (d.requests > 0) + Panel( + onTap: () => pushPage(context, const RequestsScreen()), + child: Row(children: [ + const Icon(Icons.mark_email_unread_outlined, color: Pal.accent), + const SizedBox(width: 12), + Expanded(child: Text('받은 요청 ${d.requests}건', style: const TextStyle(fontWeight: FontWeight.w700))), + const Icon(Icons.chevron_right, color: Pal.muted), + ]), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), + child: Row(children: [ + _Quick(Icons.people_alt_outlined, '모든 사람', '${d.people.length}명', () => pushPage(context, const PeopleScreen())), + _Quick(Icons.account_tree_outlined, '가계도', '촌수', () => pushPage(context, const GatheringScreen())), + _Quick(Icons.contact_phone_outlined, '주소록', '불러오기', () => pushPage(context, const ContactImportScreen())), + _Quick(Icons.vibration, '흔들기', '명함 교환', () => pushPage(context, const ShakeScreen())), + ]), + ), + SectionHeader('그룹', trailing: TextButton.icon(onPressed: _addGroup, icon: const Icon(Icons.add, size: 18), label: const Text('새 그룹'))), + for (final v in roots) GroupTile(v, children: kids(v), onOpen: () => pushPage(context, GroupDetailScreen(groupId: v.group.id))), + if (d.hidden > 0) + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 0), + child: Row(children: [ + const Icon(Icons.lock, size: 14, color: Pal.muted), + const SizedBox(width: 6), + Text('잠긴 그룹의 ${d.hidden}명은 숨겨져 있어요', style: const TextStyle(fontSize: 12, color: Pal.muted)), + ]), + ), + if (d.suggestions.isNotEmpty) ...[ + const SectionHeader('이런 사이 아닐까요?'), + for (final s in d.suggestions.take(5)) + ListTile( + leading: Avatar(s.person.name, size: 40), + title: Text(s.person.name), + subtitle: Text('${s.guess.relation} · ${s.guess.detail}', maxLines: 1, overflow: TextOverflow.ellipsis), + onTap: () => pushPage(context, PersonScreen(personId: s.person.id)), + trailing: TextButton( + onPressed: () => AppScope.of(context).app.network.addLink(s.person.id, s.guess.relation, s.guess.detail), + child: const Text('맞아요'), + ), + ), + ], + ]; + } +} + +class _Quick extends StatelessWidget { + const _Quick(this.icon, this.title, this.sub, this.onTap); + final IconData icon; + final String title; + final String sub; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Material( + color: Pal.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column(children: [ + Icon(icon, color: Pal.accent), + const SizedBox(height: 6), + Text(title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + Text(sub, style: const TextStyle(fontSize: 11, color: Pal.muted)), + ]), + ), + ), + ), + ), + ); +} diff --git a/lib/modules/ui/src/screens/requests_screen.dart b/lib/modules/ui/src/screens/requests_screen.dart new file mode 100644 index 0000000..f2ab08e --- /dev/null +++ b/lib/modules/ui/src/screens/requests_screen.dart @@ -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 _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>( + 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 ? '승인' : '수락'), + ), + ), + ]), + ]), + ), + ]); + }, + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/settings_screen.dart b/lib/modules/ui/src/screens/settings_screen.dart index bcf093b..fb9e0fd 100644 --- a/lib/modules/ui/src/screens/settings_screen.dart +++ b/lib/modules/ui/src/screens/settings_screen.dart @@ -6,44 +6,26 @@ import '../theme.dart'; import '../widgets/async_view.dart'; import '../widgets/common.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; -/// 저장·보안 설정: 별도 저장 기준, 기기 저장 공간 기준, 그룹·주소록, 복구 코드와 조각 수. +/// 알림 · 저장 공간: 생일·기일 알림, 대화 보관 기준. class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); - Future _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( - builder: (c) => RecoveryCodeScreen(phrase: phrase, readOnly: true, onDone: () => Navigator.of(c).pop()), - )); - } - @override Widget build(BuildContext context) { final svc = AppScope.of(context).app.settings; return Scaffold( - appBar: AppBar(title: const Text('저장 · 보안 설정')), + appBar: AppBar(title: const Text('알림 · 저장 공간')), body: AsyncView( load: (app) => app.settings.load(), builder: (context, s) => ListView(padding: const EdgeInsets.only(bottom: 32), children: [ Panel( 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 Text('지우지 않고 영구 보관하되, 아래 기준을 넘으면 별도 저장소로 나눠 담아요.', style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)), + const Text('대화는 지우지 않아요. 아래 기준을 넘으면 오래된 대화를 별도 보관함으로 옮겨 폰을 가볍게 해요.', style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)), _Stepper( title: '대화 텍스트 크기', hint: '대화방 하나가 이 크기를 넘으면', @@ -65,50 +47,6 @@ class SettingsScreen extends StatelessWidget { ]), ), 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('내가 맡은 조각'))), - ]), - ]), - ), ]), ), ); diff --git a/lib/modules/ui/src/screens/shake_pick_screen.dart b/lib/modules/ui/src/screens/shake_pick_screen.dart index 41b5102..20aaf0e 100644 --- a/lib/modules/ui/src/screens/shake_pick_screen.dart +++ b/lib/modules/ui/src/screens/shake_pick_screen.dart @@ -62,7 +62,7 @@ class _ShakePickScreenState extends State { value: n.phone, activeColor: Pal.accent, 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)), ), ]), ), diff --git a/lib/modules/ui/src/screens/share_screen.dart b/lib/modules/ui/src/screens/share_screen.dart index 0473145..4b502d0 100644 --- a/lib/modules/ui/src/screens/share_screen.dart +++ b/lib/modules/ui/src/screens/share_screen.dart @@ -65,7 +65,7 @@ class _ShareScreenState extends State { onChanged: (v) => setState(() => _personId = v), child: Column(children: [ for (final p in d.people) - RadioListTile(value: p.id, activeColor: Pal.accent, title: Text(p.name), subtitle: Text(p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted))), + RadioListTile(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('보낼 명함'), diff --git a/lib/modules/ui/src/screens/tasks_screen.dart b/lib/modules/ui/src/screens/tasks_screen.dart index 2cf9633..a8be69f 100644 --- a/lib/modules/ui/src/screens/tasks_screen.dart +++ b/lib/modules/ui/src/screens/tasks_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; import 'package:friendly_map/modules/work/work.dart'; import '../app_scope.dart'; @@ -9,25 +10,23 @@ import 'chat_room_screen.dart'; /// 맡은 업무: 내 업무 / 전체, 끝난 것 보기. [roomId] 를 주면 그 방의 업무만. 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 title; + /// 아래 메뉴의 한 칸으로 쓸 때 (처음 보기를 "내 업무"로). + final bool asTab; + @override State createState() => _TasksScreenState(); } class _TasksScreenState extends State { - bool _onlyMine = false; + late bool _onlyMine = widget.asTab; bool _done = false; - String _due(DateTime 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'; - } + String _due(DateTime d) => '마감 ${d.month}/${d.day} · ${dueText(d)}'; @override Widget build(BuildContext context) { @@ -50,7 +49,7 @@ class _TasksScreenState extends State { key: ValueKey('$_onlyMine$_done'), load: (a) => a.work.tasks(roomId: widget.roomId, onlyMine: _onlyMine, includeDone: _done), 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: [ for (final t in list) Dismissible( diff --git a/lib/modules/ui/src/screens/welcome_screen.dart b/lib/modules/ui/src/screens/welcome_screen.dart deleted file mode 100644 index 9e6651d..0000000 --- a/lib/modules/ui/src/screens/welcome_screen.dart +++ /dev/null @@ -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 Function() onCreate; - final Future Function(String phrase) onRestore; - - @override - State createState() => _WelcomeScreenState(); -} - -class _WelcomeScreenState extends State { - final _phrase = TextEditingController(); - bool _restoring = false; - bool _busy = false; - String? _error; - - @override - void dispose() { - _phrase.dispose(); - super.dispose(); - } - - Future _create() async { - setState(() => _busy = true); - await widget.onCreate(); - if (mounted) setState(() => _busy = false); - } - - Future _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 createState() => _RecoveryCodeScreenState(); -} - -class _RecoveryCodeScreenState extends State { - 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 ? '닫기' : '시작하기'), - ), - ), - ], - ), - ); - } -} diff --git a/lib/modules/ui/src/startup_gate.dart b/lib/modules/ui/src/startup_gate.dart index 34ac747..8cff784 100644 --- a/lib/modules/ui/src/startup_gate.dart +++ b/lib/modules/ui/src/startup_gate.dart @@ -3,11 +3,10 @@ import 'package:friendly_map/modules/app/app.dart'; import 'bootstrap.dart'; import 'screens/home_shell.dart'; -import 'screens/profile_setup_screen.dart'; -import 'screens/welcome_screen.dart'; +import 'screens/onboarding_screen.dart'; -/// 앱이 열리기까지의 순서: 키 확인 → (처음이면) 새로 시작/복구 → 앱 열기 → 내 정보 정하기 → 홈. -/// 앱 내부가 열리면 [onOpened] 로 알려서 위쪽이 AppScope 를 씌우게 한다. +/// 앱이 열리기까지: 내 정보가 있으면 바로 채팅으로. 처음이면 이름·번호만 받고 키는 조용히 만든다. +/// 복구 코드나 보관 키는 없다. 앱 내부가 열리면 [onOpened] 로 알려서 위쪽이 AppScope 를 씌우게 한다. class StartupGate extends StatefulWidget { const StartupGate({super.key, required this.bootstrap, required this.onOpened}); @@ -18,11 +17,10 @@ class StartupGate extends StatefulWidget { State createState() => _StartupGateState(); } -enum _Stage { checking, welcome, code, profile, home } +enum _Stage { checking, onboarding, home } class _StartupGateState extends State { _Stage _stage = _Stage.checking; - String _phrase = ''; FriendlyApp? _app; @override @@ -33,56 +31,40 @@ class _StartupGateState extends State { Future _begin() async { if (await widget.bootstrap.hasKeys()) { - await _open(); - } else if (mounted) { - setState(() => _stage = _Stage.welcome); + final app = await _open(); + if (await app.profile.isSetUp()) { + await app.start(); + if (mounted) setState(() => _stage = _Stage.home); + return; + } } + if (mounted) setState(() => _stage = _Stage.onboarding); } - Future _open() async { - final app = await widget.bootstrap.openApp(); - if (!mounted) return; - _app = app; - widget.onOpened(app); - final ready = await app.profile.isSetUp(); - if (!mounted) return; - if (ready) await app.start(); - if (mounted) setState(() => _stage = ready ? _Stage.home : _Stage.profile); + Future _open() async { + final app = _app ?? await widget.bootstrap.openApp(); + if (_app == null) { + _app = app; + widget.onOpened(app); + } + return app; } - Future _profileDone() async { - await _app?.start(); + /// 이름·번호를 받아 시작한다. 문제가 있으면 안내 글. + Future _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); + return null; } @override - Widget build(BuildContext context) { - switch (_stage) { - case _Stage.checking: - return const Scaffold(body: Center(child: CircularProgressIndicator())); - 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(); - } - } + Widget build(BuildContext context) => switch (_stage) { + _Stage.checking => const Scaffold(body: Center(child: CircularProgressIndicator())), + _Stage.onboarding => OnboardingScreen(onStart: _start), + _Stage.home => const HomeShell(), + }; } diff --git a/lib/modules/ui/src/theme.dart b/lib/modules/ui/src/theme.dart index 5ecc436..64b4a4d 100644 --- a/lib/modules/ui/src/theme.dart +++ b/lib/modules/ui/src/theme.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; class Pal { static const bg = Color(0xFF121417); static const surface = Color(0xFF1B1E23); + static const raised = Color(0xFF262A31); static const line = Color(0xFF2E333B); static const text = Color(0xFFECEEF1); static const muted = Color(0xFFA3AAB5); @@ -12,6 +13,8 @@ class Pal { static const school = Color(0xFF5CC8BA); static const group = Color(0xFFF28B82); static const danger = Color(0xFFF28B82); + static const urgent = Color(0xFFE5484D); + static const ok = Color(0xFF5CC8BA); static Color ofKind(String kind) => switch (kind) { 'company' => company, @@ -22,39 +25,81 @@ class Pal { }; } +/// 안드로이드 기본 글꼴 (한글은 시스템이 Noto Sans CJK 로 채운다). 테마의 모든 글자 모양에 같이 쓴다. +const _font = 'Roboto'; + ThemeData buildTheme() { final base = ColorScheme.fromSeed( seedColor: Pal.accent, brightness: Brightness.dark, ).copyWith( surface: Pal.bg, + surfaceContainerHigh: Pal.surface, + surfaceContainerHighest: Pal.raised, primary: Pal.accent, onPrimary: Colors.black, onSurface: Pal.text, + onSurfaceVariant: Pal.muted, outline: Pal.line, + outlineVariant: Pal.line, + error: Pal.danger, ); + final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)); return ThemeData( useMaterial3: true, + fontFamily: _font, colorScheme: base, scaffoldBackgroundColor: Pal.bg, appBarTheme: const AppBarTheme( backgroundColor: Pal.bg, foregroundColor: Pal.text, + surfaceTintColor: Colors.transparent, elevation: 0, + scrolledUnderElevation: 0, centerTitle: false, + titleTextStyle: TextStyle(fontFamily: _font, fontSize: 20, fontWeight: FontWeight.w700, color: Pal.text), ), - navigationBarTheme: const NavigationBarThemeData( + navigationBarTheme: NavigationBarThemeData( 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( filled: true, fillColor: Pal.surface, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Pal.line), - ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), 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, ); } diff --git a/lib/modules/ui/src/widgets/group_dialogs.dart b/lib/modules/ui/src/widgets/group_dialogs.dart index d5b8b0e..7fcb58a 100644 --- a/lib/modules/ui/src/widgets/group_dialogs.dart +++ b/lib/modules/ui/src/widgets/group_dialogs.dart @@ -1,4 +1,5 @@ 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/storage/storage.dart'; @@ -124,7 +125,7 @@ class _PeoplePickerState extends State<_PeoplePicker> { : (v) => setState(() => v == true ? _picked.add(p.id) : _picked.remove(p.id)), secondary: Avatar(p.name, size: 36), 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)), ), ]), ), diff --git a/lib/modules/ui/src/widgets/labels.dart b/lib/modules/ui/src/widgets/labels.dart index d504a1b..9f3c363 100644 --- a/lib/modules/ui/src/widgets/labels.dart +++ b/lib/modules/ui/src/widgets/labels.dart @@ -11,8 +11,8 @@ Future> fieldLabels(FriendlyApp app) async { return out; } -/// 값이 있는 항목만 "이름: 값" 줄로. +/// 값이 있는 항목만 "이름: 값" 줄로. 전화번호는 010 형식으로. List> filledFields(Map fields, Map labels) => [ 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), ]; diff --git a/lib/modules/ui/src/widgets/message_bubble.dart b/lib/modules/ui/src/widgets/message_bubble.dart new file mode 100644 index 0000000..3e4ab5d --- /dev/null +++ b/lib/modules/ui/src/widgets/message_bubble.dart @@ -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)), + ), + ), + ); +} diff --git a/lib/modules/ui/src/widgets/person_picker.dart b/lib/modules/ui/src/widgets/person_picker.dart index 15886ad..bf3fc56 100644 --- a/lib/modules/ui/src/widgets/person_picker.dart +++ b/lib/modules/ui/src/widgets/person_picker.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; import 'package:friendly_map/modules/storage/storage.dart'; import '../theme.dart'; @@ -27,7 +28,7 @@ Future pickPerson(BuildContext context, List people, {String ti minTileHeight: 56, leading: Avatar(p.name, size: 40), 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), ), ]), diff --git a/test/reconnect_display_test.dart b/test/reconnect_display_test.dart new file mode 100644 index 0000000..d7362d7 --- /dev/null +++ b/test/reconnect_display_test.dart @@ -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 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); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 8e7bb13..9b44346 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.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/ui/src/screens/person_screen.dart'; import 'package:friendly_map/modules/ui/ui.dart'; import 'support/app_device.dart'; +import 'support/fake_cipher.dart'; +import 'support/storage_contract.dart' show FakeProbe; /// 키·저장소를 어떻게 여는지는 화면이 모른다. 시험에서는 메모리 기기로 대신한다. class FakeBootstrap implements Bootstrap { @@ -33,50 +36,75 @@ class FakeBootstrap implements Bootstrap { } void main() { - testWidgets('키가 없으면 처음 화면이 보인다', (tester) async { - await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap())); - await tester.pumpAndSettle(); - expect(find.text('새로 시작하기'), findsOneWidget); - }); - - testWidgets('내 정보가 있으면 홈과 아래 메뉴가 보이고 칸을 옮길 수 있다', (tester) async { + Future openWithFriend(WidgetTester tester) async { 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.pumpAndSettle(); + return device.app; + } + + Future 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.delayed(const Duration(milliseconds: 50))); + await tester.pumpAndSettle(); + expect(boot.keys, isTrue, reason: '키는 조용히 만들어진다'); expect(find.byType(NavigationBar), findsOneWidget); - await tester.tap(find.descendant(of: find.byType(NavigationBar), matching: find.text('명함'))); - await tester.pumpAndSettle(); - expect(find.text('내 명함'), findsWidgets); + expect(find.text('아직 대화가 없어요'), findsOneWidget); }); - testWidgets('설정에서 그룹 관리를 열면 처음 그룹 네 개가 보이고, 새 그룹을 더하고, 학교 안에 하위 그룹이 보인다', (tester) async { - 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.pumpAndSettle(); - await tester.tap(find.descendant(of: find.byType(NavigationBar), matching: find.text('내 정보'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('저장·보안')); - await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('그룹 관리'), 200, scrollable: find.byType(Scrollable).last); - await tester.tap(find.text('그룹 관리')); - await tester.pumpAndSettle(); + testWidgets('아래 메뉴: 채팅 · 관계 · 업무 · 명함 · 나', (tester) async { + await openWithFriend(tester); + for (final l in ['채팅', '관계', '업무', '명함', '나']) { + expect(find.descendant(of: find.byType(NavigationBar), matching: find.text(l)), findsOneWidget); + } + await tab(tester, '명함'); + expect(find.text('내 명함'), findsWidgets); + await tab(tester, '나'); + expect(find.text('010-1111-2222'), findsOneWidget, reason: '번호는 010 형식으로'); + }); + + testWidgets('관계 탭: 그룹 네 개, 새 그룹 더하기, 학교 안에 하위 그룹', (tester) async { + await openWithFriend(tester); + await tab(tester, '관계'); for (final n in ['가족', '학교', '회사', '그룹·여행']) { expect(find.text(n), findsOneWidget); } - await tester.tap(find.byTooltip('새 그룹')); + await tester.tap(find.text('새 그룹')); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), '등산 모임'); + await tester.enterText(find.byType(TextField).last, '등산 모임'); await tester.tap(find.text('확인')); await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('등산 모임'), 200, scrollable: find.byType(Scrollable).first); expect(find.text('등산 모임'), findsOneWidget); + await tester.scrollUntilVisible(find.text('학교'), -200, scrollable: find.byType(Scrollable).first); await tester.tap(find.text('학교')); await tester.pumpAndSettle(); 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 app = device!.app; final friend = await tester.runAsync(() => app.friends.add('+821033334444', '김민수')); @@ -84,8 +112,9 @@ void main() { await tester.pumpAndSettle(); tester.state(find.byType(Navigator).first).push(MaterialPageRoute(builder: (_) => PersonScreen(personId: friend!.id))); await tester.pumpAndSettle(); + expect(find.text('010-3333-4444'), findsOneWidget); expect(find.textContaining('관계 정하기 —'), findsOneWidget); - await tester.tap(find.byTooltip('관계 정하기')); + await tester.tap(find.text('관계')); await tester.pumpAndSettle(); await tester.tap(find.textContaining('학교 동문')); await tester.pumpAndSettle(); diff --git a/test_ux/shots/00_onboarding.png b/test_ux/shots/00_onboarding.png new file mode 100644 index 0000000..86f36ff Binary files /dev/null and b/test_ux/shots/00_onboarding.png differ diff --git a/test_ux/shots/01_chats.png b/test_ux/shots/01_chats.png new file mode 100644 index 0000000..1f6368a Binary files /dev/null and b/test_ux/shots/01_chats.png differ diff --git a/test_ux/shots/02_relations.png b/test_ux/shots/02_relations.png new file mode 100644 index 0000000..34cc0e4 Binary files /dev/null and b/test_ux/shots/02_relations.png differ diff --git a/test_ux/shots/03_tasks.png b/test_ux/shots/03_tasks.png new file mode 100644 index 0000000..7192c01 Binary files /dev/null and b/test_ux/shots/03_tasks.png differ diff --git a/test_ux/shots/04_cards.png b/test_ux/shots/04_cards.png new file mode 100644 index 0000000..8264490 Binary files /dev/null and b/test_ux/shots/04_cards.png differ diff --git a/test_ux/shots/05_me.png b/test_ux/shots/05_me.png new file mode 100644 index 0000000..b2cda16 Binary files /dev/null and b/test_ux/shots/05_me.png differ diff --git a/test_ux/shots/10_requests.png b/test_ux/shots/10_requests.png new file mode 100644 index 0000000..3218950 Binary files /dev/null and b/test_ux/shots/10_requests.png differ diff --git a/test_ux/shots/11_group_school.png b/test_ux/shots/11_group_school.png new file mode 100644 index 0000000..1218271 Binary files /dev/null and b/test_ux/shots/11_group_school.png differ diff --git a/test_ux/shots/12_group_company.png b/test_ux/shots/12_group_company.png new file mode 100644 index 0000000..23b54c1 Binary files /dev/null and b/test_ux/shots/12_group_company.png differ diff --git a/test_ux/shots/13_settings.png b/test_ux/shots/13_settings.png new file mode 100644 index 0000000..748cff6 Binary files /dev/null and b/test_ux/shots/13_settings.png differ diff --git a/test_ux/shots/20_contacts.png b/test_ux/shots/20_contacts.png new file mode 100644 index 0000000..2e732be Binary files /dev/null and b/test_ux/shots/20_contacts.png differ diff --git a/test_ux/shots/21_person.png b/test_ux/shots/21_person.png new file mode 100644 index 0000000..d45ecf6 Binary files /dev/null and b/test_ux/shots/21_person.png differ diff --git a/test_ux/shots/22_relate.png b/test_ux/shots/22_relate.png new file mode 100644 index 0000000..fac8b69 Binary files /dev/null and b/test_ux/shots/22_relate.png differ diff --git a/test_ux/shots/23_chat_room.png b/test_ux/shots/23_chat_room.png new file mode 100644 index 0000000..823fff7 Binary files /dev/null and b/test_ux/shots/23_chat_room.png differ diff --git a/test_ux/shots_test.dart b/test_ux/shots_test.dart new file mode 100644 index 0000000..2ef90ef --- /dev/null +++ b/test_ux/shots_test.dart @@ -0,0 +1,159 @@ +// 화면 점검용 캡처 (일반 시험이 아니다). 폰 크기(360×800)로 주요 화면을 그려 test_ux/shots/*.png 로 남긴다. +// flutter test test_ux/shots_test.dart --update-goldens +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; +import 'package:friendly_map/modules/transport/transport.dart'; +import 'package:friendly_map/modules/ui/src/screens/chat_room_screen.dart'; +import 'package:friendly_map/modules/ui/src/screens/contact_import_screen.dart'; +import 'package:friendly_map/modules/ui/src/screens/group_detail_screen.dart'; +import 'package:friendly_map/modules/ui/src/screens/person_screen.dart'; +import 'package:friendly_map/modules/ui/src/screens/requests_screen.dart'; +import 'package:friendly_map/modules/ui/src/screens/settings_screen.dart'; +import 'package:friendly_map/modules/ui/ui.dart'; + +import '../test/support/app_device.dart'; +import '../test/support/fake_cipher.dart'; +import '../test/support/storage_contract.dart' show FakeProbe; +import '../test/widget_test.dart' show FakeBootstrap; + +class _Contacts implements ContactsProvider { + @override + Future requestPermission() async => true; + @override + Future> 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 _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 seed(WidgetTester tester) async { + await tester.runAsync(() async { + final hub = MemoryHub(); + me = await AppDevice.create(hub, '010-1111-2222', '+821011112222', '이미영', contacts: _Contacts()); + minsu = await AppDevice.create(hub, '010-3333-4444', '+821033334444', '김민수'); + app = me.app; + await app.friends.add(minsu.phone, minsu.name); + await AppDevice.settleAll(); + await minsu.app.friends.accept(me.phone); + await AppDevice.settleAll(); + for (final (p, n) in [('+821055556666', '이영희'), ('+821077778888', '박철수'), ('+821012123434', '최지은')]) { + await app.friends.add(p, n); + } + final people = {for (final p in await app.friends.people()) p.name: p.id}; + await app.relate.into(people['이영희']!, 'school.high'); + final hb = (await app.groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!; + final team = (await app.groups.add('결제팀', CardKinds.company, parent: hb.id))!; + await app.relate.into(people['박철수']!, team.id); + await app.relate.into(people['김민수']!, team.id); + final room = (await app.chats.openDirect(people['김민수']!, CardKinds.personal))!; + await app.chats.send(room.id, '안녕하세요, 내일 회의 몇 시죠?'); + await app.work.assign(room.id, '결제 API 정리', assigneeId: people['김민수'], due: DateTime(2026, 10, 30)); + await app.work.urgent(room.id, '결제 서버가 멈췄어요'); + // 김민수가 새 폰으로 다시 연결을 청함 + final again = await AppDevice.create(hub, '010-3333-4444', '+821033334444', '김민수 새폰'); + await again.app.friends.add(me.phone, '이미영'); + await AppDevice.settleAll(); + }); + } + + Future 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 shot(WidgetTester tester, String name) async { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await tester.pumpAndSettle(); + await expectLater(find.byType(MaterialApp), matchesGoldenFile('shots/$name.png')); + } + + Future push(WidgetTester tester, Widget page) async { + tester.state(find.byType(Navigator).first).push(MaterialPageRoute(builder: (_) => page)); + await tester.pumpAndSettle(); + } + + Future tab(WidgetTester tester, String label) async { + await tester.tap(find.descendant(of: find.byType(NavigationBar), matching: find.text(label))); + await tester.pumpAndSettle(); + } + + testWidgets('처음 시작', (tester) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + final fresh = FriendlyApp(AppDeps(storage: Storage.memory(), transport: MemoryTransport('+82100', MemoryHub()), cipher: FakeCipher('K'), identity: FakeIdentity('K'), probe: FakeProbe(10))); + await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap(app: fresh))); + await tester.pumpAndSettle(); + await shot(tester, '00_onboarding'); + }); + + testWidgets('아래 메뉴 다섯 칸', (tester) async { + await open(tester); + await shot(tester, '01_chats'); + await tab(tester, '관계'); + await shot(tester, '02_relations'); + await tab(tester, '업무'); + await shot(tester, '03_tasks'); + await tab(tester, '명함'); + await shot(tester, '04_cards'); + await tab(tester, '나'); + await shot(tester, '05_me'); + }); + + testWidgets('요청 · 그룹 · 설정', (tester) async { + await open(tester); + await push(tester, const RequestsScreen()); + await shot(tester, '10_requests'); + await push(tester, const GroupDetailScreen(groupId: CardKinds.school)); + await shot(tester, '11_group_school'); + await push(tester, const GroupDetailScreen(groupId: CardKinds.company)); + await shot(tester, '12_group_company'); + await push(tester, const SettingsScreen()); + await shot(tester, '13_settings'); + }); + + testWidgets('주소록 · 사람 · 대화', (tester) async { + await open(tester); + await push(tester, const ContactImportScreen()); + await shot(tester, '20_contacts'); + final people = {for (final p in (await tester.runAsync(() => app.friends.people()))!) p.name: p.id}; + await push(tester, PersonScreen(personId: people['김민수']!)); + await shot(tester, '21_person'); + await tester.tap(find.text('관계')); + await tester.pumpAndSettle(); + await shot(tester, '22_relate'); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + final rooms = (await tester.runAsync(() => app.chats.summaries()))!; + await push(tester, ChatRoomScreen(roomId: rooms.first.room.id)); + await shot(tester, '23_chat_room'); + }); +} diff --git a/tool/test_module.sh b/tool/test_module.sh index 970fdb2..bdc61f9 100644 --- a/tool/test_module.sh +++ b/tool/test_module.sh @@ -18,7 +18,7 @@ declare -A T=( [inference]="inference_test.dart" [layout]="layout_test.dart" [sharing]="sharing_service_test.dart sharing_status_test.dart" - [app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart group_service_test.dart contact_import_test.dart relate_service_test.dart work_service_test.dart career_service_test.dart" + [app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart group_service_test.dart contact_import_test.dart relate_service_test.dart work_service_test.dart career_service_test.dart reconnect_display_test.dart" [platform_android]="df_parser_test.dart" [ui]="widget_test.dart" ) diff --git a/wiki/.obsidian/app.json b/wiki/.obsidian/app.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/wiki/.obsidian/app.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/wiki/.obsidian/appearance.json b/wiki/.obsidian/appearance.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/wiki/.obsidian/appearance.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/wiki/.obsidian/core-plugins.json b/wiki/.obsidian/core-plugins.json new file mode 100644 index 0000000..be3f191 --- /dev/null +++ b/wiki/.obsidian/core-plugins.json @@ -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 +} \ No newline at end of file diff --git a/wiki/00 최우선 규칙.md b/wiki/00 최우선 규칙.md new file mode 100644 index 0000000..0f8d904 --- /dev/null +++ b/wiki/00 최우선 규칙.md @@ -0,0 +1,17 @@ +# 최우선 규칙 (모든 규칙보다 위) + +- **0순위: 모든 가이드·문서·결정·진행 기록·화면 점검은 이 wiki 보관함에 저장·관리한다.** 세션 시작 때 홈과 이 문서부터 읽고, 일할 때마다 진행 로그·결정 기록을 갱신한다. + +- **질문은 설치 가능한 APK 를 먼저 건넨 뒤에만.** 사용자가 폰에 설치해 써 보며 답할 수 있게. 캡처도 함께 보여 주고 "이렇게 하면 / 저렇게 하면" 화면 비교로만 묻는다. +- 암호 비트 수·키 구조·함수 설계 같은 **내부 기술 질문 금지.** 내가 정하고 결과에 적는다. +- **책임 회피성 질문 금지** ("물었으니 사용자 책임"). +- 방법론: 화면 먼저 → 화면 보고 사용자가 답 → 그 답에 따라 내부 아키텍처를 바꾼다. +- 묻지 말고 끝까지 진행. 목표는 APK. 멈춰서 대기하지 않는다. + +## 일하는 규칙 +- 모든 답변 한글. "목업" 대신 "화면 설계". +- 구조가 기능보다 먼저, 화면과 내부 분리, 모듈은 빼고 끼울 수 있게 (`tool/check_modules.py`). +- 파일 300~400줄, 최대 700줄. 모듈별 시험. +- 한 번에 한 플랫폼(안드로이드). +- 평소 Gitea 에 푸시, GitLab 은 하루 한 번 메이저 업그레이드 때만. +- 모든 기록은 이 Obsidian 보관함에도 남긴다. diff --git a/wiki/01 제품 개요.md b/wiki/01 제품 개요.md new file mode 100644 index 0000000..c2d1600 --- /dev/null +++ b/wiki/01 제품 개요.md @@ -0,0 +1,9 @@ +# 제품 개요 + +- 기본은 **채팅**. 채팅하다 관계를 알게 되면 그룹으로 넣는다. +- 가족 → 촌수 맞춰 넣기(친가·외가·처가·시가), 모르면 "친척(촌수 모름)". +- 학교 → 초등·중등·고등·대학·대학원 하위 그룹. +- 회사 → 회사 › 프로젝트·팀, 팀 대화방, 업무 맡기기·정리, 긴급 호출. +- 이직 → 상대가 회사 명함을 다시 보내면 "이직했습니다" 알림, 확인하면 이전 회사는 흐리게·새 회사로 이동·삭제. +- 목적별 명함(개인/회사/학교/그룹·여행/가족), 공개 범위 표, "어디서 봤지?" 유추, 주소록 가져오기, 비밀번호 잠금 그룹. +- 모든 데이터는 기기 안 암호화(SQLCipher), 기기끼리 P2P, 서버는 암호화 우편함뿐. diff --git a/wiki/02 결정 기록.md b/wiki/02 결정 기록.md new file mode 100644 index 0000000..71807f6 --- /dev/null +++ b/wiki/02 결정 기록.md @@ -0,0 +1,12 @@ +# 결정 기록 + +| 날짜 | 결정 | 이유 | +|---|---|---| +| 2026-10-26 | 그룹·비밀번호 잠금(PBKDF2, 5번 틀리면 30초부터 두 배), 연 뒤 5분/앱 내리면 다시 잠금 | 사용자 요청: 비번 눌러야 열리는 그룹 | +| 2026-10-26 | 주소록 가져오기: 이름·초성·번호·주소록 라벨 거르기, 한 명/여러 명, 친구 요청은 선택(기본 끔) | 사용자 요청 | +| 2026-10-26 | 그룹 계층 4단, 학교·가족 기본 하위 그룹 | 채팅에서 시작하는 관계망 | +| 2026-10-26 | 동기화는 새 통신 형식 없이 명함 "다시 보내기"와 대화 메시지(📌 업무, 🚨 긴급)로 | 기존 결정(가족 데이터 기기 안, 고친 명함은 다시 보내기로만) 유지 | +| 2026-10-26 | **복구 코드·조각·보관 키 폐지.** 처음 시작은 이름·번호만, 키는 조용히 생성 | 사용자 결정: 복구 방안 없음, 탈퇴 후 재가입 없음 | +| 2026-10-26 | 새 폰에서 다시 시작 → 관계를 유지하는 사용자 각자가 **승인**(다시 연결) 또는 **제명**(번호 영구 차단) | 사용자 결정: 관리자 승인 | +| 2026-10-26 | 아래 메뉴: 채팅(첫 화면) · 관계 · 업무 · 명함 · 나. 옛 홈 타일·중복 화면 삭제 | UX 점검 결과 | +| 2026-10-26 | 질문은 APK·화면을 먼저 보여 준 뒤에만 | 사용자 최우선 지시 | diff --git a/wiki/03 로드맵.md b/wiki/03 로드맵.md new file mode 100644 index 0000000..54b9fd2 --- /dev/null +++ b/wiki/03 로드맵.md @@ -0,0 +1,11 @@ +# 로드맵 + +- [x] 그룹 정의·잠금·주소록 가져오기 +- [x] 1단계 그룹 계층 +- [x] 2단계 채팅에서 관계 정하기(촌수, 친가·외가·처가, 촌수 모름) +- [x] 3단계 업무: 팀 대화방, 업무 맡기기·정리, 긴급 호출 +- [x] 4단계 이직 알림 +- [x] 복구 폐지 → 다시 연결 승인·제명 +- [x] UX 전면 개편 1차 (캡처 [[06 화면 점검]]) +- [ ] APK 전달 → 사용자 설치·시험 → 화면 보며 피드백 반영 +- [ ] 실제 기기 점검(주소록, 알림, 위치, QR, 같은 네트워크) diff --git a/wiki/04 구조와 모듈.md b/wiki/04 구조와 모듈.md new file mode 100644 index 0000000..343255c --- /dev/null +++ b/wiki/04 구조와 모듈.md @@ -0,0 +1,17 @@ +# 구조와 모듈 + +`lib/modules/<이름>/<이름>.dart` 가 공개 입구. 조립은 `lib/main.dart`, `lib/composition/android_bootstrap.dart`. + +| 모듈 | 하는 일 | +|---|---| +| core | 모듈끼리의 약속(키·전송·암호·봉투·명함 항목) | +| platform / platform_android | 기기 기능 약속 / 안드로이드 구현(플러그인은 여기서만) | +| storage | 저장소(메모리·SQLCipher) | +| keys | 키 파생·암호 (복구 코드 화면은 폐지, 모듈은 남김) | +| sharing, sync, transport, discovery | 공유·동기화·전송·발견 | +| inference, visibility, layout | 유추·공개 범위·배치 | +| kinship, events | 촌수·쪽(친가/외가), 생일·기일 | +| groups | 그룹 나무·잠금·이전 구성원 (순수) | +| work | 📌 업무·🚨 긴급 표시, 업무 목록 (순수) | +| app | 서비스 묶음 FriendlyApp (groups, contacts, relate, work, career, blocks …) | +| ui | 화면 | diff --git a/wiki/05 빌드 · 시험 · 저장소.md b/wiki/05 빌드 · 시험 · 저장소.md new file mode 100644 index 0000000..3fc6a08 --- /dev/null +++ b/wiki/05 빌드 · 시험 · 저장소.md @@ -0,0 +1,12 @@ +# 빌드 · 시험 · 저장소 + +``` +podman run --rm -v "$PWD":/app -v fm-gradle:/root/.gradle -v fm-pub:/root/.pub-cache -w /app ghcr.io/cirruslabs/flutter:stable bash +python3 tool/android_patch.py +flutter pub get && flutter analyze && flutter test +flutter build apk --debug # build/app/outputs/flutter-apk/app-debug.apk +flutter test test_ux/shots_test.dart --update-goldens # 화면 캡처 test_ux/shots/ +``` +- 빌드할 때 시험을 같이 돌리지 않는다(podman 머신 8GB, Gradle 데몬이 꺼진 적 있음). +- 푸시: `git push origin main` 단독 명령(샌드박스 제외 설정). Gitea 토큰은 키체인. +- VS Code 터미널의 git 은 아이디를 창 맨 위 입력칸으로 묻는다. diff --git a/wiki/06 화면 점검.md b/wiki/06 화면 점검.md new file mode 100644 index 0000000..c47fc91 --- /dev/null +++ b/wiki/06 화면 점검.md @@ -0,0 +1,29 @@ +# 화면 점검 + +## 2026-10-26 개편 전 (지적: "초딩 수준도 안 됨") +![[화면 점검/2026-10-26 개편 전/01_home.png|250]] ![[화면 점검/2026-10-26 개편 전/02_chats.png|250]] ![[화면 점검/2026-10-26 개편 전/23_chat_room.png|250]] ![[화면 점검/2026-10-26 개편 전/21_person.png|250]] + +문제: +1. 홈이 채팅 중심이 아님, 정보 없는 타일 10개 +2. 회사·학교·그룹 타일과 그룹 관리가 두 벌 +3. 그룹이 저장·보안 설정 안에 숨어 있음 +4. 업무·긴급이 날것의 글자, 미리보기도 같음 +5. 번호 +82 형식 +6. 뜻 모를 아이콘 +7. 사람 화면에 상관없는 칸 +8. 명함 칸 반복, 버튼이 가림 +9. 글자 줄바꿈·잘림·설명문 과다 + +## 2026-10-26 개편 후 +![[화면 점검/2026-10-26 개편 후/00_onboarding.png|220]] ![[화면 점검/2026-10-26 개편 후/01_chats.png|220]] ![[화면 점검/2026-10-26 개편 후/02_relations.png|220]] ![[화면 점검/2026-10-26 개편 후/03_tasks.png|220]] ![[화면 점검/2026-10-26 개편 후/04_cards.png|220]] ![[화면 점검/2026-10-26 개편 후/05_me.png|220]] ![[화면 점검/2026-10-26 개편 후/10_requests.png|220]] ![[화면 점검/2026-10-26 개편 후/11_group_school.png|220]] ![[화면 점검/2026-10-26 개편 후/20_contacts.png|220]] ![[화면 점검/2026-10-26 개편 후/21_person.png|220]] ![[화면 점검/2026-10-26 개편 후/22_relate.png|220]] ![[화면 점검/2026-10-26 개편 후/23_chat_room.png|220]] + +바뀐 것: +- 처음 시작: 이름·번호만 (복구 코드 없음) +- 아래 메뉴 채팅(첫 화면) · 관계 · 업무 · 명함 · 나, 배지(관계=받은 요청, 업무=내 업무) +- 채팅: 다시 연결 요청 띠, 이직 알림, 미리보기를 읽는 말로(긴급은 빨간색), 시각 "오전 11:29/어제/10월 24일" +- 대화방: 날짜 구분선, 업무 카드, 긴급 호출 빨간 띠, + 버튼(업무 맡기기·긴급 호출), ⋮ 메뉴(프로필·관계·이 방 업무·보관) +- 관계: 사람 찾기, 빠른 버튼(모든 사람·가계도·주소록·흔들기), 그룹 나무, "이런 사이 아닐까요?" +- 프로필: 큰 이름·010 번호·동그란 행동 버튼, 해당 없는 칸 숨김 +- 나: 설정 목록(공개 범위·요청 받기·막기·위치·잠금 시간·제명한 번호·알림·저장 공간) +- 번호는 모든 곳에서 010-0000-0000 +- 캡처 글꼴 문제: 테마 글자 모양에 글꼴 이름을 명시해 캡처=폰 화면 diff --git a/wiki/가이드/기능 안내.md b/wiki/가이드/기능 안내.md new file mode 100644 index 0000000..6253d8e --- /dev/null +++ b/wiki/가이드/기능 안내.md @@ -0,0 +1,136 @@ +# 기능 안내 + +> 이 문서가 기준이다. 저장소의 README.md 는 이 문서를 가리키기만 한다. + + +가족·친구·직장 관계를 한 앱에서. 새 친구가 "어디서 봤지?" 싶을 때 목적별 명함을 주고받아 관계를 유추한다. +안드로이드 우선, Flutter. 모든 기기가 서버 겸 클라이언트(P2P)이고, 외부 DB는 암호화된 임시 우편함일 뿐이다. + +## 원칙 (1순위) + +1. 구조가 기능보다 먼저다. 화면과 내부를 분리한다. +2. 모듈은 언제든 빼고 끼울 수 있다. 기능을 더하거나 빼도 다른 모듈을 고치지 않는다. +3. 파일은 300~400줄, 최대 700줄. +4. 모듈별로 시험한다. 시험이 끝난 모듈은 다시 만들지 않고 그 위에 쌓는다. +5. 한 번에 한 플랫폼. 지금은 안드로이드. 다른 플랫폼은 `platform_<이름>` 모듈만 새로 붙인다. + +## 모듈과 의존 방향 (`tool/check_modules.py` 가 검사) + +| 모듈 | 하는 일 | 쓸 수 있는 모듈 | +|---|---|---| +| core | 모듈끼리 주고받는 약속(키·전송·암호·봉투·명함 항목 이름) | 없음 | +| platform | 기기마다 다른 기능의 약속(보안 저장소, 저장 공간, 센서, QR, 발견, 위치, 통화 기록·전화, 알림, 주소록) | 없음 | +| platform_android | platform 의 안드로이드 구현(플러그인은 여기서만) | core, platform, storage | +| storage | 저장소 약속 + 메모리/SQLite 구현 | platform | +| keys | 키 파생, 둘만 아는 키 암호 (복구 코드·조각 기능은 화면에서 폐지, 코드는 남김) | core, platform | +| sharing | 명함·대화방이 누구와 공유되는지 | storage | +| sync | 봉투 만들기·반영, 충돌 규칙, 시계 맞추기, 재전송 | core, storage | +| transport | 메모리·직접 연결·외부 보관함·늦게 만드는 연결 | core | +| discovery | QR 내용, 흔들기 감지, 같은 네트워크 주소록 | platform | +| inference | "어디서 봤지?" 유추와 추천 | core | +| visibility | 공개 범위 표(정보 × 관계)와 명함 거르기. 순수 계산 | core | +| layout | 홈·명함 틀(템플릿) 순서/보이기/편집 | core | +| kinship | 가계도로 촌수·호칭 계산(순수) | 없음 | +| events | 생일·기일 일정, 알림 날짜 계획, 통화 기록 요약(순수) | 없음 | +| groups | 그룹 정의(이름·종류·순서·구성원·하위 그룹), 비밀번호 잠금, 여러 번 틀리면 기다리기(순수) | core | +| work | 대화 속 업무 표시(📌 업무·🚨 긴급 호출) 읽고 쓰기, 맡은 업무 목록(순수) | 없음 | +| app | 서비스 묶음(`FriendlyApp`). 화면이 쓰는 유일한 내부 입구 | 위 모듈들(키·전송 구현 제외) | +| ui | 화면. `app` 의 서비스만 쓴다 | app, core, platform, storage, discovery, inference, layout, visibility, kinship, events, groups, work | + +모듈 폴더 밖의 `lib/main.dart`, `lib/composition/` 이 부품을 고르는 유일한 곳이다. +암호 구현·저장소·연결 방식을 바꾸려면 `lib/composition/android_bootstrap.dart` 의 해당 줄만 바꾼다. + +## 시험 + +``` +tool/test_module.sh # 모듈 목록 +tool/test_module.sh sync # 모듈 하나만 +flutter test # 전체 +python3 tool/check_modules.py # 줄 수·import 방향 +``` + +## 시작 + +``` +flutter create --platforms=android --org com.friendlymap --project-name friendly_map . +python3 tool/android_patch.py # 권한, 평문 통신, minSdk 23 +flutter pub get +flutter analyze +flutter test +flutter run +``` + +보관함 서버(선택): `server/README.md`. 서버 주소를 설정 `mailbox_url` 에 넣으면 같은 네트워크 밖에서도 주고받는다. + +## 알려진 한계 + +- 보관함의 우편함 토큰은 번호별 처음 쓴 기기에 묶인다(번호 선점 가능). 나중에 문자 인증으로 막는다. +- 그룹 대화방은 사람이 나가도 키를 바꾸지 않는다. +- 위치는 지도 타일 없이 레이더(내 위치 중심, 방위·거리)로 보여 준다. 지도 SDK는 붙이지 않았다. +- 문자 인증은 만들어 뒀지만 문자 발송 주소(`--sms-webhook`)를 정해 서버에 넣어야 켜진다. 켜기 전에는 처음 쓴 기기에 묶이는 방식(번호 선점 가능)이다. + +## 가족 모임 (촌수 · 통화 요약 · 생일/기일 알림) +- 관계 탭 › 가계도에서 가족을 한 분씩 추가한다. 번호가 없는 친척도 넣을 수 있다(이 기기 안에서만 쓰이고 친구 요청은 가지 않는다). +- "나의 부모/자녀/배우자"를 고르거나 부모·자녀·배우자를 이어 가계도를 만들면, 나와 내 자녀 각각의 눈으로 본 호칭과 촌수가 나온다(예: 내게는 친사촌 형 4촌, 내 아들에게는 당숙 5촌). +- 사람을 누르면 관계, 통화 횟수·시간·마지막 통화·통화 잘 되는 시간대, 주제 메모, 다가오는 생일/기일을 요약하고 전화·문자·대화 버튼이 있다. +- 돌아가신 날을 적으면 맨 위에 "故" 띠가 뜨고(설정에서 끌 수 있다) 생일 대신 기일을 알린다. +- 나 › 알림 · 저장 공간 › "생일·기일 알림"에 `7, 2, 1, 0` 처럼 적으면 그 날짜 전 오전 9시에 알림이 울린다(기본 1일 전·당일). +- 음력: 생일·기일마다 "음력"을 켤 수 있다(1951~2049년 변환표 내장, 기일은 윤달도 지원). 제사 알림이 음력 날짜로 잡힌다. +- 한계: 통화 "내용"은 기기에 남지 않으므로 주제는 직접 적는다. 통화 기록 읽기(READ_CALL_LOG)는 Play 스토어 심사 제한이 있다. +- 안드로이드: `call_log`, `url_launcher`, `flutter_local_notifications`, `timezone` 플러그인과 권한·수신기·desugaring 설정이 필요하다(`tool/android_patch.py` 가 맞춘다). + +## 처음 시작 · 다시 연결 (복구 없음) + +- 처음 시작은 이름과 휴대폰 번호만 넣는다. 키는 이 폰 안에서 조용히 만들고, 복구 코드·조각·보관 키는 없다. +- 폰을 바꾸거나 잃으면 그 폰의 기록은 되살리지 않는다. 탈퇴 후 재가입도 없다. +- 새 폰에서 같은 번호로 시작하면, 관계를 유지하고 있는 사용자 각자에게 "새 기기로 다시 연결" 요청이 간다(채팅 맨 위 띠, 관계 탭). **승인**하면 이전 대화·그룹·명함 기록 그대로 새 키로 이어지고, **제명**하면 그 번호는 영구히 막힌다(나 › 제명한 번호에서 풀 수 있음). + +## 공개 범위 + +- 나 › 공개 범위(학교 · 회사·직책 · 생일 · 연락처 × 가족 · 회사 · 동문 · 그룹)에서 칸을 눌러 공개/일부/숨김을 정한다. +- 명함을 보내거나 "다시 보내기"를 할 때 받는 사람의 관계(연결된 관계 + 보내는 명함 종류)에 맞게 걸러서 보낸다. 관계를 모르는 사람에게는 내가 고른 그대로 간다. +- 표를 고쳐도 이미 보낸 명함은 "다시 보내기" 전까지 그대로다. + +## 위치 공유 + +- 나 › 위치 공유에서 켠다. 기본은 꺼짐. +- 켜면 1분마다, 공개 범위 표의 "실시간 위치"가 공개·일부인 관계의 친구에게만 보낸다. 관계를 모르는 친구에게는 보내지 않는다. "일부"는 약 1km 단위로 뭉개서 보낸다. +- 위치는 둘만 아는 키로 잠가서 보내고, 받은 쪽에는 마지막 위치 하나만 남는다. +- 안드로이드: `geolocator` 플러그인과 위치 권한이 필요하다 (`tool/android_patch.py` 가 권한을 넣는다). + +## 번호 확인 (문자 인증) + +- 나 › "내 번호 확인하기": 문자로 온 6자리를 넣으면 이 기기의 토큰이 내 번호에 묶인다. 폰을 바꾼 진짜 주인은 다시 확인하면 새 기기로 넘어오고, 이전 기기는 막힌다. +- 서버: `python3 mailbox_server.py --sms-webhook <주소> --require-verify`. 문자 발송은 `SmsSender` 하나(웹훅은 `{to,text}` 를 POST)라 업체를 바꿔도 서버 나머지는 그대로다. +- `--require-verify` 를 켜면 확인을 마친 기기만 우편함을 열 수 있다 (번호 선점 차단). 켜지 않으면 예전처럼 처음 쓴 기기에 묶인다. +- 5분 만료, 번호당 1시간 3번, 5번 오답이면 새로 요청. `--sms-log` 는 시험용(문자를 보내지 않고 기록)이라 실서비스에서 쓰면 안 된다. +- 서버 시험: `cd server && python3 -m unittest test_mailbox test_verify` + +## 그룹 · 주소록 가져오기 +- 관계 탭. 처음 그룹은 가족·학교·회사·그룹·여행이고, 더하기·이름과 종류 고치기·순서 바꾸기·지우기가 된다(지워도 사람은 인맥에 남는다). +- 그룹 아래에 하위 그룹을 4단까지 만든다. 처음에는 학교 › 초등학교·중학교·고등학교·대학교·대학원, 가족 › 친가·외가·처가·시가·친척(촌수 모름)이 있고, 회사 › 회사 이름 › 프로젝트·팀은 직접 만든다. 같은 부모 아래에서만 이름이 겹치지 않으면 된다. 지우면 하위 그룹도 함께 지워진다(사람은 남는다). +- 그룹마다 비밀번호(4~16자)를 걸 수 있다. 잠긴 그룹은 열 때 비밀번호를 묻고, 닫혀 있으면 하위 그룹과 인맥 목록에서도 그 사람들을 숨긴다. 연 뒤 정한 시간(기본 5분, 1~60분)이 지나거나 앱을 내리면 다시 잠근다. +- 5번 틀리면 30초, 그 뒤로 틀릴 때마다 두 배(최대 1시간) 기다려야 한다. 비밀번호는 PBKDF2-SHA256 값만 남긴다. 잊으면 그 그룹은 지우고 다시 만들어야 한다. +- 관계 탭 › 주소록 불러오기: 이름·초성(ㄱㅁㅅ)·번호로 찾고, 휴대폰 주소록 그룹(라벨)으로 거르고, 이미 있는 사람은 빼고 볼 수 있다. 한 명씩 또는 보이는 사람을 모두 고른 뒤, 넣을 그룹(여러 개)을 골라 가져온다. +- 번호가 여럿이면 휴대폰(010) 번호를 먼저 쓴다. 가져온 사람은 이 기기 인맥에만 들어가고, "친구 요청도 보내기"를 켰을 때만 상대에게 요청이 간다. 주소록은 읽기만 한다. +- 그룹은 이 기기에만 저장한다. 안드로이드: `flutter_contacts` 플러그인과 READ_CONTACTS 권한이 필요하다(`tool/android_patch.py` 가 넣는다). + +## 관계 정하기 (채팅에서 시작하는 관계망) +- 대화방 ⋮ 메뉴 "관계 정하기", 또는 프로필의 "관계" 버튼에서 고른다: 가족·친척 / 학교 동문 / 회사·업무·팀 / 그룹·모임·여행. +- 가족: 나(또는 가계도에 있는 다른 가족)의 부모·자녀·배우자·형제자매를 고르면 가계도에 잇고 촌수·호칭을 계산한다. 아버지 쪽은 친가, 어머니 쪽은 외가, 배우자 쪽은 처가·시가 그룹에 들어간다. 모르면 "친척(촌수 모름)"에 모아 두고, 나중에 이으면 모음에서 빠진다. +- 형제자매를 고를 때 기준 사람의 부모가 가계도에 없으면 "부모(이름 모름)"를 이 기기 안에서만 쓰는 사람으로 만들어 잇는다. +- 학교·회사·모임: 그 종류의 그룹 나무에서 골라 넣거나, + 로 그 아래 새 그룹(예: 학교 › 고등학교 › 가람고 27회, 회사 › 한빛전자 › 결제팀)을 만들어 바로 넣는다. +- 넣으면 연결 고리(가족·동문·직장 동료·같은 모임)가 하나 남아, 공개 범위 표가 그 관계로 적용된다. 같은 종류의 그룹에서 모두 빠지면 연결 고리도 지운다. + +## 업무 · 팀 · 긴급 호출 +- 회사 종류 그룹(예: 회사 › 한빛전자 › 결제팀) 안의 "팀 대화방"을 누르면 구성원과 회사 맥락의 대화방이 생긴다. 회사 명함을 아직 공유하지 않은 사람이 있으면 그 이름을 알려 주고 만들지 않는다. +- 대화방 입력칸 왼쪽 + 버튼: "업무 맡기기"(할 일·담당·마감), "긴급 호출". 오른쪽 위 "업무 N"과 ⋮ 메뉴 "이 방 업무". 아래 메뉴 업무 탭은 모든 방의 업무(처음은 내 업무). +- 업무와 긴급 호출은 보통 대화로 간다(`📌 업무 [김민수] 결제 API 정리 (마감 10/30)`, `🚨 긴급 호출: …`). 받는 쪽 앱이 읽어서 업무 목록에 넣고(담당 이름이 내 이름이면 "내 업무"), 긴급 호출은 바로 알림을 띄운다. 새 봉투 형식은 없다. +- 지난 대화를 길게 누르면 "업무로 정리"(내 목록에만). 업무 목록은 이 기기에만 저장하고, 끝냄 표시·밀어서 지우기가 된다. +- 안드로이드: 긴급 호출 알림은 `flutter_local_notifications` 의 높은 중요도 채널(urgent)을 쓴다. + +## 이직 알림 +- 상대가 회사 명함을 고쳐 "다시 보내기" 하면, 이 기기가 알던 회사와 비교해 바뀌었으면 알림이 생긴다(채팅 맨 위, 프로필). 예: "김민수님이 한빛전자에서 누리소프트로 옮겼어요", "지금은 다니지 않음"이면 "…를 떠났어요". +- "확인 · 옮기기": 이전 회사 이름이 경로에 든 회사 그룹(예: 회사 › 한빛전자 › 결제팀)에서는 이전 구성원으로 흐리게 남기고, "회사 › 새 회사" 그룹(없으면 만든다)에 넣는다. 연결 고리는 '직장 동료'가 새 회사로 바뀌고 '전 직장 동료'(이전 경로)가 남는다. +- "이전 회사에서 지우기": 흐리게 남기지 않고 뺀다. "무시": 알림만 지운다. 이전 구성원은 그룹 안에서 ✕ 로 지울 수 있다. +- 처음 받는 회사 명함, 앱을 새로 연 뒤의 첫 비교는 알림을 만들지 않는다(알던 회사로만 기억). 알던 회사·알림은 이 기기에만 저장한다. diff --git a/wiki/가이드/보관함 서버.md b/wiki/가이드/보관함 서버.md new file mode 100644 index 0000000..f54dd17 --- /dev/null +++ b/wiki/가이드/보관함 서버.md @@ -0,0 +1,27 @@ +# 보관함 서버 + +잠긴 봉투를 받는 사람이 접속할 때까지 잠시 맡아 두는 우체통입니다. +봉투 속은 기기끼리만 풀 수 있어서 서버는 내용을 읽지 못합니다. + +## 실행 (파이썬만 있으면 됨) + + python3 mailbox_server.py --port 8080 --db mailbox.sqlite3 + +## 컨테이너로 실행 + + podman build -t fm-mailbox -f Containerfile . + podman run -d -p 8080:8080 -v fm-data:/data fm-mailbox + +## 시험 + + python3 test_mailbox.py + +## 알아둘 점 + +- 저장 방식은 `mailbox_store.py` 의 `MailboxStore` 약속만 지키면 바꿀 수 있습니다 (클라우드로 옮길 때 이 파일만 교체). +- 기본: 우편함은 처음 연 기기의 비밀값에 묶입니다(번호를 먼저 선점당할 수 있음). +- 문자 확인을 켜면(`--sms-webhook <주소>` + `--require-verify`) 문자로 번호를 확인한 기기만 우편함을 열 수 있어 선점이 막힙니다. + 문자 발송은 웹훅 하나(`POST {"to":"+82...","text":"..."}`, 선택적으로 `--sms-webhook-token`)라 업체에 묶이지 않습니다. + `phone_verify.py` 의 `SmsSender` 를 바꾸면 다른 방식도 쓸 수 있습니다. 시험용 `--sms-log` 는 실서비스에서 쓰지 마세요. +- 인터넷에 열 때는 앞에 HTTPS 를 붙이세요 (Caddy, nginx 등). +- 봉투는 30일 안에 못 받아 가면 정리되고, 받는 사람 한 명당 5000통까지 맡습니다. diff --git a/wiki/진행 로그/2026-10-26.md b/wiki/진행 로그/2026-10-26.md new file mode 100644 index 0000000..a1de9cb --- /dev/null +++ b/wiki/진행 로그/2026-10-26.md @@ -0,0 +1,13 @@ +# 2026-10-26 + +- Flutter analyze·test 통과 확인, 첫 APK 빌드(약 190MB) +- 그룹·잠금·주소록 가져오기 → 그룹 계층 → 관계 정하기 → 업무·긴급 → 이직 알림 (시험 307개) +- Gitea 푸시: 키체인에 git 토큰이 없던 문제 → 사용자가 직접 한 번 푸시해 해결 +- 화면 14장 캡처 점검 → 전면 개편 시작 +- 사용자 결정: 복구 코드 폐지, 다시 연결은 승인/제명, 처음 시작은 이름·번호만 +- 최우선 규칙: 질문은 APK·화면 먼저 +- Obsidian 보관함 FriendlyMap 만들고 기록 시작 +- 화면 전면 개편: 새 탭 구조, 처음 시작 단순화, 다시 연결 승인·제명(BlockService), 표시 도우미(display.dart), 대화 말풍선·업무 카드·긴급 띠 +- 옛 화면 삭제: 환영·복구 코드·조각 맡기기·맡은 조각·홈 타일·홈 편집·묶음·옛 가족·옛 찾기·옛 내 정보·그룹 관리 +- 시험 313개 통과, analyze 0건, 화면 캡처 14장 → [[06 화면 점검]] +- 최우선 규칙 추가: 질문은 APK·화면 먼저 / 모든 문서는 wiki diff --git a/wiki/홈.md b/wiki/홈.md new file mode 100644 index 0000000..3367fe6 --- /dev/null +++ b/wiki/홈.md @@ -0,0 +1,17 @@ +# Friendly Map 기록 홈 + +> **최우선:** 모든 가이드·문서·결정·기록은 이 보관함에 저장하고 관리한다. 다른 문서는 여기를 가리키기만 한다. + +안드로이드 다크모드 인맥 앱(Flutter). 채팅에서 시작해 가족·학교·회사·모임으로 관계를 잇는 사회 관계망. + +- [[00 최우선 규칙]] +- [[01 제품 개요]] +- [[02 결정 기록]] +- [[03 로드맵]] +- [[04 구조와 모듈]] +- [[05 빌드 · 시험 · 저장소]] +- [[06 화면 점검]] +- 가이드: [[가이드/기능 안내]] · [[가이드/보관함 서버]] +- 진행 로그: [[진행 로그/2026-10-26]] + +저장소: http://192.168.88.15:3180/diadmin/frindlymap (Gitea) · 소스: ~/friendly_map diff --git a/wiki/화면 점검/2026-10-26 개편 전/01_home.png b/wiki/화면 점검/2026-10-26 개편 전/01_home.png new file mode 100644 index 0000000..12ee428 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/01_home.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 전/02_chats.png b/wiki/화면 점검/2026-10-26 개편 전/02_chats.png new file mode 100644 index 0000000..da21128 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/02_chats.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 전/03_discover.png b/wiki/화면 점검/2026-10-26 개편 전/03_discover.png new file mode 100644 index 0000000..604cbf9 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/03_discover.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 전/04_cards.png b/wiki/화면 점검/2026-10-26 개편 전/04_cards.png new file mode 100644 index 0000000..b58f10d Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/04_cards.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 전/05_me.png b/wiki/화면 점검/2026-10-26 개편 전/05_me.png new file mode 100644 index 0000000..d4d1603 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/05_me.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 전/10_settings.png b/wiki/화면 점검/2026-10-26 개편 전/10_settings.png new file mode 100644 index 0000000..7b362a8 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/10_settings.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 전/11_groups.png b/wiki/화면 점검/2026-10-26 개편 전/11_groups.png new file mode 100644 index 0000000..455e49b Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/11_groups.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 전/12_group_school.png b/wiki/화면 점검/2026-10-26 개편 전/12_group_school.png new file mode 100644 index 0000000..1e5fd7a Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/12_group_school.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 전/13_group_company.png b/wiki/화면 점검/2026-10-26 개편 전/13_group_company.png new file mode 100644 index 0000000..06ba412 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/13_group_company.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 전/20_contacts.png b/wiki/화면 점검/2026-10-26 개편 전/20_contacts.png new file mode 100644 index 0000000..457e3f0 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/20_contacts.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 전/21_person.png b/wiki/화면 점검/2026-10-26 개편 전/21_person.png new file mode 100644 index 0000000..457927e Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/21_person.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 전/22_relate.png b/wiki/화면 점검/2026-10-26 개편 전/22_relate.png new file mode 100644 index 0000000..2d451ad Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/22_relate.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 전/23_chat_room.png b/wiki/화면 점검/2026-10-26 개편 전/23_chat_room.png new file mode 100644 index 0000000..773ee35 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/23_chat_room.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 전/24_tasks.png b/wiki/화면 점검/2026-10-26 개편 전/24_tasks.png new file mode 100644 index 0000000..5ade034 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 전/24_tasks.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/00_onboarding.png b/wiki/화면 점검/2026-10-26 개편 후/00_onboarding.png new file mode 100644 index 0000000..86f36ff Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/00_onboarding.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/01_chats.png b/wiki/화면 점검/2026-10-26 개편 후/01_chats.png new file mode 100644 index 0000000..1f6368a Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/01_chats.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/02_relations.png b/wiki/화면 점검/2026-10-26 개편 후/02_relations.png new file mode 100644 index 0000000..34cc0e4 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/02_relations.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/03_tasks.png b/wiki/화면 점검/2026-10-26 개편 후/03_tasks.png new file mode 100644 index 0000000..7192c01 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/03_tasks.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/04_cards.png b/wiki/화면 점검/2026-10-26 개편 후/04_cards.png new file mode 100644 index 0000000..8264490 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/04_cards.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/05_me.png b/wiki/화면 점검/2026-10-26 개편 후/05_me.png new file mode 100644 index 0000000..b2cda16 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/05_me.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/10_requests.png b/wiki/화면 점검/2026-10-26 개편 후/10_requests.png new file mode 100644 index 0000000..3218950 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/10_requests.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/11_group_school.png b/wiki/화면 점검/2026-10-26 개편 후/11_group_school.png new file mode 100644 index 0000000..1218271 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/11_group_school.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/12_group_company.png b/wiki/화면 점검/2026-10-26 개편 후/12_group_company.png new file mode 100644 index 0000000..23b54c1 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/12_group_company.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/13_settings.png b/wiki/화면 점검/2026-10-26 개편 후/13_settings.png new file mode 100644 index 0000000..748cff6 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/13_settings.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/20_contacts.png b/wiki/화면 점검/2026-10-26 개편 후/20_contacts.png new file mode 100644 index 0000000..2e732be Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/20_contacts.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/21_person.png b/wiki/화면 점검/2026-10-26 개편 후/21_person.png new file mode 100644 index 0000000..d45ecf6 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/21_person.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/22_relate.png b/wiki/화면 점검/2026-10-26 개편 후/22_relate.png new file mode 100644 index 0000000..fac8b69 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/22_relate.png differ diff --git a/wiki/화면 점검/2026-10-26 개편 후/23_chat_room.png b/wiki/화면 점검/2026-10-26 개편 후/23_chat_room.png new file mode 100644 index 0000000..823fff7 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 개편 후/23_chat_room.png differ