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