diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a7b80a..e429f89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,23 @@ jobs: END{pct=h*100/t; printf "라인 커버리지 %.1f%% (%d/%d)\n", pct, h, t; exit (pct>=12)?0:1}' \ coverage/lcov.info | tee -a "$GITHUB_STEP_SUMMARY" + + # `dart:io` 금지 게이트 — 웹에서 컴파일은 되지만 Platform.isIOS 등이 런타임에 + # UnsupportedError 를 던진다(빌드로는 안 잡히고 흰 화면으로만 드러남). + # 플랫폼 판별은 lib/utils/platform_info.dart, 파일 조작은 조건부 import 파사드로. + - name: dart:io 직접 사용 금지 + shell: bash + run: | + hits=$(git ls-files | grep -E '^lib/.*\.dart$' \ + | grep -v '^lib/utils/temp_file_io\.dart$' \ + | xargs grep -ln "^import 'dart:io'" || true) + if [ -n "$hits" ]; then + echo "dart:io 를 직접 import 한 파일 — docs/web-port.md 참고:" + echo "$hits" + exit 1 + fi + + # 웹 빌드 게이트 — 앱 쪽 변경이 웹 번들을 조용히 깨는 것을 막는다. + # (런타임 회귀까지는 못 잡는다. 위 dart:io 게이트가 그 몫을 일부 담당.) + - name: 웹 빌드 + run: flutter build web --release --no-wasm-dry-run diff --git a/assets/terms/lite_review_terms.md b/assets/terms/lite_review_terms.md new file mode 100644 index 0000000..011cc50 --- /dev/null +++ b/assets/terms/lite_review_terms.md @@ -0,0 +1,86 @@ +# 간이 후기 이용조건 및 개인정보 수집·이용 동의 + +**시행일: 2026-07-27** + +이병현 (이하 "회사")이 제공하는 PawMate 의 **간이 후기** 기능에 적용되는 조건입니다. +회원가입 절차 없이 휴대전화번호 인증만으로 업체 방문 후기를 남기려는 분은 이 문서에 +동의한 뒤 이용할 수 있습니다. + +--- + +## 1. 간이 후기란 + +"간이 후기"란 아이디·비밀번호·닉네임 등록 등 회원가입 절차 없이, **휴대전화번호 +인증만으로** 업체 방문 후기를 작성하는 기능을 말합니다. + +간이 후기 이용자는 「서비스 이용약관」상 **비회원**에 해당하며, 회원으로 가입한 것이 +아닙니다. + +## 2. 이용 범위 — 후기 작성만 가능합니다 + +간이 후기 인증으로 이용할 수 있는 기능은 **업체 방문 후기의 작성 하나뿐**입니다. +커뮤니티 게시글 작성, 채팅, 반려동물 등록, 이웃 맺기 등 그 밖의 서비스는 이용할 수 +없으며, 이용하려면 정식 회원가입이 필요합니다. + +또한 간이 후기 인증은 **후기 1건마다** 새로 받아야 합니다. 인증으로 발급된 이용 자격은 +약 15분간만 유지되며 기기에 저장되지 않습니다. 앱이나 브라우저를 다시 열면 인증 기록이 +남지 않습니다. + +## 3. 개인정보 수집·이용 동의 + +| 구분 | 내용 | +|---|---| +| 수집 항목 | 휴대전화번호 (필수) | +| 수집 방법 | 이용자가 후기 작성 화면에서 직접 입력 | +| 이용 목적 | ① 후기 작성자 본인 확인 및 식별
② 중복·허위 후기 등 부정 이용 방지
③ **정식 회원 가입 시 기존 간이 후기의 계정 연결** | +| 보유·이용 기간 | 작성한 후기가 게시되어 있는 동안. 후기를 모두 삭제하거나 파기를 요청하면 지체 없이 파기합니다 | + +이용자는 이 동의를 거부할 수 있습니다. 다만 휴대전화번호는 후기 작성자를 식별하는 +유일한 수단이므로, 동의하지 않으면 간이 후기를 이용할 수 없습니다. 이 경우 정식 +회원가입을 통해 후기를 작성할 수 있습니다. + +## 4. 작성자 표시 방식 + +간이 후기의 작성자는 휴대전화번호 전체가 아니라 **일부만 가린 형태**로 표시됩니다. + +- 표시 예: `***-1***-**78` +- 앞 3자리(통신사 식별번호)는 전부 가리고, 가운데 4자리 중 첫 자리와 마지막 2자리만 + 표시합니다. + +이 표시는 본인이 자신의 후기를 알아볼 수 있도록 하기 위한 것이며, 회사는 후기 화면에 +휴대전화번호 전체를 공개하지 않습니다. + +## 5. 정식 회원 전환 시 후기 연결 + +간이 후기에 사용한 것과 **동일한 휴대전화번호**로 정식 회원가입을 하면, 기존에 작성한 +간이 후기가 그 회원 계정으로 자동 연결됩니다. 연결 후에는 다음과 같이 바뀝니다. + +- 작성자 표시가 가려진 번호에서 **회원 닉네임**으로 바뀝니다. +- 같은 업체에 남긴 후기의 방문 회차가 이어집니다. +- 회원 기능(수정·삭제 등)을 그대로 이용할 수 있습니다. + +이는 별도의 계정을 합치는 것이 아니라, 처음부터 하나였던 이용자 정보에 회원 정보를 +추가하는 것입니다. + +## 6. 후기의 관리 + +- 작성한 후기의 수정·삭제는 동일한 휴대전화번호로 인증하거나 정식 회원으로 전환한 뒤 + 이용할 수 있습니다. +- 후기가 「서비스 이용약관」의 게시물 운영 기준(허위·비방·광고 등)에 어긋나는 경우 + 회사는 해당 후기를 숨기거나 삭제할 수 있습니다. +- 후기 삭제·개인정보 파기 요청은 아래 문의처로 접수할 수 있습니다. + +## 7. 그 밖의 사항 + +이 문서에 정하지 않은 사항은 「서비스 이용약관」과 「개인정보 처리방침」을 따릅니다. +간이 후기 이용자에게도 「개인정보 처리방침」의 이용자 권리(열람·정정·삭제·처리정지 +요구권) 규정이 동일하게 적용됩니다. + +--- + +**문의처** + +- 개인정보 보호책임자: 이병현 +- 이메일: 8520lbh@naver.com + +이 조건은 2026-07-27부터 시행합니다. diff --git a/docs/web-port.md b/docs/web-port.md new file mode 100644 index 0000000..e2cf510 --- /dev/null +++ b/docs/web-port.md @@ -0,0 +1,445 @@ +# 웹 이식 계획 (Flutter Web) + +## 목적 + +웹은 **앱 유입 퍼널**이다. 독립된 제품이 아니다. + +- 웹이 하는 일 — 커뮤니티 게시글·후기를 **읽게** 하고, 회원가입까지 받는다 +- 웹이 하지 않는 일 — 지도, 동네 인증, 글쓰기, 채팅, 펫 신원 인증 + → 전부 "앱에서 계속하기"로 유도한다 + +따라서 웹의 성공 지표는 체류가 아니라 **앱 설치 전환**이다. 기능을 웹으로 +끌어오고 싶은 요구가 생기면 이 문서의 목적을 먼저 다시 본다. + +## 결정 1 — 단일 코드베이스, 포크 금지 + +디자인을 앱과 100% 동일하게 유지하는 유일한 방법이다. 같은 `lib/` 를 web +타깃으로 빌드하면 같은 위젯 트리·같은 `theme/app_theme.dart`·같은 `motion/` +스프링이 그대로 돌아간다. + +규칙: + +- 웹 전용 화면을 새로 만들지 않는다. 분기는 `kIsWeb` 과 조건부 import 만 +- 렌더러는 **CanvasKit 고정**. HTML 렌더러는 `ImageFiltered` 블러(11개 파일)· + 그림자·폰트 메트릭이 앱과 달라진다 +- 지도·지역 인증 코드는 **삭제하지 않는다**. 진입점만 웹에서 차단하고 코드는 + 앱 빌드와 계속 공유한다 +- 데스크톱에서 바뀌는 것은 **크롬(내비게이션)뿐**이다 — 결정 5 참고. 본문의 + 반응형 재배치(다단 피드 등)는 곧 디자인이 갈라진다는 뜻이므로 하지 않는다 + +## 결정 2 — 웹 탭 구성 (5탭 노출, 2개는 앱 유도) + +| 탭 | 웹 | +|---|---| +| 커뮤니티 | ✅ | +| 유저 검색 | ✅ | +| 마이 | ✅ | +| 지도 | 🔒 아이콘은 보이고, 누르면 설치 안내 | +| 채팅 | 🔒 아이콘은 보이고, 누르면 설치 안내 | + +처음엔 지도·채팅을 **숨겨서** 3탭으로 갔는데 뒤집었다 — 숨기면 앱에서 뭘 더 할 +수 있는지 알 길이 없어 유입으로 이어지지 않는다. **보여주고 누르면 권한다.** + +구현(`main_screen.dart`): + +- `visibleTabs` = 아이콘으로 노출할 탭(5개, 플랫폼 무관) +- `appOnlyTabs` = 웹에서 `{지도, 채팅}` — 누르면 `AppInviteDialog`, 화면 전환 + 없음(알약도 안 움직인다). 내용 위젯도 만들지 않는다(웹 구현 없는 지도 + 플랫폼뷰가 붙는 것 방지) +- `contentTabs` = 실제 화면이 있는 탭. `IndexedStack` 은 이쪽 인덱스를 쓴다 +- 딥링크(`tabRequest`)가 앱 전용 탭을 가리키면 조용히 무시한다 — 사용자가 누른 + 것이 아니므로 안내를 띄우지 않는다 + +`AppInviteDialog` 는 `AuthWallDialog` 와 같은 시각 언어다. 스토어 주소 +(`Env.storeUrlIos`/`storeUrlAndroid`, `--dart-define`)가 **비어 있으면 — 출시 전 +기본값 — 설치 버튼 대신 "출시 준비 중" 안내만** 보여준다(죽은 링크 방지). +share-view Edge Function 의 `STORE_URL_*` 와 같은 값을 넣으면 된다. + +## 결정 3 — 게스트 경계 + +앱에 이미 있는 게스트 모드(`MainScreen(isGuest: true)` + `AuthWallDialog`)를 +그대로 쓴다. 경계도 앱과 동일하다. + +| 게스트 허용 | 앱 유도 | +|---|---| +| 커뮤니티 피드 | 댓글 작성 | +| 게시글 상세 본문·사진 | 하트 / pawing | +| 댓글 읽기 | 채팅 시작 | +| 후기 목록·상세 | 글쓰기 | +| 회원가입 · 로그인 | | + +상세 열람까지 자유롭게 두는 이유: 공유 링크로 들어온 사람이 내용을 다 보고 +설득되는 구조라야 전환이 난다. 목록에서 막으면 그냥 이탈한다. + +웹에서 `AuthWallDialog` 는 **`AppInviteDialog` 로 대체**한다 — "로그인 할래요 / +나중에 할래요" 대신 "앱에서 열기(스토어) / 로그인 / 나중에". 로그인은 웹에서도 +되므로 선택지에서 빼지 않는다. + +로그인한 사용자는 웹에서도 정상 동작한다. 단 위 표의 '앱 전용' 기능(채팅· +글쓰기·지도·동네 인증)은 로그인 여부와 무관하게 앱으로 유도한다. + +## 결정 4 — 커뮤니티 피드 폴백 (C-a) — **서버 작업 불필요** + +계획 단계에서는 "웹 사용자는 `region_code` 가 없어 피드가 빈다"고 보고 pmdb 에 +`feed_public` RPC 신설을 잡았다. **실제로 확인해보니 이미 폴백이 동작한다.** + +`public.feed_region_codes()` 는 아래 중 하나라도 해당하면 `NULL` 을 반환한다: + +- `activity_radius_m` 이 null +- `is_location_verified` 이 false +- `latitude` 가 null + +게스트와 웹 신규 가입자는 전부 여기 걸려 `NULL` 이고, +`community_repository.dart` 의 `fetchFeed` 는 `codes == null` 이면 지역 필터를 +아예 걸지 않는다(빈 배열 `[]` 일 때만 빈 목록을 반환한다). 결과는 전국 피드다. + +즉 C-a 는 **클라이언트·서버 모두 무변경**으로 이미 만족한다. 실제 브라우저에서 +게스트로 진입해 전국 게시글이 나오는 것을 확인했다. + +남은 선택지는 정렬뿐이다 — 현재는 `created_at desc`(최신순 전국)이다. 인기·최근 +혼합이 첫인상에 낫지만, 이건 웹 전용이 아니라 앱의 미인증 사용자에게도 똑같이 +적용되는 기존 동작이므로 별도 과제로 둔다. + +## 결정 5 — 데스크톱은 "크롬만" 분리 ✅ 구현됨 + +넓은 화면에서 모바일 레이아웃을 그대로 늘리면 게시글 카드가 1300px 로 퍼져 +앱과 완전히 다른 비율이 된다. 그렇다고 데스크톱 전용 반응형(다단 피드·우측 +상세 패널)으로 가면 화면마다 두 벌을 영구히 유지해야 한다. + +**중간을 택한다 — 본문은 그대로, 내비게이션 위치만 바꾼다.** + +| | < 900px (모바일 브라우저) | ≥ 900px (데스크톱) | +|---|---|---| +| 내비게이션 | 하단 플로팅 바 | 좌측 플로팅 레일 | +| 본문 컬럼 | 화면 폭 (460 초과 시 460) | 460 중앙 정렬 | +| 카드·타이포·모션 | 앱과 동일 | 앱과 동일 | + +구현 지점: + +- `lib/utils/layout.dart` — `kContentMaxWidth`(460), `useSideNav`, + `useContentColumn`, `bottomNavClearance`. **전부 웹에서만 참이다** — 네이티브 + 앱(아이패드 포함)의 현재 레이아웃은 이 작업 범위가 아니라 건드리지 않는다 +- `lib/motion/springy_nav_bar.dart` — `axis` 추가. 세로 레일은 **별도 위젯이 + 아니라 같은 위젯**이다. 스프링·스쿼시·근접 보간이 전부 같은 코드를 타므로 + 두 형태가 영원히 같은 모션 언어를 유지한다(알약만 90° 돌아 진행 축을 따른다) +- `lib/widgets/app_shell.dart` — `MaterialApp.builder` 에 꽂히는 셸. 레일이 + 본문 컬럼 **바깥**에 있어야 하고 상세 라우트가 얹혀도 유지돼야 해서 + Navigator 바깥에 둔다. `MainScreen` 은 탭 상태만 `navRail` 로 공개한다 +- `bottomNavClearance` — 레일을 쓰면 0. 데스크톱에서 목록 끝에 하단 바용 + 빈 공간이 남던 것을 막는다 + +미구현 — 레일 하단의 "앱으로 보기" CTA. 스토어 등록 전이라 링크가 없어 +Phase C 로 미룬다. + +### 셸이 Navigator 바깥에 있어서 생기는 함정 두 가지 (배포 후 발견) + +컬럼이 Navigator를 **옮기고 좁히기** 때문에, 라우트 안에서 보는 좌표와 크기가 +창의 그것과 달라진다. 둘 다 데스크톱에서만, **창이 넓을수록 더 크게** 어긋난다. + +1. **좌표** — 모프의 `originRect` 는 `localToGlobal`(창 좌표)로 잡는데 라우트는 + 컬럼 좌표계다. 보정 없이 쓰면 상세가 엉뚱한 자리(거의 화면 밖)에서 펼쳐진다. + → 규약을 "`originRect` 는 언제나 창 좌표"로 못박고, 소비 지점 + (`CollapsibleView`·`ExpandRoute`)에서 `toRouteRect()` 로 변환한다. 캡처 지점이 + 18곳이라 소비 쪽 3곳에서 잡는 편이 안전하다. `riseOriginRect` 는 MediaQuery + 기준이라 반대로 `shellOrigin()` 을 더해 규약에 맞춘다. + +2. **크기** — `ConstrainedBox` 는 MediaQuery 를 바꾸지 않아 컬럼 안에서도 + `MediaQuery.size` 가 창 크기로 보고된다. 모프의 도착 사각형이 창 폭으로 + 잡혀 어긋난다. → 컬럼 안에서 MediaQuery 를 컬럼 크기로 덮어쓴다. + + ⚠️ 그런데 이걸 덮으면 **크롬 구성 판단까지 오염된다** — `useSideNav` 가 + 컬럼 폭(460)을 보고 "좁은 화면"으로 오판해 **좌측 레일과 하단 바가 동시에** + 나온다. 그래서 창 크기는 `ShellMetrics`(InheritedWidget)로 따로 내려보내고, + `useSideNav`/`useContentColumn` 은 그 값을 우선 본다. + +## 결정 6 — 공유 링크는 share-view 유지 + 웹앱 CTA 추가 ✅ 구현됨 + +`go.pawmate.kr/s?t=` 을 웹앱으로 **대체하지 않는다**. 지금 페이지는 +빈 껍데기가 아니라 서버 렌더링 퍼널 페이지이고, 대체하면 잃는 게 크다: + +| 잃는 것 | 이유 | +|---|---| +| 카톡 링크 미리보기 | Flutter 웹은 클라이언트 렌더링 — 크롤러는 빈 셸만 받는다. 공유 기반 유입에서 가장 치명적 | +| 즉시 페인트 | 서버 HTML(JS·외부 리소스 0) → CanvasKit 약 2MB 선다운로드 | +| 퍼널 계측 | `share_view`/`store_click` 을 RPC 가 원자적으로 기록 중 | + +**게시글 공유는 사람이 열면 웹앱으로 302 를 보낸다**(2026-07-27 변경). 처음엔 +"웹에서 계속 보기" 2차 CTA 로 갔다가, 링크를 누르면 **바로** 웹앱이 뜨는 쪽으로 +바꿨다 — 한 번 더 누르게 하는 단계를 없앤다. + +동작: `share-view` 가 User-Agent 로 갈라 + +- **크롤러** → 지금까지의 서버 렌더링 HTML(OG 태그 포함) → **링크 미리보기 유지** +- **사람** → `app.pawmate.kr/p/` 로 302 +- `WEB_APP_URL` 미설정이면 모두 서버 렌더링(스위치) + +⚠️ **인앱 브라우저를 크롤러로 오인하면 안 된다.** 카카오톡은 스크래퍼가 +`kakaotalk-scrap/1.0`, 인앱 브라우저(사람)가 `… KAKAOTALK 10.x.x` 다. +`kakaotalk` 로 뭉뚱그리면 **한국에서 이 링크를 여는 가장 흔한 경로**가 전부 +서버 렌더링에 갇힌다. 네이버(앱 `NAVER` / 크롤러 `Yeti`)·다음(앱 `Daum` / +크롤러 `daumoa`)도 같다. 판정 오류의 손해가 비대칭이라(사람→크롤러 오인은 무해, +크롤러→사람 오인은 미리보기 사망) 일반 봇 토큰도 넓게 받는다. + +감수한 비용: 즉시 표시 → CanvasKit 2MB 동안 스플래시 3~4초. 주소창이 +`app.pawmate.kr` 로 바뀐다. + +### 폐기한 방법 — 같은 주소에서 웹앱 셸 서빙 (시도했다가 되돌림) + +`go.pawmate.kr/s` 에서 웹앱 `index.html` 을 `` 만 웹앱 절대주소로 +바꿔 내려주는 방법을 먼저 구현했다. 셸·OG·`` 주입까지 +정상이었지만 **브라우저에서 앱이 시작하자마자 죽었다**: + +``` +SecurityError: Failed to execute 'replaceState' on 'History': +URL 'https://app.pawmate.kr/s?…' cannot be created in a document +with origin 'https://go.pawmate.kr' +``` + +Flutter 라우터가 히스토리를 조작할 때 교차 출처 base 를 쓰면 브라우저가 막는다. +서비스워커 등록도 같은 이유로 실패한다. `flutter.js` 에는 에셋 기준(assetBase)을 +base href 와 분리하는 설정이 **없어**(모두 `document.baseURI` 기준) 우회 불가다. +같은 주소를 유지하려면 Worker 가 앱 전체를 동일 출처로 프록시해야 하는데 비용 +대비 이득이 작아 302 로 갔다. + +**케어리포트·업체 미리보기는 이 경로를 쓰지 않는다.** 그쪽 수신자는 '내 아이 +기록을 받을 보호자' 한 명이라 웹 탐색이 아니라 **앱 설치·계정 연결**(전화번호 +대조 자동 연결)이 목적이고, 그 연결은 앱에만 있다. 웹앱으로 보내면 "로그인해도 +내 기록이 안 보이는" 상태가 된다. + +**토큰이 아니라 게시글 id 로 넘기는 이유**: `share_view_load` 는 service_role +전용이라(`revoke execute … from anon, authenticated`) 웹앱(anon)이 토큰을 못 +읽는다. 새 anon RPC 를 파는 대신 이미 서버가 아는 post id 를 응답에 실었다. +노출 확대가 아니다 — 게시글은 `v_post_feed` 로 이미 anon 열람 가능하다 +(비로그인 둘러보기와 같은 경로). + +구현 지점: + +- **pmdb** `migrations/20260726120000_share_view_post_id.sql` — `share_view_load` + 의 post 분기에 `'id', p.id` 추가. 권한 변경 없음 +- **pmdb** `functions/share-view/index.ts` — `WEB_APP_URL` 환경변수. **미설정이면 + CTA 를 아예 그리지 않는다**(웹앱 배포 전 죽은 링크 방지 스위치) +- **pmdart** `lib/utils/web_link.dart` — 진입 URL `/p/` 파싱 +- **pmdart** `lib/main.dart` `_openInitialWebLink()` — 게시글을 열되, 비로그인이면 + 웰컴 대신 **게스트 메인을 깔고 그 위에** 상세를 얹는다(닫으면 커뮤니티 피드가 + 나와야 둘러보기로 이어진다. 웰컴으로 되돌아가면 거기서 끊긴다). + `openFromPush` 를 안 쓰는 이유는 그쪽이 로그인 전용이기 때문 +- **pmdart** `web/_redirects` — SPA 폴백(`/* /index.html 200`). 없으면 `/p/` + 직접 진입·새로고침이 404 + +배포 순서 (전부 웹앱 배포 이후): + +1. 마이그레이션 적용 → `./scripts/dump_schema.sh` 로 스냅샷 갱신·커밋 +2. `supabase functions deploy share-view` +3. Edge Function 시크릿에 `WEB_APP_URL=https://app.pawmate.kr` 설정 → 이 시점에 + CTA 가 나타난다 + +## 결정 7 — 웹 세션은 탭 단위·8시간 상한 ✅ 구현됨 + +### 먼저, 실제 토큰 모델 (계획 단계의 서술이 틀렸다) + +계획에는 "무상태 JWT exp 30일"이라고 적었는데 **옛날 얘기**였다. refresh-token +phase 2 이후 실제 모델은: + +| | 값 | +|---|---| +| access TTL | **8시간** (`ACCESS_TTL_CAPABLE`) | +| access TTL (레거시) | 30일 — `x-client-refresh:1` 헤더를 **안 보낼 때만** | +| refresh | 슬라이딩 30일 / 절대 90일, 회전형 | +| 탈취 감지 | `rt_rotate` 재사용 감지 시 family 전체 회수(`reuse_revoked`) | + +⚠️ **로그인 요청의 `x-client-refresh:1` 헤더를 빼면 안 된다** — 서버가 레거시 +클라로 보고 30일짜리 access 를 발급해 오히려 나빠진다(`login/index.ts`). + +### 정책 + +httpOnly 쿠키 이관(원래 계획)은 하지 않는다. 로그인 경로 자체를 바꾸는 작업이라 +검증 부담이 큰 데 비해, XSS 가 페이지 안에서 그대로 요청하는 건 어차피 못 막는다. +웹은 **앱 유입 퍼널**이므로 세션을 짧게 끊는 쪽이 맞는 교환이다. + +| | 앱(네이티브) | 웹 | +|---|---|---| +| access | secure storage | **sessionStorage**(탭 닫으면 소멸) | +| user | SharedPreferences | sessionStorage | +| refresh | secure storage, 회전 | **영속화 안 함**, 갱신에도 안 씀 | +| 세션 상한 | 30일(회전) | **8시간** | + +refresh 를 메모리에는 들고 있는다 — 로그아웃 시 서버 family 회수(`logout` 엣지)에 +필요하기 때문이다. 갱신에는 쓰지 않는다(`isAccessExpiringSoon` 이 웹에서 항상 +false, `_doRefresh` 도 즉시 반환). + +구현: `lib/services/session_store.dart`(조건부 export) + `_io`/`_web`, +`session.dart` 는 `_store` 를 통해서만 접근. 서버 변경 없음. + +`flutter_secure_storage` 를 웹에서 안 쓰는 이유: 웹 구현이 localStorage + AES 인데 +복호화 키도 같은 localStorage 에 있어 사실상 난독화다. + +## 작업 단계 + +### Phase A — 웹에서 정상 기동 ✅ 완료 + +계획 단계의 전제("`flutter build web` 이 컴파일에서 깨진다")는 **틀렸다**. +빌드는 손대기 전에도 통과했다. `dart:io` 는 웹에서 컴파일되는 스텁이고, +`flutter_naver_map`·`flutter_local_notifications` 등도 Dart 코드는 멀쩡히 +컴파일된다. **문제는 전부 런타임이었다.** + +실제 증상 — 브라우저에서 흰 화면: + +``` +DartError: MissingPluginException(No implementation found for +method initializeNcp on channel flutter_naver_map_sdk) +``` + +`main()` 의 네이버 지도 초기화가 `runApp` **이전에** 던져서 앱이 아예 뜨지 않았다. +빌드 게이트로는 절대 못 잡는 종류다. + +**A1. `dart:io` 제거 (3파일)** — `lib/utils/platform_info.dart` 신설 + +- `services/push_service.dart` — `Platform.isIOS` → `isIOS` +- `services/local_notice_service.dart` — `Platform.isAndroid` → `isAndroid` +- `screen/pet_identity_enroll_screen.dart` — `File(video.path).readAsBytes()` + → `XFile.readAsBytes()`, 임시파일 삭제는 `utils/temp_file.dart` 조건부 import + +**A2. 네이티브 전용 플러그인** — 파사드는 필요 없었다. 컴파일은 되므로 +`kIsWeb` 가드로 **호출만** 막으면 된다. + +| 플러그인 | 처리 | +|---|---| +| `flutter_naver_map` | `main.dart` 초기화를 `if (!kIsWeb)` 로 감쌈. 지도 탭은 웹에서 생성 자체를 안 함 | +| `firebase_messaging` | `main.dart` 에서 웹이면 `_setupPush()` 를 건너뜀 | +| `flutter_local_notifications` | `isAndroid` 가 false → `init()` 이 즉시 반환 | +| `video_thumbnail` | `storage_service` 포스터 생성을 `kIsWeb` 이면 생략 | + +⚠️ **A2 에서 놓쳤던 것 — 네이티브 싱글턴의 필드 초기화** + +`main()` 에서 `_setupPush()` 만 건너뛰면 충분하다고 봤는데 아니었다. +`PushService` 는 `final FirebaseMessaging _fm = FirebaseMessaging.instance;` 를 +**인스턴스 필드**로 갖고 있어서, `PushService.instance` 를 처음 참조하는 순간 +Firebase 가 없다며 `[core/no-app]` 로 던진다. 그 참조가 로그인 성공 직후 +(`auth_service.dart` 의 `registerToken()`)와 로그아웃(`clearToken()`)에 있었다. + +증상이 고약했다 — 세션은 이미 저장된 뒤라 **"로그인 버튼은 눌리는데 로그인이 +안 된다"**(에러 토스트만 뜨고, 새로고침하면 로그인돼 있음). 게스트 둘러보기만 +확인해서는 절대 안 잡힌다. + +교훈: `kIsWeb` 가드는 **초기화 지점이 아니라 공개 진입점마다** 걸어야 하고, +네이티브 플러그인 핸들은 필드가 아니라 **게터**로 둬야 한다(참조 시점 지연). + +**A3. 웹 진입점 차단** + +- `main_screen.dart` — `visibleTabs` 도입. 탭 상수는 정체성으로 고정하고 표시 + 순서만 플랫폼별로. 딥링크(`tabRequest`)는 상수로 요청 → 없는 탭이면 무시 +- `community_tab.dart` 글쓰기 FAB — 웹 미노출 +- `profile_edit_screen.dart` 지역 재인증 진입 — 웹에서 `onTap: null` + +**A4. CI 게이트** — `.github/workflows/ci.yml` 에 둘 다 추가: + +- `flutter build web --release` — 번들이 깨지는 것 방지 +- **`dart:io` 직접 import 금지 grep** — 빌드로는 못 잡는 런타임 회귀를 막는 + 실질적인 게이트. `lib/utils/temp_file_io.dart` 만 예외 + +**검증** — `flutter analyze` 무결점, `flutter test` 154개 통과, +릴리스 웹 번들을 실제 Chrome 에서 기동해 웰컴 화면 → 게스트 진입 → +커뮤니티 피드(실데이터)·3탭 내비까지 확인. + +### Phase B — 웹 고유 문제 + +- **B1. 토큰 저장** ✅ 완료 — 결정 7 참고. `flutter_secure_storage` 를 웹에서 + 쓰지 않게 되면서, `dart:html`·`package:js` 때문에 **wasm 빌드를 막던 유일한 + 의존성**도 웹 경로에서 빠졌다(wasm 전환 여지가 열렸다 — 별도 과제) +- **B2. juso CORS** — `juso_service.dart` 의 직접 호출은 브라우저에서 막힌다. + Worker `/api/juso` 프록시 경유. (업체등록은 웹 범위 밖이라 우선순위 낮음) +- **B3. URL 라우팅** — 최소 `/post/:id`, `/u/:id`. 기존 `AppPageRoute` 모션을 + 깨지 않으려면 `Router` 전면 도입 대신 `onGenerateRoute` 로 간다 +- ~~**B4. 데스크톱 셸**~~ ✅ 완료 — 결정 5 참고 +- **B5. 스크롤 동작** ✅ 완료 — `widgets/app_scroll_behavior.dart`(웹 전용): + 데스크톱 스크롤바 제거(앱에 없는 요소) + `dragDevices` 에 마우스·트랙패드 추가. + 후자가 중요하다 — 기본값은 터치·스타일러스뿐이라 **가로 목록(커뮤니티 카테고리 + 칩)이 마우스만 있는 PC 에서 도달 불가**였다(뒤쪽 '입양'·'자유'를 못 씀). + 오버스크롤 물리는 건드리지 않았다(관측된 문제 없음) +- ~~**B6. 초기 로딩**~~ ✅ 부분 완료 — `web/index.html` 정비(제목·OG·theme-color), + 앱 배경색 스플래시 + `flutter-first-frame` 에 제거, 아이콘 5종을 앱 아이콘으로 + 재생성(전부 Flutter 기본 로고였다), `manifest.json` 정비 +- **B7. 한글 두부(tofu)** ✅ 완화 완료 — **폰트 번들은 하지 않는다** + + 증상: 첫 로드에서 몇 초간 모든 한글이 □로 보였다. CanvasKit 은 CJK 글리프를 + 번들하지 않고, **첫 프레임을 그린 뒤에야** 빠진 글자를 발견해 Noto Sans KR 을 + 받는다. localhost 에서는 빨라 안 보였고 실제 도메인에서 드러났다. + + 처음엔 한글 폰트 번들로 잡으려 했는데, **실측해보니 처방이 틀렸다**: + + | 항목 | 실측 | + |---|---| + | 폴백으로 받는 Noto Sans KR | **32KB** (unicode-range 조각 4개) | + | 번들할 경우(전체 한글 음절 서브셋) | **1.15MB × 굵기 수** (앱은 w400~w800 사용) | + | `canvaskit.wasm` (gstatic, 자체호스팅 전) | **5.3MB** | + + 32KB 를 없애자고 2~4MB 를 첫 프레임 앞에 세우는 꼴이라 오히려 손해다. 진짜 + 원인은 용량이 아니라 **순서와 연결 비용**이었다. 그래서: + + - `web/index.html` 에 `` + — 폰트를 받을 시점에 DNS+TLS 부터 시작하던 것을 미리 끝내 둔다 + - **CanvasKit 자체 호스팅** — 빌드에 `--no-web-resources-cdn`. 같은 오리진에서 + Cloudflare 가 brotli 로 **2.0MB** 만 보낸다(gstatic 원본 5.4MB). 서드파티 + 의존도 사라진다. **이 플래그는 배포 때마다 필요하다** + + 콜드 로드 실측(적용 후): `main.dart.js` 0.74→2.6s, `canvaskit.wasm` 0.74→2.4s, + 폰트 요청 3.7s 시작·**7ms 만에 완료**. 두부 구간이 수 초 → 100ms 대로 줄어 + 사실상 보이지 않는다. + + 남은 여지(필요해지면): 완전 제거를 원하면 스플래시를 첫 프레임 후 ~250ms 더 + 유지해 폰트 도착을 덮으면 된다. 다만 재방문(폰트 캐시됨)에는 순수 지연이라 + 지금은 넣지 않았다. `google_fonts` 의 Baloo2(웰컴 로고)도 런타임 다운로드지만 + 같은 preconnect 로 덮인다 + +### Phase C — 앱 유도 UI + +- `AppInviteDialog` — 스토어 링크(iOS/Android 분기) + 로그인 + 나중에 +- 채팅 탭 자리·글쓰기 FAB 등 앱 전용 기능 진입 시 동일 다이얼로그 +- 상단 스마트 배너(선택) — 모바일 브라우저에서만 + +### Phase D — 선택 + +- FCM 웹 푸시(서비스워커 + VAPID). iOS Safari 는 PWA 설치 상태에서만 수신 +- PWA `manifest.json` 정비 + +### Phase E — 배포 ✅ 완료 (2026-07-26) + +**https://app.pawmate.kr** (= `pawmate-web.pages.dev`) + +- Cloudflare Pages 프로젝트 `pawmate-web`. 배포: + + ``` + flutter build web --release --no-web-resources-cdn + find build/web/canvaskit -name '*.symbols' -delete # 런타임 미사용, 8MB 절감 + npx wrangler pages deploy build/web --project-name pawmate-web --branch main + ``` + + `--no-web-resources-cdn` 는 선택이 아니다 — 빼면 CanvasKit 이 gstatic 에서 + 오면서 한글 두부 구간이 다시 길어진다(B7) +- `go.pawmate.kr/s` 의 share-proxy Worker 는 건드리지 않았다(경로 충돌 없음) +- 커스텀 도메인 함정: **wrangler CLI 에는 Pages 커스텀 도메인 명령이 없고**, + wrangler OAuth 토큰은 `zone(read)` 뿐이라 **DNS 레코드를 만들 수 없다**. + 도메인 등록은 API(`POST /pages/projects/

/domains`)로 되지만 DNS 는 대시보드에서 + 사람이 넣어야 한다 — `CNAME app → pawmate-web.pages.dev`, **Proxied(주황)**. + API 로 등록하면 대시보드 마법사의 DNS 자동 생성 단계가 건너뛰어져 행에 버튼이 + 안 생긴다. 전파 중 몇 분간 522 가 나는 것은 정상. +- 검증: `/`·`/p/`·임의 경로 전부 200(SPA 폴백 동작), + `app.pawmate.kr` 전용 인증서 발급(Google Trust Services), OG 메타 서빙 확인 + +남은 것: + +- GitHub Actions 자동 배포(현재는 수동) + `--dart-define` 주입 +- CSP — CanvasKit 이 wasm 을 쓰므로 `script-src 'wasm-unsafe-eval'` 필요. + Pages 는 기본 CSP 가 없어 지금도 동작하지만 하드닝 대상(`web/_headers`) + +## 웹에서 재현되지 않는 것 + +디자인은 동일하지만 플랫폼상 불가능한 것들이다. 버그로 신고하지 않는다. + +- 상태바가 없다 → `SystemChrome.setSystemUIOverlayStyle`(`main.dart`)과 상태바 + 스크림 페이드는 웹에서 무의미. 상단 세이프에어리어 계산이 다르다 +- 햅틱 피드백 no-op +- iOS Safari 에서 `ImageFiltered` 블러가 긴 리스트에서 프레임을 떨어뜨릴 수 + 있다 → 필요 시 웹 한정 블러 시그마 하향 스위치 +- 카메라 촬영 인증은 모바일 브라우저만. 데스크톱은 파일 선택 폴백 + (단 촬영 인증이 필요한 글쓰기 자체가 웹 범위 밖이라 실질 영향 없음) diff --git a/lib/env.dart b/lib/env.dart index 5c51d13..339f786 100644 --- a/lib/env.dart +++ b/lib/env.dart @@ -27,6 +27,13 @@ abstract final class Env { defaultValue: 'cy02y6r0d5', ); + /// 스토어 주소 — 웹에서 앱 전용 기능(지도·채팅)을 누르면 여기로 보낸다. + /// **미설정이 기본**이다(출시 전). 비어 있으면 설치 버튼 대신 "출시 준비 중" + /// 안내를 보여준다 — 죽은 링크를 노출하지 않기 위한 스위치. + /// share-view Edge Function 의 STORE_URL_IOS/ANDROID 와 같은 값을 넣는다. + static const storeUrlIos = String.fromEnvironment('STORE_URL_IOS'); + static const storeUrlAndroid = String.fromEnvironment('STORE_URL_ANDROID'); + /// 행안부 juso.go.kr confmKey(클라이언트 키). 위의 값들과 달리 콘솔에서 /// 앱 단위로 제한할 수 없고 쿼터 소진·남용 여지가 있어 **기본값을 두지 않는다** /// — 빌드 시 --dart-define=JUSO_API_KEY=... 로 주입한다. diff --git a/lib/main.dart b/lib/main.dart index 79ad2f0..5facb96 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -9,10 +10,12 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import 'env.dart'; import 'firebase_options.dart'; +import 'models/community.dart' show Post; import 'motion/motion.dart'; import 'screen/activity_screens.dart' show MyReviewsScreen; import 'screen/admin/admin_home_screen.dart'; import 'screen/chat_room_screen.dart'; +import 'screen/facility_review_screen.dart'; import 'screen/guardian_invites_screen.dart'; import 'screen/main_screen.dart'; import 'screen/notifications_screen.dart'; @@ -23,6 +26,7 @@ import 'screen/vaccination_schedule_screen.dart'; import 'screen/welcome_screen.dart'; import 'services/chat_repository.dart'; import 'services/community_repository.dart'; +import 'services/facility_repository.dart'; import 'services/facility_review_repository.dart'; import 'services/keyboard_barrier.dart'; import 'services/local_notice_service.dart'; @@ -32,6 +36,9 @@ import 'services/realtime_service.dart'; import 'services/session.dart'; import 'services/theme_controller.dart'; import 'theme/app_theme.dart'; +import 'utils/web_link.dart'; +import 'widgets/app_scroll_behavior.dart'; +import 'widgets/app_shell.dart'; import 'widgets/app_toast.dart'; import 'widgets/review_cards.dart'; @@ -61,19 +68,23 @@ Future main() async { await ThemeController.load(); // 네이버 지도 SDK 초기화 (NCP Maps 신규 인증 - Client ID) - await FlutterNaverMap().init( - clientId: Env.naverMapClientId, - onAuthFailed: (ex) { - switch (ex) { - case NQuotaExceededException(:final message): - debugPrint('네이버 지도 사용량 초과: $message'); - case NUnauthorizedClientException() || - NClientUnspecifiedException() || - NAnotherAuthFailedException(): - debugPrint('네이버 지도 인증 실패: $ex'); - } - }, - ); + // 웹은 지도 탭 자체가 없다(docs/web-port.md) — 플러그인이 웹 구현을 갖고 있지 + // 않아 여기서 던지면 runApp 전에 죽어 흰 화면이 된다. + if (!kIsWeb) { + await FlutterNaverMap().init( + clientId: Env.naverMapClientId, + onAuthFailed: (ex) { + switch (ex) { + case NQuotaExceededException(:final message): + debugPrint('네이버 지도 사용량 초과: $message'); + case NUnauthorizedClientException() || + NClientUnspecifiedException() || + NAnotherAuthFailedException(): + debugPrint('네이버 지도 인증 실패: $ex'); + } + }, + ); + } // Supabase 연결 // publishableKey(공개 키)만 클라이언트에 둔다. service_role 키는 절대 포함 금지. @@ -91,7 +102,9 @@ Future main() async { if (!s.isRefreshing && s.isAccessExpiringSoon(skew: 60)) { await s.refreshOnce(); } - return s.token; + // 간이 회원(후기 전용) 토큰은 정식 세션이 없을 때만 쓰인다 — 갱신 대상도 + // 아니고 저장되지도 않는다(SessionManager.beginLiteSession 참고). + return s.token ?? s.liteToken; }, ); @@ -110,6 +123,10 @@ Future main() async { runApp(const PawMateApp()); + // 웹은 OS 푸시를 쓰지 않는다 — 서비스워커·VAPID 세팅이 필요하고 iOS Safari 는 + // PWA 설치 상태에서만 수신된다. 웹 푸시는 Phase D(docs/web-port.md). + if (kIsWeb) return; + // OS 푸시(FCM) 초기화는 앱 시작(runApp)을 막지 않도록 백그라운드로. // (init 이 iOS 권한 다이얼로그 응답 대기 + APNs 미설정 getToken 에서 블록될 수 있어, // await 하면 흰 화면에서 멈춘다.) @@ -341,9 +358,148 @@ class _PawMateAppState extends State with WidgetsBindingObserver { // 시작 시 1회 세션 유효성 확인. WidgetsBinding.instance.addPostFrameCallback((_) { SessionManager.instance.checkAliveAndClearIfDead(); + unawaited(_openInitialWebLink()); }); } + /// 공유 링크로 들어온 웹 진입(`/p/`) — 게시글 상세를 바로 연다. + /// + /// `openFromPush` 를 쓰지 않는 이유: 그쪽은 알림 라우팅이라 로그인 사용자 + /// 전용이다. 공유 링크는 **비로그인 열람이 목적**이므로(설치 전 가치, 0028 + /// 원칙 2) 게스트도 통과해야 한다. + /// + /// 비로그인이면 웰컴 화면 대신 게스트 메인을 깔고 그 위에 상세를 얹는다 — + /// 상세를 닫았을 때 커뮤니티 피드가 나와야 둘러보기로 이어진다(웰컴으로 + /// 되돌아가면 거기서 끊긴다). + Future _openInitialWebLink() async { + final postId = initialSharedPostId(); + final userId = initialSharedUserId(); + final reviewFacilityId = initialReviewFacilityId(); + if (postId == null && userId == null && reviewFacilityId == null) return; + + // 네비게이터 준비 대기 — 첫 프레임 직후라 보통 바로 있지만 없으면 잠깐 기다린다 + // (openFromPush 와 같은 방어). + NavigatorState? nav = navigatorKey.currentState; + for (var i = 0; i < 20 && nav == null; i++) { + await Future.delayed(const Duration(milliseconds: 100)); + nav = navigatorKey.currentState; + } + if (nav == null || !mounted) return; + + // 매장 QR(`/r/`) — 그 매장의 후기 작성 화면을 바로 연다. + if (reviewFacilityId != null) { + await _openReviewWrite(nav, reviewFacilityId); + return; + } + + // 업체 프로필(`/u/`). 게시글과 달리 선조회를 하지 않는다: 프로필 화면이 + // 스스로 로드하며 실패 표시까지 처리하고, 여기서 한 번 더 받아봐야 첫 화면만 늦어진다. + if (userId != null) { + _openSharedProfile(nav, userId); + return; + } + // 위에서 userId 를 처리하고 돌아갔으므로 여기 오면 postId 는 반드시 있다. + final sharedPostId = postId!; + + // 콜드 로드에서는 번들·wasm 다운로드와 겹쳐 첫 요청이 일시 실패하곤 한다. + // 한 번 실패했다고 포기하면 공유 링크가 **조용히 피드로 떨어진다**(실제로 + // 간헐 발생을 목격). 예외일 때만 재시도하고, 결과가 null(삭제·비공개)이면 + // 재시도해봐야 소용없으므로 바로 빠진다. + Post? loaded; + var failed = false; + for (var i = 0; i < 3; i++) { + try { + loaded = await CommunityRepository.instance.fetchPost(sharedPostId); + failed = false; + break; + } catch (e) { + failed = true; + debugPrint('공유 링크: 게시글 조회 실패(${i + 1}/3) — $e'); + if (i < 2) await Future.delayed(const Duration(milliseconds: 400)); + } + } + if (!mounted) return; + + // final 로 받아야 아래 클로저에서 널 프로모션이 유지된다(캡처된 지역변수는 + // 승격이 풀려 dart2js 가 거부한다 — analyzer 만으로는 안 잡힌다). + final post = loaded; + if (post == null) { + // 말없이 피드로 떨구지 않는다 — 왜 안 열렸는지 알려준다. + final overlay = nav.overlay; + if (overlay != null) { + AppToast.show( + overlay, + failed ? '게시글을 불러오지 못했어요' : '삭제되었거나 볼 수 없는 게시글이에요', + ); + } + return; + } + + final guest = !SessionManager.instance.isLoggedIn; + if (guest) { + nav.pushAndRemoveUntil( + AppPageRoute(builder: (_) => const MainScreen(isGuest: true)), + (route) => false, + ); + } + // 앱 공통 상세 언어 — 아래에서 떠오르고, 쓸어내리면 닫힌다. + nav.push( + CollapseRoute( + builder: (_) => PostDetailScreen(post: post, isGuest: guest), + ), + ); + } + + /// 매장 QR 로 들어와 그 매장의 후기 작성 화면을 연다. + /// + /// 시설은 업체 계정이 없어도 존재하므로(공공데이터), 인증 전 매장에 뿌린 QR 도 + /// 그대로 동작한다. 비로그인이면 작성 화면이 게시 시점에 간이 인증을 청한다. + /// + /// 뒤로 가면 게스트 메인(커뮤니티)이 남도록 그 위에 얹는다 — 후기를 안 쓰기로 + /// 해도 앱을 둘러보는 쪽으로 이어져야 한다. + Future _openReviewWrite(NavigatorState nav, String facilityId) async { + Facility? facility; + try { + facility = await FacilityRepository.instance.fetchById(facilityId); + } catch (e) { + debugPrint('매장 QR: 시설 조회 실패 — $e'); + } + if (!mounted) return; + + if (!SessionManager.instance.isLoggedIn) { + nav.pushAndRemoveUntil( + AppPageRoute(builder: (_) => const MainScreen(isGuest: true)), + (route) => false, + ); + } + + final f = facility; + if (f == null) { + final overlay = nav.overlay; + if (overlay != null) AppToast.show(overlay, '매장 정보를 불러오지 못했어요'); + return; + } + nav.push(CollapseRoute(builder: (_) => FacilityReviewScreen(facility: f))); + } + + /// 매장 QR 로 들어온 업체 프로필을 연다. + /// + /// 게시글 공유와 같은 이유로 비로그인도 통과시키고, 게스트면 웰컴 대신 게스트 + /// 메인을 깐 뒤 그 위에 얹는다 — 프로필을 닫았을 때 웰컴으로 튕기지 않고 + /// 둘러보기로 이어져야 한다. + /// + /// 업체 얼굴로 연다(forcePersonalFace=false). QR 은 매장에서 나눠주는 물건이라 + /// 맥락이 명백히 업체고, 개인 얼굴로 열면 후기 작성 버튼이 뜨지 않는다. + void _openSharedProfile(NavigatorState nav, String userId) { + if (!SessionManager.instance.isLoggedIn) { + nav.pushAndRemoveUntil( + AppPageRoute(builder: (_) => const MainScreen(isGuest: true)), + (route) => false, + ); + } + nav.push(CollapseRoute(builder: (_) => UserProfileScreen(userId: userId))); + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); @@ -397,6 +553,8 @@ class _PawMateAppState extends State with WidgetsBindingObserver { theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: themeMode, // 설정(내정보 수정 > 화면 테마)에서 변경·저장 + // 웹만: 데스크톱 스크롤바 숨김 + 마우스 드래그 허용(가로 칩 목록 도달). + scrollBehavior: kIsWeb ? const WebScrollBehavior() : null, // 전 화면 공통 키보드 해제: // · 스크롤: 하위 스크롤뷰의 드래그 시작을 받아 해제(알림은 계속 전파). // · 탭: 키보드가 떠 있을 때만 전체 화면에 배리어를 깔아, 화면 탭을 '키보드 닫기' @@ -412,42 +570,47 @@ class _PawMateAppState extends State with WidgetsBindingObserver { : SystemUiOverlayStyle.dark; return AnnotatedRegion( value: overlay, - child: NotificationListener( - onNotification: (n) { - // 세로 스크롤만 키보드를 닫는다 — 가로 스크롤(카테고리 칩 등 캐러셀)은 - // 검색 도중의 필터 조작이므로 키보드·포커스를 유지해야 한다 - // (닫으면 searchActive 가 풀려 칩이 함께 사라지는 문제). - if (n is ScrollStartNotification && - n.dragDetails != null && - n.metrics.axis == Axis.vertical) { - FocusManager.instance.primaryFocus?.unfocus(); - } - return false; - }, - child: ValueListenableBuilder( - valueListenable: keyboardBarrierEnabled, - builder: (_, barrierOn, _) => Stack( - fit: StackFit.expand, - children: [ - child ?? const SizedBox.shrink(), - // 지도 등 자체 처리 화면(barrierOn=false)에서는 배리어를 끈다. - // translucent — 탭은 배리어가 아레나에서 먼저 이겨 '키보드 닫기'로 - // 흡수하되(아래 위젯 안 눌림), 드래그는 아래로 통과해 스크롤이 - // 정상 동작한다(스크롤 시작 시 위 리스너가 키보드를 닫음). - // opaque 였을 때 키보드가 뜬 동안 카테고리 칩 가로 스크롤 등 - // 모든 스크롤이 먹통이 되던 문제 수정. - if (keyboardUp && barrierOn) - Positioned.fill( - // 예외 영역(카테고리 칩 등)은 배리어 히트 자체를 건너뛴다. - child: KeyboardBarrierHitFilter( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => - FocusManager.instance.primaryFocus?.unfocus(), + // 넓은 화면(데스크톱 웹)에서 본문을 폰 폭 컬럼으로 묶고 좌측 레일을 + // 둔다. 좁은 화면·네이티브에서는 통과만 한다(docs/web-port.md). + // Navigator 바깥이라 상세 라우트가 얹혀도 컬럼·레일이 유지된다. + child: AppShell( + child: NotificationListener( + onNotification: (n) { + // 세로 스크롤만 키보드를 닫는다 — 가로 스크롤(카테고리 칩 등 캐러셀)은 + // 검색 도중의 필터 조작이므로 키보드·포커스를 유지해야 한다 + // (닫으면 searchActive 가 풀려 칩이 함께 사라지는 문제). + if (n is ScrollStartNotification && + n.dragDetails != null && + n.metrics.axis == Axis.vertical) { + FocusManager.instance.primaryFocus?.unfocus(); + } + return false; + }, + child: ValueListenableBuilder( + valueListenable: keyboardBarrierEnabled, + builder: (_, barrierOn, _) => Stack( + fit: StackFit.expand, + children: [ + child ?? const SizedBox.shrink(), + // 지도 등 자체 처리 화면(barrierOn=false)에서는 배리어를 끈다. + // translucent — 탭은 배리어가 아레나에서 먼저 이겨 '키보드 닫기'로 + // 흡수하되(아래 위젯 안 눌림), 드래그는 아래로 통과해 스크롤이 + // 정상 동작한다(스크롤 시작 시 위 리스너가 키보드를 닫음). + // opaque 였을 때 키보드가 뜬 동안 카테고리 칩 가로 스크롤 등 + // 모든 스크롤이 먹통이 되던 문제 수정. + if (keyboardUp && barrierOn) + Positioned.fill( + // 예외 영역(카테고리 칩 등)은 배리어 히트 자체를 건너뛴다. + child: KeyboardBarrierHitFilter( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => + FocusManager.instance.primaryFocus?.unfocus(), + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/motion/collapse_route.dart b/lib/motion/collapse_route.dart index f79cd4d..d83464d 100644 --- a/lib/motion/collapse_route.dart +++ b/lib/motion/collapse_route.dart @@ -2,12 +2,21 @@ import 'dart:ui' show lerpDouble; import 'package:flutter/gestures.dart' show VelocityTracker; import 'package:flutter/material.dart'; +import '../utils/layout.dart' show shellOrigin, toRouteRect; + /// 아래에서 위로 떠오르는 모달형 원점 — [CollapsibleView.originRect] 에 넘기면 /// 화면이 하단(오프스크린)에서 슬라이드 업으로 등장하고, 당기거나 뒤로가기 시 /// 아래로 내려가며 닫힌다. 카드 크로스페이드는 생략(card: null). Rect riseOriginRect(BuildContext context) { final size = MediaQuery.sizeOf(context); - return Rect.fromLTWH(0, size.height, size.width, size.height); + // originRect 규약은 '화면 좌표'다(utils/layout.dart toRouteRect 참고). 여기서는 + // MediaQuery(=라우트 크기) 기준으로 만들므로 셸 오프셋을 더해 화면 좌표로 맞춘다. + return Rect.fromLTWH( + 0, + size.height, + size.width, + size.height, + ).shift(shellOrigin()); } /// 투명 통과 라우트 — 아래 화면(피드)이 그대로 비쳐 보인다. @@ -132,10 +141,12 @@ class ExpandRoute extends PageRoute { curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic, ); + // 화면 좌표 → 라우트 좌표(셸이 본문을 옮겨 놓은 만큼 보정). + final rect = toRouteRect(originRect)!; return _OriginExpand( animation: curved, - originRect: originRect, - collapsedRadius: originRadius ?? originRect.height / 2, + originRect: rect, + collapsedRadius: originRadius ?? rect.height / 2, origin: origin, child: child, ); @@ -525,7 +536,7 @@ class _CollapsibleViewState extends State ); if (!_collapsible) return content; - final origin = widget.originRect!; + final origin = toRouteRect(widget.originRect)!; // 화면 좌표 → 라우트 좌표 // card 가 없으면(오프스크린 원점 모달 등) 크로스페이드 없이 콘텐츠만 이동. final cardWidget = widget.card == null ? null @@ -561,7 +572,7 @@ class _CollapsibleViewState extends State /// 크로스페이드(피드 카드와 동일). 게시글 상세와 완전히 동일한 시각 언어. /// [cardWidget] 이 null 이면 크로스페이드 없이 콘텐츠가 불투명하게 이동만 한다. Widget _wrapCollapse(BuildContext context, Widget child, Widget? cardWidget) { - final origin = widget.originRect!; + final origin = toRouteRect(widget.originRect)!; // 화면 좌표 → 라우트 좌표 final size = MediaQuery.of(context).size; final w = size.width; final h = size.height; diff --git a/lib/motion/springy_nav_bar.dart b/lib/motion/springy_nav_bar.dart index 90561c4..d59ce5b 100644 --- a/lib/motion/springy_nav_bar.dart +++ b/lib/motion/springy_nav_bar.dart @@ -18,12 +18,17 @@ class SpringyNavItem { /// - 알약은 이동 방향으로 살짝 늘어났다(스쿼시) 안착해 **방향(direction)** 과 관성을 드러낸다. /// - 선택된 아이콘은 살짝 떠오르고 확대되며, 이동 중인 인접 아이콘도 거리에 따라 /// 부드럽게 반응한다(feedback). +/// +/// [axis] 로 세로(좌측 레일) 구성도 만든다 — 넓은 화면(데스크톱 웹)에서 쓰며, +/// 스프링·스쿼시·근접 보간은 **같은 코드**를 탄다. 바뀌는 건 진행 축뿐이라 +/// 두 형태가 영원히 같은 모션 언어를 유지한다. class SpringyNavBar extends StatefulWidget { const SpringyNavBar({ super.key, required this.currentIndex, required this.onTap, required this.items, + this.axis = Axis.horizontal, this.activeColor, this.inactiveColor, this.pillColor, @@ -33,6 +38,9 @@ class SpringyNavBar extends StatefulWidget { final ValueChanged onTap; final List items; + /// 가로 = 하단 바(기본), 세로 = 좌측 레일. + final Axis axis; + /// null 이면 테마 팔레트([AppPalette])에서 가져온다. final Color? activeColor; final Color? inactiveColor; @@ -70,13 +78,20 @@ class _SpringyNavBarState extends State widget.onTap(i); } + /// 바 두께 — 가로면 높이, 세로면 너비. + static const double _thickness = 62.0; + @override Widget build(BuildContext context) { + final vertical = widget.axis == Axis.vertical; + final n = widget.items.length; final bottomInset = MediaQuery.paddingOf(context).bottom; // 화면 가장자리에서 띄운 둥근 직사각형 바 — 네 모서리 모두 동일 곡률(24, // 상단 헤더 패널과 통일). SafeArea 대신 하단 여백으로 홈 인디케이터를 피한다. return Padding( - padding: EdgeInsets.fromLTRB(12, 0, 12, bottomInset + 8), + padding: vertical + ? const EdgeInsets.fromLTRB(12, 8, 8, 8) + : EdgeInsets.fromLTRB(12, 0, 12, bottomInset + 8), child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( @@ -92,64 +107,85 @@ class _SpringyNavBarState extends State ), ], ), - child: SizedBox( - height: 62, - child: LayoutBuilder( - builder: (context, constraints) { - final n = widget.items.length; - final itemW = constraints.maxWidth / n; - return AnimatedBuilder( - animation: _c, - builder: (context, _) { - final pos = _c.value; // 연속 위치 - // 이동 속도로 알약을 진행 방향으로 늘림(스쿼시 & 스트레치). - final v = _c.velocity; - final stretch = (v.abs() * 0.04).clamp(0.0, 0.22); - // 아이콘 단독 구성 — 알약도 원에 가깝게(앱 전반의 둥근 무드). - const pillW = 52.0; - const pillH = 42.0; - final pillCenterX = (pos + 0.5) * itemW; - - final pill = - widget.pillColor ?? - context.colors.primaryDark.withValues(alpha: 0.08); - return Stack( - children: [ - // 흐르는 알약 인디케이터 - Positioned( - top: (62 - pillH) / 2, - left: pillCenterX - pillW / 2, - child: Transform.scale( - scaleX: 1 + stretch, - scaleY: 1 - stretch * 0.6, - child: Container( - width: pillW, - height: pillH, - decoration: BoxDecoration( - color: pill, - borderRadius: BorderRadius.circular(21), - ), - ), - ), - ), - // 아이콘 (라벨 없음 — 아이콘이 곧 아이덴티티) - Row( - children: [ - for (var i = 0; i < n; i++) _buildItem(i, pos, itemW), - ], - ), - ], - ); - }, - ); - }, - ), - ), + // 세로 레일은 항목 수만큼 정확히 자란다(가로는 폭을 n 등분). + child: vertical + ? SizedBox( + width: _thickness, + height: _thickness * n, + child: _track(n, _thickness), + ) + : SizedBox( + height: _thickness, + child: LayoutBuilder( + builder: (context, constraints) => + _track(n, constraints.maxWidth / n), + ), + ), ), ); } - Widget _buildItem(int i, double pos, double itemW) { + /// 알약 + 아이콘 — 가로/세로 공용. [itemExtent] 는 진행 축 방향의 항목 크기. + Widget _track(int n, double itemExtent) { + final vertical = widget.axis == Axis.vertical; + return AnimatedBuilder( + animation: _c, + builder: (context, _) { + final pos = _c.value; // 연속 위치 + // 이동 속도로 알약을 진행 방향으로 늘림(스쿼시 & 스트레치). + final v = _c.velocity; + final stretch = (v.abs() * 0.04).clamp(0.0, 0.22); + // 아이콘 단독 구성 — 알약도 원에 가깝게(앱 전반의 둥근 무드). + // 세로 레일에서는 같은 알약을 90° 돌려 진행 축을 따르게 한다. + final pillW = vertical ? 42.0 : 52.0; + final pillH = vertical ? 52.0 : 42.0; + final pillCenter = (pos + 0.5) * itemExtent; + + final pill = + widget.pillColor ?? + context.colors.primaryDark.withValues(alpha: 0.08); + return Stack( + children: [ + // 흐르는 알약 인디케이터 + Positioned( + top: vertical ? pillCenter - pillH / 2 : (_thickness - pillH) / 2, + left: vertical + ? (_thickness - pillW) / 2 + : pillCenter - pillW / 2, + child: Transform.scale( + // 늘어남은 언제나 진행 축, 수축은 그 직교 축. + scaleX: vertical ? 1 - stretch * 0.6 : 1 + stretch, + scaleY: vertical ? 1 + stretch : 1 - stretch * 0.6, + child: Container( + width: pillW, + height: pillH, + decoration: BoxDecoration( + color: pill, + borderRadius: BorderRadius.circular(21), + ), + ), + ), + ), + // 아이콘 (라벨 없음 — 아이콘이 곧 아이덴티티) + if (vertical) + Column( + children: [ + for (var i = 0; i < n; i++) _buildItem(i, pos, itemExtent), + ], + ) + else + Row( + children: [ + for (var i = 0; i < n; i++) _buildItem(i, pos, itemExtent), + ], + ), + ], + ); + }, + ); + } + + Widget _buildItem(int i, double pos, double itemExtent) { final item = widget.items[i]; // 알약과의 근접도(0~1): 가까울수록 활성에 가깝게 보간된다. final proximity = (1 - (pos - i).abs()).clamp(0.0, 1.0); @@ -161,15 +197,19 @@ class _SpringyNavBarState extends State final lift = -1.5 * proximity; // 떠오름(라벨이 없어 과하지 않게) final iconScale = 1 + 0.16 * proximity; // 확대 — 아이콘이 곧 상태 표시 final selected = proximity > 0.5; + final vertical = widget.axis == Axis.vertical; return SizedBox( - width: itemW, + // 진행 축은 itemExtent, 직교 축은 바 두께. + width: vertical ? _thickness : itemExtent, + height: vertical ? itemExtent : _thickness, child: GestureDetector( onTap: () => _tap(i), behavior: HitTestBehavior.opaque, child: Center( child: Transform.translate( - offset: Offset(0, lift), + // 떠오름도 바 안쪽(직교 축) 방향으로 — 세로 레일은 왼쪽으로. + offset: vertical ? Offset(lift, 0) : Offset(0, lift), child: Transform.scale( scale: iconScale, child: Icon( diff --git a/lib/screen/facility_review_screen.dart b/lib/screen/facility_review_screen.dart index fe788d5..31c89a6 100644 --- a/lib/screen/facility_review_screen.dart +++ b/lib/screen/facility_review_screen.dart @@ -1,13 +1,26 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart' show XFile; import '../models/facility_review.dart'; import '../motion/motion.dart'; import '../services/facility_repository.dart'; import '../services/facility_review_repository.dart'; +import '../services/session.dart'; import '../services/storage_service.dart'; import '../theme/app_palette.dart'; +import '../widgets/lite_review_auth_sheet.dart'; import '../widgets/media_widgets.dart'; +/// 인증 전에 고른 사진 — 업로드는 인증 후에만 가능하다(Storage RLS 가 `/...` +/// 폴더 기준이라 비로그인은 쓰지 못한다). 미리보기는 바이트로 그린다(웹·앱 공통). +class _PendingPhoto { + final XFile file; + final Uint8List bytes; + const _PendingPhoto(this.file, this.bytes); +} + /// 시설 후기 작성/수정 (0022) — 게시글 작성(post_create_screen)과 같은 디자인 /// 문법: 섹션 라벨·간격·타이포, 앱바 '등록' 액션, 첨부는 바텀시트(사진/동영상). /// 갤러리 다중 사진 허용(자유 비율 — 크롭 없음). 카페는 작성 시 승격. @@ -39,10 +52,20 @@ class _FacilityReviewScreenState extends State { bool _uploadingVideo = false; bool _submitting = false; + /// 비로그인 손님이 고른 사진 — 게시 직전 간이 인증을 마친 뒤 한꺼번에 올린다. + /// 인증을 취소해도 고른 사진이 사라지지 않게 여기 남겨 둔다(초안 보존). + final List<_PendingPhoto> _pending = []; + static const _maxPhotos = 5; static const _maxVideos = 2; final _repo = FacilityReviewRepository.instance; + /// 비로그인 작성 — 간이 회원(0029) 경로. 게시 시점에만 인증을 요구한다. + bool get _isGuest => !SessionManager.instance.isLoggedIn; + + /// 사진 개수는 업로드된 것 + 대기 중인 것의 합. + int get _photoCount => _photos.length + _pending.length; + @override void dispose() { _contentCtrl.dispose(); @@ -58,8 +81,11 @@ class _FacilityReviewScreenState extends State { /// 첨부 바텀시트 — 사진/동영상 선택(게시글 작성의 첨부 시트와 동일 문법). Future _chooseAttachment() async { - final canPhoto = _photos.length < _maxPhotos && !_uploading; - final canVideo = _videos.length < _maxVideos && !_uploadingVideo; + final canPhoto = _photoCount < _maxPhotos && !_uploading; + // 동영상은 인코딩·포스터 생성까지 걸려 있어 대기(pending) 처리를 하지 않는다. + // 비로그인은 사진만 — 인증을 앞당겨 요구하느니 명시적으로 안내한다. + final canVideo = + _videos.length < _maxVideos && !_uploadingVideo && !_isGuest; final src = await showModalBottomSheet( context: context, builder: (ctx) => SafeArea( @@ -97,7 +123,11 @@ class _FacilityReviewScreenState extends State { : context.colors.textTertiary, ), title: const Text('동영상'), - subtitle: Text('최대 60초 · 100MB · $_maxVideos개'), + subtitle: Text( + _isGuest + ? '동영상은 회원가입 후 첨부할 수 있어요' + : '최대 60초 · 100MB · $_maxVideos개', + ), onTap: () => Navigator.pop(ctx, 'video'), ), const SizedBox(height: 8), @@ -110,9 +140,27 @@ class _FacilityReviewScreenState extends State { } Future _addPhotos() async { - if (_photos.length >= _maxPhotos || _uploading) return; + if (_photoCount >= _maxPhotos || _uploading) return; final files = await StorageService.instance.pickImages(); if (files.isEmpty) return; + + // 비로그인은 아직 uid 가 없어 Storage 에 못 쓴다 — 게시 직전 인증 후 올린다. + if (_isGuest) { + setState(() => _uploading = true); + try { + for (final f in files) { + if (_photoCount >= _maxPhotos) break; + _pending.add(_PendingPhoto(f, await f.readAsBytes())); + } + if (mounted) setState(() {}); + } catch (_) { + _toast('사진을 읽지 못했어요'); + } finally { + if (mounted) setState(() => _uploading = false); + } + return; + } + setState(() => _uploading = true); try { for (final f in files) { @@ -170,9 +218,42 @@ class _FacilityReviewScreenState extends State { ); } + /// 게시. 비로그인이면 **여기서** 간이 인증을 받는다 — 쓰기 전에 가입을 요구하면 + /// 대부분 이탈하므로, 다 쓰고 게시를 누르는 순간에만 번호 인증을 청한다(0029). + /// + /// 인증을 취소하면 아무것도 잃지 않고 작성 화면으로 돌아온다(별점·본문·사진 유지). Future _submit() async { setState(() => _submitting = true); + + String? liteToken; + if (_isGuest) { + final auth = await showLiteReviewAuthSheet(context); + if (!mounted) return; + if (auth == null || auth.token == null) { + // 취소 — 초안은 그대로 두고 버튼만 되살린다. + setState(() => _submitting = false); + return; + } + if (auth.userId == null) { + setState(() => _submitting = false); + _toast('인증 정보를 받지 못했어요. 다시 시도해주세요'); + return; + } + liteToken = auth.token; + SessionManager.instance.beginLiteSession(liteToken!, auth.userId!); + } + try { + // 인증을 마쳤으니 이제 Storage 에 쓸 수 있다 — 대기 중이던 사진을 올린다. + for (final p in _pending) { + final up = await StorageService.instance.upload( + p.file, + category: 'facility_review', + ); + _photos.add(up.url); + } + _pending.clear(); + final fid = await _resolveFacilityId(); await _repo.addReview( facilityId: fid, @@ -187,12 +268,18 @@ class _FacilityReviewScreenState extends State { } catch (e) { if (!mounted) return; setState(() => _submitting = false); + final msg = e.toString(); // 자기 업체 후기는 서버가 차단(own_facility) — 안내를 구분한다. _toast( - e.toString().contains('own_facility') + msg.contains('own_facility') ? '내 업체에는 후기를 남길 수 없어요' + : msg.contains('reverify_required') + ? '인증이 만료됐어요. 다시 시도해주세요' : '후기 저장에 실패했어요', ); + } finally { + // 간이 토큰은 게시 한 건에만 쓰이고 즉시 버린다(저장하지 않는다). + if (liteToken != null) SessionManager.instance.endLiteSession(); } } @@ -319,7 +406,7 @@ class _FacilityReviewScreenState extends State { ), const SizedBox(height: 12), _SectionLabel( - '사진·동영상 · 사진 ${_photos.length}/$_maxPhotos · ' + '사진·동영상 · 사진 $_photoCount/$_maxPhotos · ' '동영상 ${_videos.length}/$_maxVideos', ), Wrap( @@ -339,6 +426,20 @@ class _FacilityReviewScreenState extends State { ), onRemove: () => setState(() => _photos.removeAt(i)), ), + // 아직 업로드 전인 사진(비로그인) — 게시 때 함께 올라간다. + for (var i = 0; i < _pending.length; i++) + _thumb( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.memory( + _pending[i].bytes, + width: 76, + height: 76, + fit: BoxFit.cover, + ), + ), + onRemove: () => setState(() => _pending.removeAt(i)), + ), for (var i = 0; i < _videos.length; i++) _thumb( child: SizedBox( @@ -355,8 +456,7 @@ class _FacilityReviewScreenState extends State { onRemove: () => setState(() => _videos.removeAt(i)), ), // 첨부 추가 — 탭하면 사진/동영상 선택 바텀시트. - if (_photos.length < _maxPhotos || - _videos.length < _maxVideos) + if (_photoCount < _maxPhotos || _videos.length < _maxVideos) Pressable( borderRadius: BorderRadius.circular(12), onTap: (_uploading || _uploadingVideo) diff --git a/lib/screen/main_screen.dart b/lib/screen/main_screen.dart index 9d0b302..d9cab31 100644 --- a/lib/screen/main_screen.dart +++ b/lib/screen/main_screen.dart @@ -1,24 +1,35 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../motion/motion.dart'; import '../services/care_report_repository.dart'; import '../services/keyboard_barrier.dart'; import '../theme/app_palette.dart'; +import '../utils/layout.dart'; +import '../widgets/app_invite_dialog.dart'; +import '../widgets/app_shell.dart'; import 'tabs/chat_tab.dart'; import 'tabs/community_tab.dart'; import 'tabs/map_tab.dart'; import 'tabs/my_info_tab.dart'; import 'tabs/user_search_tab.dart'; -/// 메인 화면 — 바텀 네비게이션 5탭. +/// 메인 화면 — 바텀 네비게이션. /// 탭 순서: 지도(개발중) / 사용자검색 / 커뮤니티(중앙=기본) / 채팅 / 내정보 -/// 기본 진입 탭은 커뮤니티(인덱스 2). +/// 기본 진입 탭은 커뮤니티. +/// +/// 웹은 지도·채팅을 뺀 3탭이다(docs/web-port.md) — 아래 `tabMap`… 상수는 위치가 +/// 아니라 **탭 정체성**이므로 플랫폼과 무관하게 고정이다. 실제 표시 순서는 +/// `visibleTabs` 가 정하고, 딥링크는 상수로 요청하면 알아서 매핑된다. class MainScreen extends StatefulWidget { final bool isGuest; const MainScreen({super.key, this.isGuest = false}); - /// 외부(푸시 딥링크 등)에서의 탭 전환 요청 — 인덱스를 넣으면 화면이 전환 후 + /// 외부(푸시 딥링크 등)에서의 탭 전환 요청 — 아래 탭 상수를 넣으면 화면이 전환 후 /// null 로 리셋한다. 딥링크가 상세를 얹기 전에 "뒤로 갔을 때 보일 탭"을 맞출 때 사용. + /// 현재 플랫폼에 없는 탭(웹의 지도·채팅)을 요청하면 무시된다. static final ValueNotifier tabRequest = ValueNotifier(null); static const tabMap = 0, @@ -27,15 +38,52 @@ class MainScreen extends StatefulWidget { tabChat = 3, tabMyInfo = 4; + /// 내비게이션에 **아이콘으로 노출**하는 탭 — 표시 순서대로. 웹도 5개 전부 + /// 보여준다. 숨기면 앱에서 뭘 더 할 수 있는지 알 길이 없어 유입으로 이어지지 + /// 않는다 — 보여주고 누르면 설치를 권한다([appOnlyTabs]). + static const List visibleTabs = [ + tabMap, + tabSearch, + tabCommunity, + tabChat, + tabMyInfo, + ]; + + /// 이 플랫폼에서 **앱 전용**인 탭 — 아이콘은 보이되 누르면 설치 안내가 뜨고 + /// 화면은 전환되지 않는다. 따라서 내용 위젯도 만들지 않는다(웹 구현이 없는 + /// 지도 플랫폼뷰가 붙는 것을 막는다). + static const Set appOnlyTabs = kIsWeb ? {tabMap, tabChat} : {}; + + /// 실제로 내용 위젯을 만드는 탭 — 표시 순서 유지. + static final List contentTabs = [ + for (final t in visibleTabs) + if (!appOnlyTabs.contains(t)) t, + ]; + + /// 앱 전용 탭의 안내 문구에 쓸 이름. + static String tabLabel(int tab) => switch (tab) { + tabMap => '지도', + tabChat => '채팅', + _ => '이 기능', + }; + @override State createState() => _MainScreenState(); } class _MainScreenState extends State with SingleTickerProviderStateMixin { - int _index = 2; // 기본 = 커뮤니티 + // `_index` 는 `MainScreen.visibleTabs`(아이콘 목록) 안에서의 위치다. + // 앱 전용 탭에는 결코 머무르지 않으므로 항상 내용이 있는 탭을 가리킨다. + int _index = MainScreen.visibleTabs.indexOf(MainScreen.tabCommunity); int _direction = 0; // 마지막 탭 이동 방향(+1 오른쪽, -1 왼쪽) + /// 현재 보고 있는 탭의 정체성 상수. + int get _currentTab => MainScreen.visibleTabs[_index]; + + /// IndexedStack 용 — 내용 탭 목록에서의 위치(아이콘 목록과 다를 수 있다). + int get _contentIndex => MainScreen.contentTabs.indexOf(_currentTab); + // 하단 네비게이션 바 표시 여부. 커뮤니티에서 아래로 스크롤 시 숨고 위로 올리면 복귀. final _navVisible = ValueNotifier(true); @@ -50,28 +98,42 @@ class _MainScreenState extends State curve: SpringCurve.standard, ); + // 내용이 있는 탭만 생성한다 — 웹에서 MapTab 을 만들면 웹 구현이 없는 지도 + // 플랫폼뷰가 붙는다. 앱 전용 탭은 아이콘만 있고 화면은 없다. late final List _tabs = [ - const MapTab(), - const UserSearchTab(), - CommunityTab(isGuest: widget.isGuest, chromeVisible: _navVisible), - ChatTab(isGuest: widget.isGuest), - MyInfoTab(isGuest: widget.isGuest, chromeVisible: _navVisible), + for (final t in MainScreen.contentTabs) _buildTab(t), ]; + Widget _buildTab(int tab) => switch (tab) { + MainScreen.tabMap => const MapTab(), + MainScreen.tabSearch => const UserSearchTab(), + MainScreen.tabCommunity => CommunityTab( + isGuest: widget.isGuest, + chromeVisible: _navVisible, + ), + MainScreen.tabChat => ChatTab(isGuest: widget.isGuest), + _ => MyInfoTab(isGuest: widget.isGuest, chromeVisible: _navVisible), + }; + // 라벨 없는 아이콘 단독 내비 — 앱의 둥근 무드에 맞춘 rounded 계열로 통일 // (외곽선 아이콘은 각져 보여 제외). 활성/비활성 구분은 색·확대·알약이 담당. - static const _navItems = [ - SpringyNavItem(icon: Icons.location_on_rounded), // 지도 — 둥근 핀 - SpringyNavItem(icon: Icons.person_search_rounded), // 검색 - SpringyNavItem(icon: Icons.groups_rounded), // 커뮤니티 — 지붕 없는 둥근 형태 - SpringyNavItem(icon: Icons.chat_bubble_rounded), // 채팅 - SpringyNavItem(icon: Icons.person_rounded), // 내정보 + static const _navIcons = { + MainScreen.tabMap: Icons.location_on_rounded, // 지도 — 둥근 핀 + MainScreen.tabSearch: Icons.person_search_rounded, // 검색 + MainScreen.tabCommunity: Icons.groups_rounded, // 커뮤니티 — 지붕 없는 둥근 형태 + MainScreen.tabChat: Icons.chat_bubble_rounded, // 채팅 + MainScreen.tabMyInfo: Icons.person_rounded, // 내정보 + }; + + static final _navItems = [ + for (final t in MainScreen.visibleTabs) SpringyNavItem(icon: _navIcons[t]!), ]; @override void initState() { super.initState(); _syncKeyboardBarrier(); + _publishRail(); MainScreen.tabRequest.addListener(_onTabRequest); // 화면 생성 전에 들어온 요청(콜드 스타트 딥링크)은 첫 프레임 뒤에 소비. WidgetsBinding.instance.addPostFrameCallback((_) => _onTabRequest()); @@ -81,16 +143,29 @@ class _MainScreenState extends State } void _onTabRequest() { - final i = MainScreen.tabRequest.value; - if (i == null) return; + final tab = MainScreen.tabRequest.value; + if (tab == null) return; MainScreen.tabRequest.value = null; - if (mounted && i >= 0 && i < _tabs.length) _select(i); + // 앱 전용 탭(웹의 지도·채팅)으로의 요청은 무시한다 — 딥링크는 사용자가 누른 + // 것이 아니므로 설치 안내를 띄우지 않고 조용히 넘긴다. + if (MainScreen.appOnlyTabs.contains(tab)) return; + final i = MainScreen.visibleTabs.indexOf(tab); + if (mounted && i >= 0) _select(i); } - // 지도 탭(index 0)은 자동완성 제안 탭을 위해 전역 키보드 배리어를 끈다. - void _syncKeyboardBarrier() => keyboardBarrierEnabled.value = _index != 0; + // 지도 탭은 자동완성 제안 탭을 위해 전역 키보드 배리어를 끈다. + void _syncKeyboardBarrier() => + keyboardBarrierEnabled.value = _currentTab != MainScreen.tabMap; void _select(int i) { + // 앱 전용 탭 — 화면을 바꾸지 않고 설치를 권한다. 선택 상태(알약)도 그대로다. + final tab = MainScreen.visibleTabs[i]; + if (MainScreen.appOnlyTabs.contains(tab)) { + unawaited( + AppInviteDialog.show(context, feature: MainScreen.tabLabel(tab)), + ); + return; + } if (i == _index) return; setState(() { _direction = i > _index ? 1 : -1; @@ -98,19 +173,77 @@ class _MainScreenState extends State }); _navVisible.value = true; // 탭 전환 시 바는 항상 보이게 복귀 _syncKeyboardBarrier(); + _publishRail(); _tab.forward(from: 0); // 새 탭이 방향에서 흘러 들어옴 } @override void dispose() { MainScreen.tabRequest.removeListener(_onTabRequest); + // 로그아웃 등으로 벗어나면 레일도 사라진다 — 단 **내가 올린 것일 때만** 지운다. + // 로그인은 pushAndRemoveUntil 이라 새 MainScreen 의 initState 가 먼저 돌고 + // 게스트 MainScreen 의 dispose 가 나중에 온다. 무조건 null 로 지우면 방금 올라온 + // 레일까지 지워져 **로그인하면 데스크톱 내비게이션이 통째로 사라진다.** + if (identical(navRail.value, _railConfig)) navRail.value = null; _tab.dispose(); _navVisible.dispose(); super.dispose(); } + /// 좌측 레일(넓은 화면)에 현재 탭 상태를 공개 — 그리는 것은 [AppShell] 이다. + /// 레일이 본문 컬럼 **바깥**에 있어야 하고 상세 라우트 위에서도 유지돼야 해서 + /// 여기서 직접 그리지 않는다. + /// 이 인스턴스가 마지막으로 공개한 설정 — dispose 에서 "내 것일 때만" 지우려고 + /// 들고 있는다([dispose] 참고). + NavRailConfig? _railConfig; + + void _publishRail() { + _railConfig = NavRailConfig( + currentIndex: _index, + items: _navItems, + onTap: _selectFromRail, + ); + navRail.value = _railConfig; + } + + /// 레일에서의 탭 — 먼저 이 화면 위에 얹힌 라우트를 모두 닫는다. + /// + /// 레일은 본문 컬럼 바깥(Navigator 위)에 있어서 **게시글 상세가 열려 있어도 + /// 계속 보인다.** 그 상태로 누르면 아래 탭만 바뀌고 화면은 상세 그대로라 + /// "눌러도 아무 일도 안 일어남"으로 보였다. 하단 바(모바일)는 상세에 가려 + /// 이 상황 자체가 없어 [_select] 를 그대로 쓴다. + void _selectFromRail(int i) { + final myRoute = ModalRoute.of(context); + if (myRoute != null) { + Navigator.of(context).popUntil((r) => r == myRoute); + } + _select(i); + } + + /// 탭 본문 — 전환 시 방향에서 흘러 들어오는 모션 포함. 가로/세로 크롬 공용. + Widget _body() => AnimatedBuilder( + animation: _tabAnim, + builder: (context, child) { + final t = _tabAnim.value.clamp(0.0, 1.0); + return Opacity( + opacity: t, + child: Transform.translate( + offset: Offset(_direction * (1 - t) * 28, 0), + child: child, + ), + ); + }, + // IndexedStack 으로 각 탭 상태는 보존하고, 전환 순간에만 방향성 있게 흘려 보낸다. + child: IndexedStack(index: _contentIndex, children: _tabs), + ); + @override Widget build(BuildContext context) { + // 넓은 화면(데스크톱 웹)은 하단 바 대신 좌측 레일 — 레일은 본문 컬럼 바깥에 + // 있어야 하므로 AppShell 이 그린다. 여기서는 하단 바만 뺀다. + // 본문(카드·타이포·모션)은 폭과 무관하게 앱과 동일하다 — 바뀌는 건 크롬뿐. + final sideNav = useSideNav(context); + return Scaffold( backgroundColor: context.colors.background, // body 를 하단 바 뒤까지 확장 → 바가 숨을 때 콘텐츠가 화면 끝까지 차서 @@ -120,36 +253,25 @@ class _MainScreenState extends State // 5개 탭 전부(지도 플랫폼뷰 포함)가 재레이아웃돼 검색 중 스크롤이 심하게 끊긴다. // 탭 내 입력 필드(커뮤니티·사용자 검색)는 모두 상단이라 가려질 것이 없다. resizeToAvoidBottomInset: false, - // IndexedStack 으로 각 탭 상태는 보존하고, 전환 순간에만 방향성 있게 흘려 보낸다. - body: AnimatedBuilder( - animation: _tabAnim, - builder: (context, child) { - final t = _tabAnim.value.clamp(0.0, 1.0); - return Opacity( - opacity: t, - child: Transform.translate( - offset: Offset(_direction * (1 - t) * 28, 0), - child: child, - ), - ); - }, - child: IndexedStack(index: _index, children: _tabs), - ), + body: _body(), // 아래로 스크롤 시 바가 아래로 슬라이드되어 숨고, 위로 올리면 스프링으로 복귀. - bottomNavigationBar: ValueListenableBuilder( - valueListenable: _navVisible, - builder: (context, shown, child) => AnimatedSlide( - offset: shown ? Offset.zero : const Offset(0, 1), - duration: MotionDurations.base, - curve: SpringCurve.standard, - child: child, - ), - child: SpringyNavBar( - currentIndex: _index, - onTap: _select, - items: _navItems, - ), - ), + // 좌측 레일에서는 세로 공간을 두고 다툴 일이 없어 숨기지 않는다(항상 표시). + bottomNavigationBar: sideNav + ? null + : ValueListenableBuilder( + valueListenable: _navVisible, + builder: (context, shown, child) => AnimatedSlide( + offset: shown ? Offset.zero : const Offset(0, 1), + duration: MotionDurations.base, + curve: SpringCurve.standard, + child: child, + ), + child: SpringyNavBar( + currentIndex: _index, + onTap: _select, + items: _navItems, + ), + ), ); } } diff --git a/lib/screen/pet_identity_enroll_screen.dart b/lib/screen/pet_identity_enroll_screen.dart index b98535e..74a72ff 100644 --- a/lib/screen/pet_identity_enroll_screen.dart +++ b/lib/screen/pet_identity_enroll_screen.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -8,6 +7,7 @@ import 'package:video_thumbnail/video_thumbnail.dart'; import '../services/pet_enroll_repository.dart'; import '../services/storage_service.dart'; import '../theme/app_palette.dart'; +import '../utils/temp_file.dart'; /// 펫 신원 인증(0020) — 반려동물 영상 촬영 → 프레임 추출 → 서버 AI 검증(실물·동일개체). /// @@ -70,7 +70,7 @@ class _PetIdentityEnrollScreenState extends State { if (!mounted) return; setState(() => _status = '신원 인증 중… (영상 분석)'); - final videoBytes = await File(video.path).readAsBytes(); + final videoBytes = await video.readAsBytes(); final res = await PetEnrollRepository.instance.enroll( petId: widget.petId, videoBytes: videoBytes, @@ -78,11 +78,7 @@ class _PetIdentityEnrollScreenState extends State { frames: frames, ); // 로컬 임시 영상 삭제(잔존 방지). - try { - await File(video.path).delete(); - } catch (e) { - debugPrint('신원 인증: 임시 영상 삭제 실패(무해): $e'); - } + await deleteTempFile(video.path); if (!mounted) return; setState(() { diff --git a/lib/screen/profile_edit_screen.dart b/lib/screen/profile_edit_screen.dart index 433d53f..300e464 100644 --- a/lib/screen/profile_edit_screen.dart +++ b/lib/screen/profile_edit_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../models/profile.dart'; @@ -12,6 +13,7 @@ import '../services/storage_service.dart'; import '../services/theme_controller.dart'; import '../state/profile_edit_state.dart'; import '../theme/app_palette.dart'; +import '../widgets/app_invite_dialog.dart'; import 'activity_screens.dart'; import 'blocked_users_screen.dart'; import 'business_register_screen.dart'; @@ -301,7 +303,16 @@ class _ProfileEditScreenState extends State { ), const SizedBox(height: 8), InkWell( - onTap: _verifyRegion, + // 웹은 위치를 아예 수집하지 않는다(법·신뢰성 — + // LocationService.getCurrentPosition 주석 참고). + // 눌리는데 아무 일도 안 나는 상태로 두지 않고 + // 앱에서 하도록 안내한다. + onTap: kIsWeb + ? () => AppInviteDialog.show( + context, + feature: '동네 인증', + ) + : _verifyRegion, borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.symmetric( @@ -338,7 +349,11 @@ class _ProfileEditScreenState extends State { ), ), Text( - _state.verified ? '재인증' : 'GPS로 인증', + kIsWeb + ? '앱에서' + : (_state.verified + ? '재인증' + : 'GPS로 인증'), style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, @@ -358,7 +373,9 @@ class _ProfileEditScreenState extends State { Align( alignment: Alignment.centerLeft, child: Text( - '활동 지역은 현재 위치(GPS)로만 인증돼요.', + kIsWeb + ? '활동 지역 인증은 앱에서만 할 수 있어요.' + : '활동 지역은 현재 위치(GPS)로만 인증돼요.', style: TextStyle( fontSize: 12, color: context.colors.textTertiary, diff --git a/lib/screen/review_detail_screen.dart b/lib/screen/review_detail_screen.dart index 8aefbc8..f9bc150 100644 --- a/lib/screen/review_detail_screen.dart +++ b/lib/screen/review_detail_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:video_player/video_player.dart'; @@ -686,15 +687,26 @@ class _ReviewDetailScreenState extends State { children: [ const ColoredBox(color: Colors.black), // letterbox 여백 if (v.isInitialized) - FittedBox( - fit: fit, - clipBehavior: Clip.hardEdge, - child: SizedBox( - width: v.size.width, - height: v.size.height, - child: VideoPlayer(ctrl), + // 웹은 FittedBox 대신 AspectRatio — 플랫폼 뷰(DOM