그룹 1 · Stage 2. 입력 근거:
competitor-screen-analysis §5 (인용 목록/상세 행), QA-1, Readwise 카드 문법 + StoryGraph 무드. 위치(별도 탭 vs 서재 내 뷰)는 §1에서 결정 대기.
/library?tab=quotes[&mood=...&bookId=...]. 책↔인용구는 같은 데이터의 두 단면 — 세그먼트 전환 시 각 뷰 스크롤 위치 보존(StatefulShellRoute state). 홈 피드(“내 인용 — 최근순”, 시간순 흐름)와 역할 구분: 이 뷰 = 무드·책 단위 탐색(다시 보기)./quote/:id/card) / “수정”(/quote/new 편집 모드 — 또는 인라인) / 책 상세로.┌─────────────────────────────────────────┐
│ 서재 [ 책 ] [ 인용구 ] 🔍 │ 서재 탭 헤더 — 책↔인용구 세그먼트 + 검색
├─────────────────────────────────────────┤
│ 〔전체〕〔위로 12〕〔먹먹 8〕〔새벽3시 5〕〔통찰 3〕│ 무드 필터 칩(개수 표시) + 가로 스크롤
│ ┌─────────────────────────────────────┐ │
│ │ ┌──┐ "가장 깊은 밤에 가장 빛나는 │ │ 인용구 카드 (Readwise 문법)
│ │ │표│ 별이 보인다." │ │ 표지 썸네일 + 인용구 2~3줄 + 책/저자/페이지
│ │ │지│ 미드나잇 라이브러리 · p.132 │ │ + 무드 칩(색 코딩) + 메모 1줄(있으면)
│ │ └──┘ 〔위로〕〔먹먹〕 │ │
│ │ 메모: 힘들 때 다시 읽으려고 │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ ┌──┐ "우리는 우리가 반복하는 것이다." │ │
│ │ │표│ 니코마코스 윤리학 · p.55 〔통찰〕│ │
│ │ └──┘ │ │
│ └─────────────────────────────────────┘ │
│ … (무한 스크롤) │
│ ┌── 정렬: 최근순 ▾ ──┐ │ 정렬 = 최근순 / 책별 그룹 / 페이지순
└─────────────────────────────────────────┘
필터는 3개로 충분(Readwise만큼 다차원일 필요 없음): 무드별(칩) / 책별(정렬 “책별 그룹” 또는 책 상세에서 진입) / 최근순(기본 정렬) + 상단 검색. “라이브러리가 커져도 안 무너지게”가 목표.
| 상태 | 처리 | 표시 | 심각도 |
|---|---|---|---|
| 로딩: 첫 페이지 | 스켈레톤 카드 3~4개. RefreshIndicator(pull-to-refresh). <800ms |
Inline 스켈레톤 | 낮음 |
| 로딩: 페이지네이션 | 하단 spinner. cursor-after(created_at + id), 페이지 15(DECISIONS 2026-05-10 — offset 금지). 중복 fetch 가드 |
Inline (하단) | 낮음 |
| 빈: 인용구 0개 | “아직 인용구가 없어요. 좋아하는 책의 한 줄을 저장해보세요.” + [+ 인용구 추가] | Empty | 중간 |
| 빈: 특정 무드 필터 결과 0개 | “이 무드의 인용구가 아직 없어요” + [전체 보기] | Empty (영역) | 낮음 |
| 빈: 검색 결과 0개 | ”‘$검색어’와 일치하는 인용구가 없어요” | Empty (영역) | 낮음 |
| 에러: 목록 로드 실패 (네트워크/RLS) | “인용구를 불러오지 못했어요” + [다시 시도]. RLS 거부(PGRST301)면 세션 만료 Modal |
Empty 에러 / Modal | 중간 |
| 에러: 인용구 삭제 실패 | 낙관적 제거 롤백 + “삭제하지 못했어요” Toast | Toast | 중간 |
| 오프라인 | 마지막 캐시 목록 표시(stale-while-revalidate) + 상단 “오프라인” 배너. 동기화 대기 중 인용구(아웃박스)는 “동기화 대기” 뱃지 + (책 매칭 실패 시) “책 정보 필요” 액션 | 배너 + 뱃지 | 중간 |
| 권한 거부 | 해당 없음 | — | — |
StatefulShellRoute 안이라 state 보존).quotes count by mood).quote-input.md).| 영역 | 토큰 |
|---|---|
| 화면 | secondary200 배경. 세그먼트: 선택 primary900/secondary50, 미선택 primary400/border primary200 |
| 무드 칩 | quote-input.md의 moodColors 맵 — 미선택: 무드별 연한 배경 + 어두운 텍스트, 선택: primary900/secondary50 |
| 인용구 카드 | secondary100 배경 + primary100 border + AppRadius.md + AppShadow.card / 표지 BookCover 34×50 / 인용구 AppFonts.quote 13 primary800 (2~3줄 말줄임) / 책·저자·페이지 ui xxs primary400 / 메모 ui xs primary500 italic |
| 빈/에러 | Empty 패턴 — primary400 + CTA accent500 |
| 동기화 대기 뱃지 | semanticWarningLight/semanticWarning xxs |
재사용: library_screen.dart(서재 탭 — 세그먼트 추가), RefreshIndicator·_EmptyView·_ErrorView 패턴, BookCover, quote_repository.listMyQuotes({moods}) / myQuotesProvider (quote-input.md 신규), cursor-after 페이지네이션 패턴(book_providers 참고).
신규: lib/features/quote/presentation/quote_list_view.dart(서재 탭 안의 인용구 뷰), lib/features/quote/presentation/widgets/quote_card.dart(목록용 카드 — card_editor의 quote_card.dart와 이름 충돌 주의, quote_list_card.dart로), quote_repository에 listMyQuotes + count-by-mood, moodColors 맵(tokens.dart).
교차 관심사: ① 오프라인=1급(stale-while-revalidate + 아웃박스 뱃지) ② 데이터 유실 = 인용구는 DB ③ PII = 인용구 텍스트·검색어 미전송 ④ 막다른 골목 = 빈 상태마다 출구 CTA ⑤ 해당 없음 ⑥ 에러 표시 일관성 ⑦ 인증 필요(내 인용구) ⑧ 해당 없음.
| 엣지 | 심각도 | 처리 |
|---|---|---|
| 매우 긴 목록(인용구 수백 개) | 낮음 | ListView.builder 가상화. 카드 안 표지는 작게 |
| 인용구에 줄바꿈·이모지 | 낮음 | 2~3줄 말줄임에서 줄바꿈은 공백 취급(미리보기), 확장 시 원본. 이모지 컬러 글리프 |
| 무드 필터 + 검색 동시 | 낮음 | AND 조합 |
| 책 없는 인용구(BOOK_UNRESOLVED) | 중간 | 표지 자리에 placeholder(“책 미연결”) + “책 연결하기” 인라인 액션 |
| 알 수 없는 무드 값(앱 업데이트로 셋 변경) | 낮음 | “기타”로 그룹 또는 그대로 표시하고 필터에서 무시 — 데이터 보존 |
| 동기화 대기 인용구가 목록 상단에 임시 표시 | 중간 | “동기화 대기” 뱃지 → 완료 시 실데이터 교체(깜빡임 최소) |
접근성: 인용구 카드 ≥48dp 탭 영역, '$book의 인용구: $text, $page페이지, 무드: $moods' semantics. 무드 칩 = 색 + 텍스트 + 개수. 세그먼트 toggle semantics. 빈 상태 CTA에 명확한 라벨. 검색 필드에 label: '인용구 검색'.