bookquote

화면 설계 — 책 상세 /book/:id (보강)

그룹 2 · Stage 2~4. 입력 근거: competitor-screen-analysis §5.7, Phase B 가상 팀. deep-link-receive.md(그룹 1)와 같은 화면 컴포넌트의 두 진입 모드 — 일반 진입(서재·검색) vs deep link 진입(?from=share). deep link 상세 동작은 deep-link-receive.md로 위임, 여기선 일반 진입 + 차이만.


1. 목적 / 진입·이탈 / 라우트


2. 와이어프레임

일반 진입 (서재·검색에서)

┌─────────────────────────────────────────┐
│ ←  책 상세                          ⋮    │  AppBar — ⋮ = 담긴 책이면 "서재에서 빼기"
├─────────────────────────────────────────┤
│ ┌────────┐  미드나잇 라이브러리           │  헤더 — 표지 96×140 + 메타
│ │  표지   │  매트 헤이그                   │  제목 headlineMedium / 저자 bodyMedium
│ │        │  ─────────────────────────── │
│ │        │  인플루엔셜 · 2021            │  출판사·연도 bodySmall (페이지·날짜 등은 접힘)
│ │        │  ISBN 9791159..   [더 보기 ▾] │
│ │        │  내 별점  ★★★☆☆  ← 로그인 시만 │  (탭=설정, 현재 별점 별 재탭=지우기. DECISIONS 2026-05-13)
│ │        │  읽기 시작 [오늘][어제][직접]  │  PR17: 입력 후 〔5월 12일 시작〕 [지우기]
│ │        │  다 읽음  [오늘][어제][직접]  │  (started_at 없이 누르면 둘 다 today + Toast)
│ └────────┘                               │
│ ┌─────────────────────────────────────┐ │  서재에 없으면 [+ 인용구 추가] + 보조 [서재에 담기]
│ │       + 이 책 인용구 추가            │ │  서재에 있으면 이 줄 대신 "✓ 서재에 있음" 칩
│ └─────────────────────────────────────┘ │  → /quote/new?bookId=:id
│  이 책에서 모은 구절  3                   │  ── 0개면 "아직 이 책에서 모은 구절이 없어요"
│ ┌─────────────────────────────────────┐ │  미니 리스트(quote_list_card 컴팩트 변형 —
│ │ "가장 깊은 밤에 가장 빛나는 별이…"    │ │  책 고정이라 표지 썸네일 생략)
│ │  p.132   〔위로〕                     │ │
│ ├─────────────────────────────────────┤ │
│ │ "후회는 인생에서 가장 무거운 짐…"     │ │
│ │  p.87                                │ │
│ └─────────────────────────────────────┘ │
│                  [ 전체 보기 ▸ ]          │  3개 초과 시 → /library?tab=quotes&bookId=
│  설명                                     │  ── 점진적 공개
│  미드나잇 라이브러리는 삶과 죽음 사이의…   │  4줄 표시 + fade
│  …                            [ 더 보기 ]│  → 전체 펼침(접기 토글)
└─────────────────────────────────────────┘

deep link 진입 (?from=share) — 위 레이아웃에 상단 2개 영역 추가:

┌─────────────────────────────────────────┐
│ ←  미드나잇 라이브러리                     │  뒤로는 홈/검색으로(스택 비면 context.go('/'))
├─────────────────────────────────────────┤
│ ╭─────────────────────────────────────╮ │  ① 보낸 사람 컨텍스트 (deep link 전용)
│ │ 💬 지윤님이 이 책의 한 줄을 보냈어요   │ │  배경 accent50, accent800 텍스트
│ │  "가장 깊은 밤에 가장 빛나는 별이…"   │ │  (sender 이름은 payload에 있을 때만 — 없으면
│ │  ─ 미드나잇 라이브러리, p.132         │ │   "누군가 이 책의 한 줄을 보냈어요"). V1: 텍스트만,
│ ╰─────────────────────────────────────╯ │   sender 이름·받은 인용구 카드 풀스펙은 V1.5
│ ┌─────────────────────────────────────┐ │  ② "내 서재에 담기" CTA — accent500, 큼 (1급)
│ │       📚 내 서재에 담기              │ │  로그인 → 담기+Toast / 미로그인 → 로그인 후 복귀
│ └─────────────────────────────────────┘ │  이미 담겼으면 "✓ 이미 서재에 있어요"(정보성)
│  … (이하 일반 진입과 동일: 표지·메타·구절·설명) │
└─────────────────────────────────────────┘

✅ PR18-D 보강 (친구 서재 탐험 V1.0 합류, 2026-05-19 구현)

일반 진입 와이어프레임에 1줄 추가 — “이 책에서 모은 구절” 헤더 에:

│ 👥  이 책을 담은 친구 3명  ▸               │  탭=시트 미니리스트(아바타·display_name).
│                                          │  N≥1일 때만 자체 렌더(0이면 숨김 — 빈상태 회피).

구현: follow_repository.countFriendsWithBook(bookId)(헤더 카운트 — user_books.eq(book_id).neq(user_id=self).count(exact) + RLS 게이트) + friendsWithBook(bookId, limit)(시트 lazy fetch — 2-step user_booksprofiles inFilter). RPC 미사용(user_books_friends_read 정책이 가시성 단일 출처, V1 측정 후 hotfix 슬롯). 미니리스트 항목 탭 → /u/:userId(friend-profile.md) + 시트 닫힘.

deep link 진입 와이어프레임에 1탭 옵션 추가?sender=<uid>가 deep link URL에 박혀 있으면 “보낸 사람 컨텍스트 박스” 우하단에 [이 사람 서재 ▸] TextButton 추가. 탭 → /u/:sender. sender가 비공개 프로필이거나 미존재면 버튼 숨김(친구 화면 도달 후 “잠긴 서재” 빈상태로 빠지는 사용자 경험 회피 — 사전 차단).

세부 = friend-profile.md. RLS 정책 = db-schema.md §2.5.


3. 상태

상태 트리거 처리 표시 심각도
로딩: 책 fetch bookByIdProvider(id) 헤더 영역 스켈레톤(표지 placeholder + 텍스트 shimmer). deep link면 받은 인용구 텍스트는 payload에 이미 있어 먼저 표시 가능. 목표 <1s(flows.md §5.5) Inline 스켈레톤 낮음
로딩: 이 책 인용구 리스트 myQuotesProvider(bookId: id) 섹션만 스켈레톤 2줄, 메타·CTA는 즉시 Inline (영역) 낮음
빈: 책 없음/삭제됨 bookByIdProvider == null (PGRST116) “이 책을 더 이상 볼 수 없어요” Empty + [홈으로] / [책 검색]. 현행 “검색 결과에서 다시 선택해주세요”보다 deep link 진입 고려한 카피로 Empty 중간
빈: 이 책 인용구 0개 리스트 비음 “아직 이 책에서 모은 구절이 없어요” — 위에 이미 [+ 인용구 추가] CTA 있으니 추가 버튼 생략 가능 Empty (영역) 낮음
에러: 책 fetch 실패 — 네트워크 NetworkError / FETCH_FAILED “책 정보를 불러오지 못했어요” + 다시 시도. 현행 '책을 불러오지 못했어요: $e' raw 노출 → userMessage만(PII·보안, error-handling §9) Inline → 재시도 중간
에러: 인용구 fetch 실패 (부분) 책 정보 OK, 인용 섹션만 5xx/RLS 책 정보·표지·메타는 그대로 보여줌(부분 실패 격리). 인용 섹션 자리에 “이 책의 인용구를 못 불러왔어요 · 다시 시도” 인라인. 전체 화면 안 죽임 Inline (섹션) 중간
에러: “담기” — 이미 있음 (23505) unique_violation on user_books “이미 서재에 있어요” Toast — 에러 아닌 정보성 Toast 낮음
에러: “담기” — 네트워크 NetworkError 낙관적 표시 후 롤백 + “담지 못했어요. 다시 시도해주세요” Toast Toast 중간
표지 URL 깨짐 (404/CDN) BookCover placeholder fallback 내장(book_cover.dart) — 베이지 + 제목 텍스트. 사용자에게 에러 표시 안 함(북모리의 표지 로딩 실패 약점을 우아한 fallback으로 차별화) (무표시) 낮음
게스트 진입 (미로그인) _redirect/book/ 통과 책 정보 read-only로 보여줌. 인용구 섹션 = 본인 인용 위주라 게스트면 섹션 숨김 또는 “로그인하면 모은 구절을 볼 수 있어요”. [+ 인용구 추가]·[내 서재 담기] 탭 → /auth/login?from=${Uri.encodeComponent('/book/$id?from=share')} → 로그인 후 _redirectfrom으로 복귀(payload 보존 — 신규 작업) (정상 흐름) 높음 (현재 담기 CTA 자체 미구현)
deep link 무한 루프 잘못된 redirect / /book/:id가 다시 deep link 트리거 deep link 앱당 1회 consume 후 클리어, 라우터 redirect 1홉 max. handler 측 처리한 URI를 세션 단위 set으로 기억 (방어) 중간
설명 매우 김 description 수천 자 현행은 전체를 Text로 무제한 → 인용구 섹션을 밀어냄. 4줄 클램프(maxLines: 4 + fade) + “더 보기” Inline 낮음
메타 일부 누락 (저자·출판일·ISBN null) 알라딘 데이터 불완전 / ISBN 직접 등록 도서 현행이 이미 if (book.author != null) 등 null-guard 함 — 누락 필드는 안 보임. "ISBN ${book.isbn13}"가 빈 값 출력 안 되게 guard 추가 (방어) 낮음
오프라인 진입 connectivity_plus 책이 books 캐시(서재)에 있으면 표시, 없으면 “오프라인 — 연결되면 책 정보를 불러와요” + [다시 시도]. “담기”·”인용구 추가”는 온라인 필요(인용은 아웃박스 큐) 배너 + 재시도 중간
권한 거부 해당 없음 책 상세는 권한 요청 0

4. 인터랙션


5. 토큰 매핑

영역 토큰
화면 배경 / AppBar AppColors.secondary200 / AppTheme.appBarTheme·⋮ 아이콘 AppColors.primary500, 타이틀 AppFonts.ui w600 AppFontSize.md(17) AppColors.primary900
보낸 사람 컨텍스트 박스 (deep link) 배경 AppColors.accent50 + border 1 AppColors.accent200 + AppRadius.lg(12) · “💬 …” AppFonts.ui w600 AppFontSize.sm(13) AppColors.accent800 · 받은 인용구 AppFonts.quote AppFontSize.base(15) AppColors.primary800 height AppLineHeight.loose(1.7) · 출처 줄 AppFonts.ui AppFontSize.xs(11) AppColors.accent700 · 패딩 AppSpacing.s4(16)
책 제목 / 저자 / 출판사·연도 headlineMedium AppColors.primary900 / bodyMedium AppColors.primary700 / bodySmall AppColors.primary400
ISBN / “더 보기 ▾” labelSmall AppColors.primary300 / “더 보기” AppColors.accent600 AppFontSize.xs(11)
표지 BookCover(width: 96, height: 140) (현행 그대로)
“내 서재에 담기” CTA 배경 AppColors.accent500 / 텍스트 AppColors.secondary50 AppFonts.ui w600 14 / AppRadius.md(8) / AppShadows.floating / 풀폭, 세로 패딩 AppSpacing.s4
“이 책 인용구 추가” CTA 같은 accent500 — 또는 deep link “담기”보다 우선순위 낮으면 OutlinedButton(border accent500)
“✓ 서재에 있음” 칩 배경 AppColors.semanticSuccessLight / 텍스트 AppColors.semanticSuccess AppFontSize.sm(13) / AppRadius.full
“이 책에서 모은 구절 N” 헤더 AppFonts.ui w600 AppFontSize.base(15) AppColors.primary800 · 개수 AppColors.primary400
인용구 미니 항목 배경 AppColors.secondary100 + border 1 AppColors.primary100 + AppRadius.md(8) · 인용구 AppFonts.quote AppFontSize.sm(13) AppColors.primary800(2줄 말줄임) · p.N AppFontSize.xxs(9) AppColors.primary400 · 무드칩 moodColors
“설명” 헤더 / 본문 titleMedium AppColors.primary800 / bodyMedium AppColors.primary700 height AppLineHeight.normal(1.5), 4줄 후 fade(maxLines + “더 보기”)
Toast / 오프라인 배너 / 빈·에러 AppTheme.snackBarTheme(action accent400) / semanticWarningLight·semanticWarning / library_screen._EmptyView·_ErrorView 패턴 — userMessage만

6. 재사용 / 신규

재사용: bookByIdProvider(id)(현행), BookCover(현행), book_repository.addToLibrary(현행 — library_screen에서 호출 중, idempotent onConflict: 'user_id,book_id', 비로그인 시 NOT_AUTHENTICATED throw), myLibraryProvider(담김 여부 1차 판정 — 단 limit 50이라 정확 판정은 isInLibrary(bookId) EXISTS 쿼리 권고), myQuotesProvider(bookId: id)(quote-list.md 신규), quote_list_card.dart(컴팩트 변형 — quote-list.md 신규), router.dart/quote/new?bookId= 라우트(배선됨), library_screen_EmptyView/_ErrorView 패턴, tokens.dart.

신규 / 변경: lib/features/book/book_detail_screen.dart 보강(인용구 섹션, 점진적 공개, deep link 분기, raw $e 노출 제거), lib/features/book/presentation/widgets/sender_context_box.dart(deep link 상단 박스), lib/app/deep_link_handler.dart 일반화(/book/:id 라우팅 + payload 보존 + 1회 consume — deep-link-receive.md §6), book_repositoryremoveFromLibrary(이미 있음 — UI만 추가) + isInLibrary(bookId) EXISTS, router.dart/book/:id GoRoute builder가 ?from= 쿼리도 넘기게 수정. pubspec.yaml: payload 보존용 shared_preferences(그룹 1에서 이미 추가) 재사용.


7. 엣지 / 접근성

교차 관심사 (공통 8원칙): ① 오프라인=1급(표지/메타 캐시, “담기” 큐) ② 데이터 유실 금지(책 메타는 DB, deep link payload 1회 consume 전까지 보존) ③ PII 로그 금지(raw $e 노출 제거 — 현행 흠, 보낸 사람 이름은 화면에만) ④ 막다른 골목 금지(죽은 책에 [홈]/[검색], 미로그인 deep link도 read-only 다 보임) ⑤ 해당 없음(이 화면엔 책 검색 시트 없음 — /quote/new로 넘김) ⑥ 에러 표시 일관성(Toast=담기 실패, Empty=죽은 책, Modal=세션) ⑦ 게스트 허용(deep link 진입용 — 라우터 이미 처리) ⑧ 해당 없음.

엣지 심각도 처리
표지 URL 깨짐 낮음 BookCover placeholder(이미 동작)
설명 없음 낮음 “설명” 섹션 통째 숨김(현행 동작 유지)
설명 1000자+ 낮음 4줄 + 더보기로 해결
deep link payload에 sender 없음 낮음 “누군가 이 책의 한 줄을 보냈어요”
이미 담긴 책에 deep link 재진입 낮음 “✓ 이미 서재에 있어요” + 인용구 추가 CTA 노출
/book/abc (잘못된 id) 낮음 bookByIdProvider == null → Empty + 출구
미로그인 deep link 높음 read-only로 다 보임, “담기”·”추가”는 로그인 유도, payload 보존
deep link 책이 books 테이블에 없음 낮음 books는 공유 시 upsert되어 있어야 정상. 없으면 “더 이상 볼 수 없어요”

접근성: 표지 semantics '$title 표지' 또는 placeholder에 제목 텍스트. “담기” 버튼 '$title을 내 서재에 담기'(또는 '이미 서재에 있음'). 인용구 미니 항목 ≥48dp. 보낸 사람 박스 '$sender가 보낸 인용구: $text, $book ${page}페이지'. “더 보기” 토글 '설명 ${expanded ? "접기" : "더 보기"}'. 색만으로 “담김” 표시 X(✓ 아이콘 + 텍스트). 헤더 대비 primary900 on secondary200 AA 통과.


변경 이력