이 글은 습관만들기 Pawbit 시리즈의 8번째 기록입니다. (총 9개)

오늘 한 일

  • 아이콘 에셋 변경
  • 퍼즐
    • 퍼즐 맞추는 애니메이션 추가
    • 퍼즐 배치 방식 변경(순차 → 랜덤)
    • 같은 이미지를 난이도별로 재도전 할 수 있도록 변경
  • 갤러리 구현
    • 관람객, 액자, 배열 로직
  • 상점
    • 슬롯 추가권, 수정권 StatChip 추가
  • 체크인 AppBar에 슬롯/수정권 StatChip 추가

막힌 부분

AnimationController.repeat()AnimationStatus.completed를 발생시키지 않는다

문제점: 관람객이 14초 사이클 끝날 때마다 옷 색과 방향을 바꾸려고 했는데 콜백이 한 번도 안 불렸다. 원인: repeat()는 내부적으로 무한 순환 시뮬레이션을 돌리기 때문에 값이 1.0에 도달해도 completed status를 emit하지 않는다. 해결: forward() (한 번만 재생) + 끝나면 다시 _startChar 호출하는 재귀 패턴으로 교체.

// 동작 안 함
controller.repeat();
controller.addStatusListener((status) {
  if (status == AnimationStatus.completed) { } // 절대 호출 안 됨
});

// 사이클마다 콜백 보장
void _startChar(int i) {
  if (!mounted) return;
  setState(() {
    _charIsRTL[i] = _rng.nextBool();
    _charShirtColors[i] = _shirtColors[_rng.nextInt(_shirtColors.length)];
    _charPosTweens[i] = _buildTween(_charIsRTL[i]);
  });
  _walkCtrls[i].forward(from: 0).whenCompleteOrCancel(() => _startChar(i));
}

late final List<T>는 리스트 참조는 final이지만 list[i] = newValue로 원소 교체는 가능하다. setState 안에서 _charPosTweens[i] = ...가 작동하는 이유. C++의 int * const와도 같다.


iOS PageView bounce가 빈 화면처럼 보이는 현상

문제점: 갤러리에 퍼즐이 1개일 때 오른쪽으로 스와이프하면 텅 빈 베이지 화면이 잠깐 나타났다. 원인: iOS 기본 physics(BouncingScrollPhysics)가 마지막 페이지 너머로 overscroll을 허용한다. 그 빈 공간으로 뒤쪽 Stack의 배경이 드러난다. 해결: ClampingScrollPhysics를 parent로 지정해 경계에서 딱 멈추게 한다.

PageView.builder(
  physics: const PageScrollPhysics(parent: ClampingScrollPhysics()),
  ...
)

PageScrollPhysics만 단독 사용하면 iOS에서 BouncingScrollPhysics를 parent로 상속한다. ClampingScrollPhysics를 명시적으로 주면 플랫폼 무관하게 동일하게 동작한다.


Riverpod StreamProvider 타입 변경 시 반드시 hot restart

문제점: provider 반환 타입을 Map<String, DateTime?>Map<String, Set<String>>으로 변경 후 타입 불일치 에러 발생. 원인: hot reload만 하면 Riverpod이 이전 타입의 provider를 메모리에 유지한다. 해결: provider 타입 시그니처가 바뀌면 반드시 hot restart.


Drift stream에 .map() 체이닝으로 Dart 레이어 집계

문제점: 갤러리에서 이미지별 최고 난이도 퍼즐 1개 선택 + 최근 완성순 정렬이 필요했다. 원인: difficulty가 'easy'/'normal'/'hard' 문자열이라 SQL MAX(difficulty)로는 알파벳 순 비교가 되어 잘못된 결과가 나온다. customSelect로 raw SQL을 짜면 서브쿼리 + 순위 매핑이 필요해 복잡해진다. 해결: 전체 완료 레코드를 가져온 뒤 .watch().map()으로 Dart에서 직접 집계. Stream 체인이므로 DB 변경 시 자동 재계산된다.

Stream<List<Puzzle>> watchCompletedPuzzles() =>
    (select(puzzles)..where((p) => p.isCompleted.equals(true)))
        .watch()
        .map((list) {
      const diffRank = {'easy': 0, 'normal': 1, 'hard': 2};

      final best = <String, Puzzle>{};
      for (final p in list) {
        final current = best[p.imageId];
        if (current == null ||
            (diffRank[p.difficulty] ?? 0) > (diffRank[current.difficulty] ?? 0)) {
          best[p.imageId] = p;
        }
      }

      return best.values.toList()
        ..sort((a, b) => (b.completedAt ?? DateTime(0))
            .compareTo(a.completedAt ?? DateTime(0)));
    });

difficulty를 숫자(0/1/2)로 바꾸면 SQL에서 해결 가능하지만, 문자열이 가독성이 더 좋고 나중에 difficulty를 여러 곳에서 비교·정렬할 일이 많아질 때 변경 예정


다음에 할 일

  • 퍼즐 그림 선택 화면 고도화
    • 앱 제공 이미지 목록 표시 (현재는 하드코딩)

Series: 습관만들기 Pawbit


Suggested Posts