/u/:userId (⏳ PR18-C 신규)신규 그룹 4(소셜). 입력 근거: DECISIONS 2026-05-17 “친구 서재 탐험 V1.0 합류”,
db-schema.md §2.5 follows. 친구 서재 탐험의 유일한 풀스크린 — 다른 진입점(Me 친구찾기·책 상세 친구 미니리스트·카드 deep link sender 배너)은 모두 이 화면으로 모임. 본인 프로필 진입은/me로 redirect — 이 화면은 남의 서재 read-only 만.
is_private=true)는 RLS가 hard exclude — 클라이언트 코드에 fallback 없음(DB가 막음 = 신뢰의 단일 출처). [팔로우/언팔로우] 1탭. 그 외 액션 X — 인용구 [카드 만들기]·[삭제]·[수정]·공유 모두 숨김(남의 데이터).GoRoute(path: '/u/:userId'). 인증 필수(_redirect가 비로그인이면 /auth/login?from=/u/:userId로). 셸 밖 풀스크린. :userId는 auth.users.id UUID. 본인 진입 차단도 라우터 _redirect 단계에서 처리 — auth.uid() == :userId면 즉시 /me로 redirect(1프레임 흰 화면 깜박임 회피, 2026-05-18 결정). 닉네임 미설정/의심 패턴(.·_·email local-part) 사용자가 진입 시 _NicknameGateView 풀스크린 노출(PR18-B/C 게이트)./book/:id(친구 컨텍스트 사라짐 — V1은 단순 그 책 화면, V1.5에 “이 책의 친구 인용구도 보기” 보강 검토) / 인용구 카드 탭 → 인라인 펼침(공유 버튼 없음) / [팔로우/언팔로우] → 상태 토글, 화면 유지 / ← → push 스택(deep link 콜드스타트로 스택 비면 context.go('/')).공개 프로필 (is_library_public=true)
┌─────────────────────────────────────────┐
│ ← 지윤 ⋮ │ AppBar — ⋮ V1.5(신고/차단). V1엔 ⋮ 자체 숨김
├─────────────────────────────────────────┤
│ ┌──┐ │ 헤더 — 64×64 아바타(없으면 이니셜)
│ │지 │ 지윤 │ display_name headlineSmall primary900
│ └──┘ 팔로워 12 · 팔로잉 5 │ 카운트 bodySmall primary500 (탭=시트로 리스트)
│ ┌─────────────────┐ │
│ │ + 팔로우 │ │ accent500 FilledButton 36dp. 팔로잉 중이면
│ └─────────────────┘ │ "✓ 팔로잉" OutlinedButton(탭=언팔로우 확인)
├─────────────────────────────────────────┤
│ 지윤님의 서재 [ 책 23 ] [ 인용구 47 ] │ 세그먼트(library.md와 같은 톤). 카운트는
├─────────────────────────────────────────┤ 잠금 제외(RLS가 거른 N).
│ (책 탭) library.md의 _BookList 그대로 │
│ 미드나잇 라이브러리 [3구절] │ "N구절" 배지는 친구의 공개 인용구 카운트 only
│ ... │
│ │
│ (인용구 탭) quote-list.md의 무드 칩 │
│ + 카드 목록. 카드 액션은: │
│ ┌─────────────────────────────────────┐│ 접힘 / 펼침 모두 카드 우상단 액션 X
│ │ "가장 깊은 밤에 가장 빛나는 별이…" ││ ([공유]·[카드 만들기]·[삭제] 모두 숨김)
│ │ 📕 미드나잇 라이브러리 p.132 · 위로 ││ 대신 펼침 시 [📕 책 보기 ▸]만(/book/:id로)
│ └─────────────────────────────────────┘│
└─────────────────────────────────────────┘
비공개 프로필 (is_library_public=false) — “잠긴 서재”
┌─────────────────────────────────────────┐
│ ← 지윤 │
├─────────────────────────────────────────┤
│ ┌──┐ 지윤 │ 헤더 + 팔로우 버튼은 그대로 표시 가능
│ │지 │ 팔로워 12 · 팔로잉 5 │ (팔로우는 공개 여부와 무관 — 트위터식)
│ └──┘ ┌─────────────────┐ │
│ │ + 팔로우 │ │
│ └─────────────────┘ │
├─────────────────────────────────────────┤
│ │
│ 🔒 │ primary400 큰 아이콘
│ │
│ 이 서재는 비공개예요 │ headlineSmall primary600
│ 지윤님이 공개 설정을 켜면 보여요 │ bodyMedium primary500
│ │
└─────────────────────────────────────────┘
본인 진입 시 — 화면 빌드 직전 context.go('/me') redirect. 깜박임 회피 위해 build 시점이 아니라 initState/didChangeDependencies에서 검사.
| 상태 | 처리 | 심각도 |
|—|—|—|
| 로딩: 프로필 | friendProfileProvider(userId) (FutureProvider.autoDispose<Profile>) | 낮음 |
| 로딩: 책·인용구 | 각각 friendBooksProvider(userId)·friendQuoteFeedProvider(userId) (notifier · cursor-after). 세그먼트 미선택 탭은 lazy | 낮음 |
| 미로그인 | 라우터 가드가 /auth/login?from=/u/:userId로 — 도달 가능성 0 | — |
| 비공개 프로필 | profile.is_library_public=false → 헤더 그대로, body는 “잠긴 서재” 빈상태. FollowState에 따라 카피 분기(팔로우 전: “공개 설정을 켜면 보여요” / 팔로잉 중: “팔로우 요청을 보냈어요. 서재가 공개되면 여기서 볼 수 있어요”). | — |
| 본인 진입 | 라우터 _redirect에서 auth.uid() == userId 검사 → /me로 redirect (1프레임 흰 화면 회피, 2026-05-18 결정) | — |
| 닉네임 미설정/의심 | display_name이 email local-part 패턴(./_ 포함)이거나 비어있으면 본 화면 진입 봉쇄 → _NicknameGateView 풀스크린 노출 (PR18-B/C 게이트) | — |
| 공개인데 빈 서재 | books·quotes 둘 다 0 → “아직 공개한 책이 없어요” 빈상태 | 낮음 |
| 팔로우 토글 중 | 낙관적 업데이트 — 버튼 즉시 토글, 실패 시 rollback + SnackBar “팔로우에 실패했어요” | 낮음 |
| 에러 (네트워크) | _ErrorView userMessage + 다시 시도. raw $error 노출 X (library.md 기준) | 중간 |
| 존재하지 않는 userId | profile fetch가 PGRST116(0 row) → “사용자를 찾을 수 없어요” 빈상태 + [홈으로] | 낮음 |
_SegmentHeader 재사용. 각 탭 스크롤 위치 보존(친구 프로필도 StatefulShellRoute 외 풀스크린이라 화면 state로 보관). 카운트는 RLS가 거른 후 수치 — 잠금 인용구 제외.follow_repository.follow(userId) → 비활성/회색 200ms 후 “✓ 팔로잉”). 실패 시 rollback + SnackBar. 본인 자신은 진입 redirect라 도달 X./book/:id(친구 컨텍스트 sender·friendOnly 쿼리 안 붙임 — V1 단순). 책 상세에서 “이 책을 담은 친구 N명”엔 이 친구도 포함되므로 다시 돌아오기 가능.book_id 있을 때만, manual_book_text only면 disabled). [공유]·[카드 만들기]·[삭제]·[수정] 전부 숨김(권한·UX 둘 다)./u/:uid. 무한 깊이 OK(stack은 누적).ref.invalidate(friendProfileProvider(userId)) + 책·인용구 둘 다 invalidate.AppColors.secondary50 · AppBar AppTheme.appBarTheme(display_name AppFonts.ui w600 17 primary900)BookCover 스타일 X(CircleAvatar accent200 배경 + 이니셜 1자 primary900). avatar_url 있으면 cached_network_image.AppFonts.ui w600 AppFontSize.lg(18) primary900 · 팔로워/팔로잉 카운트 AppFontSize.sm(13) primary500(탭 가능 영역 ≥44dp)FilledButton.icon accent500/secondary50 36dp · [✓ 팔로잉] OutlinedButton.icon border primary300/text primary700SegmentedButton 선택 primary900/secondary50)primary400 · 텍스트 primary600/primary500 · 중앙 정렬_BookRow 재사용 (library.md) · 인용구 카드: quote_list_card 재사용 with readOnly:true prop(액션 숨김)TextButton.icon accent700 · AppRadius.md_BookRow (library.md) — 그대로 표시. “N구절” 배지는 친구 공개 인용구 카운트 (PR18-D에서 책 단위 카운트 RPC)quote_list_card (quote-list.md) — readOnly prop 추가(액션 숨김)_SegmentHeader (library.md) — segments label만 “책 N · 인용구 N”으로_ErrorView (library.md) — userMessage 매핑 + [다시 시도]BookCover·CircleAvatar·cached_network_imagelib/features/profile/friend_profile_screen.dart — 본 화면 ConsumerStatefulWidgetlib/features/profile/state/friend_providers.dart — friendProfileProvider(userId) + friendBooksProvider(userId) + friendQuoteFeedProvider(userId) (notifier · cursor-after)follow_repository(PR18-B)에 follow/unfollow/isFollowing/listFollowing/listFollowers 메서드quote_repository.listFriendQuotesWithBook(userId, cursor) — from quotes where user_id = :userId 단순 쿼리(RLS가 게이트)book_repository.listFriendBooks(userId) — 동일 패턴profile_repository.getById(userId) — profiles 단순 select_FollowButton 위젯 — 낙관 토글 + rollback. FollowState enum 소비.FollowState enum(notFollowing/following/pending/failed) — 비공개 빈상태 카피·버튼 라벨 분기에 공통 사용_LockedLibraryView — 비공개 빈상태. FollowState 받아 카피 분기(2026-05-18 designer 결정)._NicknameGateView — 닉네임 미설정/email local-part 의심 패턴 시 풀스크린 봉쇄 (PR18-B/C 게이트). [내 닉네임 설정하기 →] 1버튼 → /me로 이동._FollowersSheet — 헤더 카운트 탭 시트보안 핵심 (잠금 인용구 노출 사고 = 신뢰 파괴 1순위):
from quotes where user_id = :userId 단순. quotes_friends_read 정책의 quotes.is_private = false 게이트가 DB 단에서 0 row 응답. 클라이언트에 fallback 코드 X(있으면 분기 버그 위험).is_library_public=true AND. 토글 OFF면 친구가 와도 책·인용구 0 row → “잠긴 서재” 빈상태. 단 프로필 자체는 read 가능(display_name 검색·팔로우 버튼 노출 필요).auth.uid() in (select follower_id ...) 게이트. 비팔로워가 직접 URL /u/:userId 쳐도 책·인용구 0 row. 화면 = “잠긴 서재” 빈상태 (UX는 비공개와 동일하게 — 누가 누구 팔로우 안 했는지 explicit 신호 X).auth.uid() == :userId redirect /me. 본인 잠금 인용구가 친구 화면 흐름에 잘못 노출되는 코드 경로 자체를 차단. 침투 테스트로 RLS 단독 회귀 가드.display_name이 가입 시 이메일 local-part(handle_new_user_oauth). 본명/직장 이메일이면 본명 노출. PR18-B prerequisite = Me에 “공개 닉네임” 편집 다이얼로그 + is_library_public=true 토글 ON 가기 전 강제 확인.?sender=<uid> URL에 박혀있어 변조 가능. 단 우리는 sender_uid로 권한 결정 안 함(공개 프로필 여부는 RLS가 결정). 표시만 — “지윤님이 보낸 카드”가 잘못 표시될 수 있으나 데이터 누수 X.follower_id <> followee_id + 화면 본인 진입 redirect로 이중 차단.profiles.getById PGRST116 → “사용자를 찾을 수 없어요” 빈상태. 404 페이지 별도 X.UX 엣지:
is_library_public=false 토글 — 다음 fetch에서 “잠긴 서재” 빈상태. UI 캐시는 invalidate로 정리.접근성:
Semantics(label: '지윤 프로필 사진'). 팔로우 버튼 Tooltip + Semantics(label: '지윤 팔로우').MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.15) — 무드 칩·메타 줄바꿈 회귀 방지 (PR14-D 패턴 일관).primary700) + Semantics button.20260519120000_follows_public_read.sql: follows SELECT RLS 확장 — 두 endpoint 모두 공개 프로필이면 누구나 read, 카운트·시트 노출 가능) + 화면(friend_profile_screen.dart) + providers(friend_providers.dart) + repo 메서드 4종(ProfileRepository.getById · FollowRepository.listFollowing/listFollowers/countFollowing/countFollowers · BookRepository.listFriendBooks · QuoteRepository.listFriendQuotesWithBook). quote_list_card.dart에 readOnly/onOpenBook prop 추가. router /u/:userId GoRoute + 본인 진입 redirect _redirect. friend_search_screen.dart ListTile onTap → /u/:userId. 신규 테스트 12개(readOnly 3 + friend_profile 9). flutter analyze clean, 239/239 통과. release APK 빌드 검증._redirect로 끌어올림(initState → 라우터 가드, 1프레임 깜박임 회피) ② 닉네임 미설정/의심 패턴 사용자 풀스크린 게이트 _NicknameGateView 신규(PR18-B/C 강제 게이트) ③ 비공개 빈상태에 FollowState enum 분기 카피(팔로우 전 vs 팔로잉 다른 문구 — designer 인지 부조화 회피). ④ §3 상태 표에 게이트·FollowState 분기 row 추가.