- 작은 성공 경험을 시각적으로 기록하는 달력 앱입니다.
- 한국어/영어 다국어 및 라이트/다크 모드 지원합니다.
- 기획, 설계, 디자인, 개발까지 모든 과정을 담당했습니다.
- 기능 단위 브랜치 전략과 PR 작성 등 협업을 가정한 개발 방식을 적용했습니다. (PR 이력)
Flutter Dart MVVM Provider SharedPreferences Intl (i18n) DevTools
날짜 셀을 눌러 목표 달성 여부를 토글하고, 설정 버튼을 통해 목표 수정, 삭제, 순서 변경 등의 동작을 수행할 수 있습니다.
목표 입력 화면에서 이탈 시, 입력한 내용이 저장되지 않는다는 점을 알리는 안내 다이얼로그가 표시됩니다. 저장 후에는 자동으로 캘린더 뷰로 돌아갑니다.
| 목표 생성 | 목표 수정 중 이탈 알림 | 목표 순서 변경 |
|---|---|---|
목표별로 기록 초기화, 목표 삭제, 전체 초기화 등의 정리 작업을 수행할 수 있습니다.
각 동작은 사용자 확인을 거친 후 실행되어, 실수로 인한 데이터 손실을 방지합니다.
| 기록 초기화 | 목표 삭제 | 전체 초기화 |
|---|---|---|
UI와 상태, 유틸리티 계층 간의 명확한 책임 분리와, 단방향 데이터 흐름의 유지를 목표로 설계했습니다.
| class | 역할 |
|---|---|
| GoalCalendarPage | 기록된 목표를 월별로 보여주는 메인 화면. GoalPager와 설정 버튼 등을 포함하여 전체 UI를 구성한다 |
| GoalPager | 세로 스크롤 PageView. 각 목표별 캘린더를 렌더링한다 |
| GoalCalendarContent | 단일 목표의 월간 캘린더 UI. Header·요일·Grid 등으로 분리된다 |
| GoalCalendarGrid | 월간 달력을 구성하며, 날짜 셀과 빈 셀을 구분해 각각 렌더링한다. 날짜 셀의 렌더링은 cellBuilder에 위임된다 |
| CalendarDayCell | 실제 날짜 셀 UI. 기록 여부에 따라 스타일이 다르며, 클릭 시 onTap(goalId, date) 콜백을 상위로 전달한다 |
| class | 역할 |
|---|---|
| GoalViewModel | 목표(Goal)의 생성, 수정, 삭제, 정렬 순서, 저장 및 불러오기 등 목표 관련 전반적인 상태와 비즈니스 로직을 관리한다 |
| RecordViewModel | 각 목표에 해당하는 날짜별 기록 정보와 기록 토글, 저장/불러오기, 기록 삭제, 저장 지연 처리(debounce) 등 동작을 관리한다 |
| CalendarDateViewModel | 보여줄 월 상태 관리. 월 이동 기능 및 canGoToPrevious 등의 파생 상태 계산을 포함한다 |
| class | 역할 |
|---|---|
| EditGoalHandler | 목표 추가·수정 과정에서 사용자 입력 처리, 저장 흐름, 유효성 검사 등을 담당한다 |
| ResetGoalHandler | 목표 초기화 흐름(확인 다이얼로그, 삭제, 에러 처리 등)을 캡슐화하여 제공한다 |
| class | 역할 |
|---|---|
| CalendarGridLayout | 월 시작 요일, 셀 인덱스, 공백 셀 계산 등 달력 레이아웃 계산을 전담한다 |
| DateCompareExtension | 날짜 간 연/월 비교, 기간 계산 등의 기능을 DateTime extension으로 제공한다 |
- 목표 및 기록은
Map<String, DateRecordSet>형태로 관리되며, 날짜 기반의 빠른 조회가 가능합니다. - 정렬에는 Sparse Ordering을 적용해 유연한 순서 변경이 가능합니다.
- 자세한 내용은 데이터 구조 상세 보기에서 확인할 수 있습니다.
- 사용자 인터랙션과 상태 로직의 관심사를 분리하고, Provider 기반 단방향 흐름으로 상태를 관리했습니다.
- e.g.
CalendarDayCell에서 발생한onTap(goalId, date)이벤트는 콜백을 통해 상위 계층으로 전달되며, 최종 처리는GoalCalendarPage에서 이루어집니다. - e.g.
canGoToPrevious(DateTime)는 단순한 플래그가 아니라, 최초 기록일과의 비교를 기반으로 계산되는 파생 상태(computed state)로, 이전 달로 이동 가능한지를 동적으로 판단해 UI 렌더링 조건에 반영합니다.
- e.g.
-
Provider를 사용할때, 상태 접근 방식을 목적과 시점에 따라 구분하여 사용하며, 불필요한 리렌더링을 방지하도록 구성했습니다.
- e.g.
RecordViewModel.getRecords(goalId)는_recordsByGoalId에 저장된 동일 참조 데이터를 반환하며, 셀에서는 이 데이터를 바탕으로.contains(date)연산만 수행합니다. - 달력 특성상 셀의 개수가 많기 때문에 자주 호출되는 메서드에서 계산을 반복하지 않도록 상태를 캐싱하고, 참조 기반 비교를 활용해 상태 변경이 없는 셀은 리렌더링되지 않도록 했습니다.
- e.g.
- 각
CalendarDayCell은Selector<RecordViewModel, bool>을 사용하여, 자신의 날짜에 해당하는 기록 여부만 구독하도록 구성했습니다. - Selector 내부에서는 getRecords(goalId)?.contains(date)를 호출하며, 이는 내부적으로 캐싱된 DateRecordSet을 참조하므로 매번 새로운 데이터를 생성하지 않습니다.
-
문제상황
PageController(initialPage:)는 생성 시점에만 동작해서 목표 추가 이후 index가 반영되지 않음- PageView.builder는 index 기준으로 위젯을 재사용해 index가 같으면 목표가 바뀌어도 위젯이 갱신되지 않음
jumpToPage()를 호출했지만 PageController가 연결되지 않아 (hasClients == false) 스크롤이 미동작
-
해결 방법
- initState에서 index 계산하고, 렌더링 완료(
addPostFrameCallback()) 후jumpToPage()를 호출 - goal.id를 기준으로
ValueKey(goal.id)지정해 위젯 재사용 방지 - focusedGoalForScroll, shouldScrollToFocusedPage 플래그를 사용해 최초 1회만 스크롤 실행 보장 및 중복 스크롤 방지
- initState에서 index 계산하고, 렌더링 완료(
-
실패한 시도
jumpToPage()를initState()에서 즉시 호출 -> PageController와 PageView 연결 전이라 무시됨- 목표 추가 직후
Navigator.pop()으로 돌아오면서 호출 -> 렌더링 중이라 PageController 연결 여부 불안정 - goal index를 파라미터로 직접 넘겨 처리 -> 상태 반영보다 UI가 먼저 그려져 실패
-
문제상황
- 앱 실행 중 경고 발생
Skipped 277 frames! The application may be doing too much work on its main thread.
- 달력의 DayCell이 너무 자주 리빌드되면서 렌더링 성능 저하 발생
- 앱 실행 중 경고 발생
-
해결 방법
- Selector<RecordViewModel, Set<String>>를 사용하여 리빌드 범위 최소화
- dateKey 기반 contains 연산으로 비교 정확도 및 성능 개선
- 셀 위젯에
ValueKey(cellDate)를 부여해 캐싱 유지 및 불필요한 rebuild 방지
-
실패한 시도
- context.watch() -> Selector로 변경 시도
- 셀마다 Selector<RecordViewModel, bool>을 사용해 기록 여부를 판단 -> 모든 셀이 동일한 set을 기반으로 하고 있어 notify시 모든 셀이 다시 그려지며 성능 저하 발생
- context.watch() -> Selector로 변경 시도
-
문제상황
- DateTime을
.contains(date)로 조회할 때, 같은 날이라도 시간 정보가 다르면 false를 반환
- DateTime을
-
해결 방법
- "yyyy-MM-dd" String 포맷 기반 Set 구조로 변경
-
실패한 시도
- DateTime에서 시간 정보를 삭제하고 날짜만 남겨서 사용
DateTime normalize(DateTime dt) => DateTime(dt.year, dt.month, dt.day)
- 직렬화 시 시간 정보가 포함되어 의도와 다른 비교 결과가 나옴
DateTime(2025, 6, 29).toIso8601String(); // "2025-06-29T00:00:00.000"
- DateTime에서 시간 정보를 삭제하고 날짜만 남겨서 사용
-
문제상황
- 목표 및 기록을 전부 삭제한 뒤 목표 리스트가 비어서 크래시 발생
-
해결 방법
- 리스트가 비어 있는지 상태를 확인한 후 fallback 흐름 설계
- 목표명 입력 화면을 띄워 새 목표 생성 유도하고, 새 목표가 생성되면 해당 페이지로 이동시킴
- 리스트가 비어 있는지 상태를 확인한 후 fallback 흐름 설계
-
실패한 시도
- 목표가 없을 시 자동으로 비어있는 목표를 생성해 앱이 정상 동작하도록 처리함 -> 사용자 의도와 다르게 새 목표 생성되어 사용자 경험이 어색함
-
문제상황
- push가 너무 이른 시점(
initState()등)에서 조건 판단 없이 실행되었거나, Navigator가 준비되지 않아 push 무시됨 - 렌더링이 완료되기 전
build()나initState()에서Navigator.push()를 호출하면 위젯 재빌드나 상태 변화로 인해 push가 중복 실행될 수 있음
- push가 너무 이른 시점(
-
해결 방법
- 렌더링이 완료된 이후(
addPostFrameCallback())에 리스트를 재확인해 안전하게 push하도록 수정 _isAddGoalFlowActive플래그로 중복 진입 방지. 사용 후 초기화
- 렌더링이 완료된 이후(
-
실패한 시도
- 플래그 없이 렌더링이 완료된 이후에 조건을 확인하는 방법만 사용함 -> 상태가 유지된 채 build가 반복되면 goals.isEmpty는 계속 true라 push가 여러 번 발생
| 사용 방법 | 목적 및 사용 시점 | 사용 예시 |
|---|---|---|
read<T>() |
콜백 내부에서 상태만 읽고 UI는 반응할 필요 없을 때 | toggleRecord(goalId, date) |
watch<T>() |
전체 위젯이 상태 변경에 반응해야 할 때 | watch<CalendarDateViewModel>(); |
select<T, R>() |
상태의 특정 필드만 구독하고 싶을 때 | select<RecordViewModel, Goal?>((vm) => vm.getGoalById(id)); |
Selector<T, R>() |
특정 하위 위젯 단위에서만 리빌드가 필요할 때 | Selector<RecordViewModel, bool>(..) |