Files
life-helper/docs/design/226-catalog-gallery/fn-catalog_repository.md
joungmin 4665f06a94 [02-Architect] #226 design spec + ADR-0004
- docs/design/226-catalog-gallery/README.md — 12 changed files, DisplayCategory enum, sealed CatalogItem, 5 scenarios
- docs/design/226-catalog-gallery/fn-catalog_repository.md — 3-source unification algorithm + helpers
- docs/design/226-catalog-gallery/fn-migration_v1_to_v2.md — first schema migration (DROP + reseed pattern)
- docs/adr/0004-catalog-recategorization-and-first-migration.md — Catalog 재분류 + 첫 마이그레이션 정책

Refs #226
2026-06-12 16:59:31 +09:00

7.5 KiB

함수 설계서: CatalogRepository.all (#226)

부모 설계서: ./README.md · 상태: Draft 작성: [AI] Architect (2026-06-12) · 구현: app/lib/data/catalog/catalog_repository.dart · 테스트: app/test/data/catalog/catalog_repository_test.dart

1. 시그니처

class CatalogRepository {
  CatalogRepository(this._db);
  final AppDatabase _db;

  Future<List<CatalogItem>> all();
  Future<CatalogItem?> byId(String id);
  Future<List<ReferenceRow>> referencesByIds(List<String> ids);
}

본 fn-*.md 는 all() 의 알고리즘만 다룬다. byId / referencesByIds 는 단순 lookup 이므로 README §7 표 한 줄로 충분.

2. 책임 (단일 책임, 1줄)

3 source (Protocols / BreakProtocols / DietPatterns) 를 단일 List<CatalogItem> 으로 통합 — 본 이슈의 핵심 변환 한 점.

3. 입력

파라미터 타입 제약 설명
(인스턴스 필드) _db AppDatabase non-null seed 가 끝난 DB (시드 안 끝났으면 호출자가 보장)

4. 출력

  • 반환: List<CatalogItem> — 총 47 항목 (protocols 34 + break 8 + diet 5).
    • 각 항목은 ProtocolCatalogItem / BreakCatalogItem / DietCatalogItem 중 하나 (sealed).
    • 정렬: displayCategory.indexid 알파벳 순.
  • 부수효과: DB 3회 read. write 0.

5. 동작 / 알고리즘

1. final protocolRows  = await _db.select(_db.protocols).get();        // 34
2. final breakRows     = await _db.select(_db.breakProtocols).get();  // 8
3. final dietRows      = await _db.select(_db.dietPatterns).get();    // 5

4. final items = <CatalogItem>[];

5. for each p in protocolRows:
     final dc = DisplayCategory.fromProtocolCategory(p.category);
     if (dc == null) {
       throw StateError(
         'unknown protocol category "${p.category}" for id=${p.id}'
       );
     }
     items.add(ProtocolCatalogItem(
       id: p.id,
       title: p.title,
       titleEn: p.titleEn,
       summary: _summary(p.what, fallback: p.title),
       displayCategory: dc,
       evidenceStrength: p.evidenceStrength,
       referenceIds: _decodeIds(p.referenceIdsJson),
       what: p.what,
       whenText: p.whenText,
       dose: p.dose,
       why: p.why,
       how: _decodeList(p.howJson),
       checkText: p.checkText,
       caution: p.caution,
       defaultAnchor: _decodeAnchor(p.defaultAnchorJson),
       minDoseForStart: p.minDoseForStart,
       sourceDoc: p.sourceDoc,
     ));

6. for each b in breakRows:
     items.add(BreakCatalogItem(
       id: b.id,
       title: b.title,
       titleEn: null,
       summary: b.hubermanSummary,    // 이미 1줄 요약 형태
       displayCategory: DisplayCategory.breakHabit,
       evidenceStrength: null,        // BreakProtocol 스키마에 없음
       referenceIds: _decodeIds(b.referenceIdsJson),
       breakCategory: b.category,     // 'alcohol' / 'nicotine' / ...
       hubermanSummary: b.hubermanSummary,
       phases: _decodeList(b.phasesJson),
       defaultCommonFrames: _decodeList(b.defaultCommonFramesJson),
       tools: _decodeList(b.toolsJson),
       medicalWarning: b.medicalWarning,
     ));

7. for each d in dietRows:
     items.add(DietCatalogItem(
       id: d.id,
       title: d.name,
       titleEn: null,
       summary: d.core,
       displayCategory: DisplayCategory.nutrition,
       evidenceStrength: d.evidenceStrength,
       referenceIds: _decodeIds(d.referenceIdsJson),
       name: d.name,
       core: d.core,
       strengths: _decodeList(d.strengthsJson),
       weaknesses: _decodeList(d.weaknessesJson),
       koreanContextFit: d.koreanContextFit,
       starterLevers: _decodeList(d.starterLeversJson),
       medicalWarning: d.medicalWarning,
       linkedProtocolIds: _decodeIds(d.linkedProtocolIdsJson),
     ));

8. items.sort((a, b) {
     final c = a.displayCategory.index - b.displayCategory.index;
     return c != 0 ? c : a.id.compareTo(b.id);
   });

9. return items;

헬퍼 (file-private, 모두 pure)

String _summary(String what, {required String fallback, int max = 60}) {
  final firstSentence = what.split(RegExp(r'[.!?。!?]')).first.trim();
  final s = firstSentence.isEmpty ? fallback : firstSentence;
  return s.length <= max ? s : '${s.substring(0, max - 1)}…';
}

List<String> _decodeIds(String? json) {
  if (json == null) return const [];
  final decoded = jsonDecode(json);
  return decoded is List ? decoded.cast<String>() : const [];
}

List<String> _decodeList(String? json) {
  if (json == null) return const [];
  final decoded = jsonDecode(json);
  return decoded is List ? decoded.map((e) => e.toString()).toList() : const [];
}

Map<String, dynamic>? _decodeAnchor(String? json) {
  if (json == null) return null;
  final decoded = jsonDecode(json);
  return decoded is Map<String, dynamic> ? decoded : null;
}

6. 에러 & 실패 모드

조건 처리 반환/예외
protocol.category 가 DisplayCategory 매핑 0 부팅 직후 첫 호출에서 throw → AsyncValue.error → SnackBar "카탈로그 손상" StateError('unknown protocol category "x" for id=y')
howJson / referenceIdsJson 파싱 실패 빈 리스트 반환 (graceful)
DB 미시드 (kSeededV1Flag 없음) 호출자가 seedInProgressProvider 로 막아야 함. 본 함수는 raw 결과 (0 row) 반환 — UI 가 "준비 중" 표시.
3 source 중 한 source 가 부분 손상 (예: protocols 0 row, break 정상) partial 결과 반환 — 사용자가 break 만 봄.
_summary 가 빈 문자열 fallback (title) 사용.

7. 엣지케이스

  • 47 ≠ 실제 row 수: 시드 갱신 후 row 수 변동 가능. 본 함수는 row 수 비검증 — catalog_repository_test.dart 가 일관성 검증.
  • DisplayCategory 추가: enum 에만 추가하고 매핑은 staging JSON 으로 들어가는 새 protocol 만 채움. 기존 47 항목 매핑 무변화.
  • 중복 id: PK 제약상 발생 불가 — drift 가 보장.
  • 빈 summary: what 이 punctuation 으로만 시작하면 _summary 가 빈 문자열 → fallback 적용.

8. 복잡도 / 성능

  • 시간: O(N) — N=47, 사용자 화면 진입 1회.
  • 공간: O(N) — 47 인스턴스.
  • 호출 빈도: 갤러리 진입 시 1회 (Riverpod cache, 화면 사라질 때 dispose).
  • 실측 추정 latency: < 5ms on 8GB+ Android. 5초 cold start 영향 0.

9. 의존성

  • 호출: AppDatabase.select x 3, dart:convert (jsonDecode), DisplayCategory.fromProtocolCategory.
  • 호출처: catalogItemsProvider (Riverpod FutureProvider<List<CatalogItem>>).

10. 테스트 케이스

  • 정상: seed 가 끝난 in-memory DB → all() 가 47 item + 정렬 (displayCategory.index → id)
  • 카테고리 매핑: 모든 ProtocolCatalogItem.displayCategory ≠ null
  • Break 단일 카테고리: 모든 BreakCatalogItem.displayCategory == breakHabit
  • Diet 단일 카테고리: 모든 DietCatalogItem.displayCategory == nutrition
  • summary 길이: 모든 item 의 summary ≤ 60자
  • referenceIds 디코딩: 빈 JSON / null / 정상 케이스 3개
  • 에러: 손상된 category 값을 직접 DB 에 insert 후 all() → StateError
  • 빈 DB: seed 안 한 DB → 빈 리스트 (throw 안 함)
  • byId 정상 / 미존재: 2 케이스
  • referencesByIds: 일부 매칭 / 전부 미매칭 2 케이스

11. 추적성

  • 인수조건: #226 AC-2 (카테고리 칩 표시), AC-3 (카드 그리드), AC-4 (8 카테고리 매핑), AC-7 (reference 매칭).
  • 관련 ADR: ADR-0004 (본 이슈에서 발행).