Skip to content

20260216 #17 캘린더 화면 구현 및 할일 연동#19

Merged
EM-H20 merged 57 commits intomainfrom
20260216_#17_캘린더_화면_구현_및_할일_연동
Feb 16, 2026

Hidden character warning

The head ref may contain hidden characters: "20260216_#17_\uce98\ub9b0\ub354_\ud654\uba74_\uad6c\ud604_\ubc0f_\ud560\uc77c_\uc5f0\ub3d9"
Merged

20260216 #17 캘린더 화면 구현 및 할일 연동#19
EM-H20 merged 57 commits intomainfrom
20260216_#17_캘린더_화면_구현_및_할일_연동

Conversation

@EM-H20
Copy link
Contributor

@EM-H20 EM-H20 commented Feb 16, 2026

✨ 변경 사항


✅ 테스트


  • 수동 테스트 완료
  • 테스트 코드 완료

Summary by CodeRabbit

  • 새로운 기능

    • 게스트용 Todo(목록/추가/삭제/카테고리) 및 카테고리 폴더 UI 추가
    • 캘린더 기반 스케줄링(날짜별 보기·필터·추가) 및 주/월 토글, 일정 마커
    • 타이머 연동(실시간 누적, Todo에 시간 반영) 및 Todo 연동 선택 시트
    • Todo 추가/이동/다중선택(일괄삭제) 및 드래그 가능한 하단시트
  • 버그 수정

    • 스와이프 삭제/취소 관련 UI 오류 개선(즉시 제거·안정화)
    • SharedPreferences 초기화 실패 시 안전 복구
  • 개선 사항

    • Todo 전체영역 탭으로 토글 동작 통일 및 UX 일관성 강화

- TodoAddBottomSheet에서 수동 estimatedMinutes 입력 필드 제거
- 할일 시간 표시를 estimatedMinutes → actualMinutes(타이머 기록)로 변경
- TimerState Freezed 모델 생성 (idle/running/paused 상태)
- TimerNotifier 구현 (시작/일시정지/재개/정지 + 할일 시간 누적)
- 타이머 시작 시 할일 선택 바텀시트 추가
- TimerScreen 기능 구현 (ConsumerStatefulWidget 전환)
- toggleTodo/updateTodo: invalidateSelf → 낙관적 업데이트로 불필요한 리빌드 제거
- deleteCategory/deleteCategories: 낙관적 업데이트 패턴 일관성 적용
- _confirmBatchDelete: 할일 먼저 삭제 후 카테고리 삭제로 경쟁조건 해소
- TodoItem.onDelete: 미사용 필드/UI 제거
- TodoItem: 전체 영역 탭 토글 UX 개선 (onTap ?? onToggle)
- TodoEntity: scheduledDate→scheduledDates, completed→completedDates
- TodoModel: JSON 하위호환 마이그레이션 (_migrateJson)
- Provider: toggleTodoForDate, removeDateFromTodo 등 신규 메서드
- DismissibleTodoItem: contextDate별 완료 토글 + 다중날짜 삭제 옵션
- TodoAddBottomSheet: 인라인 캘린더 다중 날짜 선택
- SpaceCalendar: 완료 상태 반영 마커 (초록 체크/파란 점)
- 리뷰 개선: dead code 제거, 미지정 할일 불필요한 리빌드 수정
- TodoListScreen: 새로고침 시 기존 데이터 유지 (스피너 대신 캐시 표시)
- TodoListScreen/CategoryTodoScreen: 에러 객체 노출 제거 → 사용자 친화 메시지
- main.dart: SharedPreferences 초기화 try-catch 추가
@coderabbitai
Copy link

coderabbitai bot commented Feb 16, 2026

Walkthrough

게스트용 Todo 기능을 전면 추가합니다. 도메인·데이터·유스케이스·로컬 데이터소스(SharedPreferences)·레포지토리·Riverpod 프로바이더와 UI(달력, 카테고리, 바텀시트, 리스트, 타이머 연동)를 구현·연결하고 라우팅·초기화 로직을 업데이트합니다.

Changes

Cohort / File(s) Summary
도메인: 엔티티 & 리포지토리
lib/features/todo/domain/entities/todo_entity.dart, .../todo_entity.freezed.dart, lib/features/todo/domain/entities/todo_category_entity.dart, .../todo_category_entity.freezed.dart, lib/features/todo/domain/repositories/todo_repository.dart
Todo 및 TodoCategory 엔티티(Freezed) 추가 및 TodoRepository 인터페이스(CRUD, 카테고리, clearAll) 정의.
도메인: 유스케이스
lib/features/todo/domain/usecases/...
Create/Get/Update/Delete 관련 유스케이스(할 일·카테고리) 다수 추가.
데이터: 모델 & 데이터소스
lib/features/todo/data/models/todo_model.*, .../todo_category_model.*, lib/features/todo/data/datasources/local_todo_datasource.dart
TodoModel/TodoCategoryModel(Freezed, JSON, 마이그레이션) 추가 및 SharedPreferences 기반 LocalTodoDataSource 구현(파싱 오류 복구, clearAll).
데이터: 레포지토리 구현
lib/features/todo/data/repositories/local_todo_repository_impl.dart
LocalTodoRepositoryImpl 추가: CRUD, 카테고리 삭제 시 할당 해제(uncategorized), clearAll 구현.
프레젠테이션: 프로바이더/노티파이어
lib/features/todo/presentation/providers/todo_provider.dart, ...todo_provider.g.dart
Riverpod providers 추가: 데이터소스/레포/유스케이스/노티파이어(TodoListNotifier, CategoryListNotifier) 및 날짜별 셀렉터(unscheduled, todosForDate, todosByDateMap), 낙관적 업데이트 로직 포함.
프레젠테이션: UI — 스크린
lib/features/todo/presentation/screens/todo_list_screen.dart, .../category_todo_screen.dart, lib/features/home/presentation/screens/home_screen.dart, lib/features/timer/presentation/screens/timer_screen.dart
TodoListScreen(카테고리 격자·배치 삭제), CategoryTodoScreen, HomeScreen → ConsumerStatefulWidget로 전환하여 달력/날짜 통합, TimerScreen → ConsumerStatefulWidget로 전환 및 타이머 연동 UI 수정.
프레젠테이션: UI — 위젯/바텀시트
lib/features/todo/presentation/widgets/...
TodoAdd/CategoryAdd/CategoryMove/TodoSelect 바텀시트, SpaceCalendar, CategoryFolderCard, DismissibleTodoItem 등 다수의 위젯 추가 및 TodoItem 탭/삭제 API 변경(onDelete 제거).
타이머: 상태 및 프로바이더
lib/features/timer/presentation/providers/timer_state.dart, timer_state.freezed.dart, timer_provider.dart, timer_provider.g.dart
타이머 상태(TimerState: startTime, accumulatedBeforePause, elapsed getter) 및 TimerNotifier(시작/일시정지/재개/중지, 앱 라이프사이클 처리, linked todo actualMinutes 업데이트) 추가.
라우팅 & 초기화
lib/routes/app_router.dart, lib/routes/route_paths.dart, lib/main.dart, pubspec.yaml
Todo 라우트(카테고리 경로) 등록, SharedPreferences 초기화 및 localTodoDataSourceProvider 오버라이드, table_calendar 의존성 추가 및 한국 로케일 초기화.
테마/상수/스타일 정리
lib/core/theme/app_theme.dart, lib/core/widgets/backgrounds/space_background.dart, lib/features/exploration/.../space_map_background.dart
하드코딩된 BorderRadius·Color를 AppRadius/AppColors로 대체한 곳 업데이트.
인증 흐름
lib/features/auth/presentation/providers/auth_provider.dart, auth_provider.g.dart
게스트 로그아웃 시 todoRepository.clearAll() 호출하도록 로그아웃 흐름 확장(SharedPreferences 초기화 연계).

Sequence Diagram(s)

sequenceDiagram
  participant UI as TimerScreen (UI)
  participant Timer as TimerNotifier
  participant TodoRepo as TodoRepository
  participant LocalDS as LocalTodoDataSource
  participant Storage as SharedPreferences

  UI->>Timer: start(todoId?, todoTitle?)
  Timer->>Timer: set startTime, status=running
  Timer->>UI: periodic UI update (elapsed)
  UI->>Timer: stop()
  Timer->>Timer: compute final elapsed
  alt linked todoId provided
    Timer->>TodoRepo: _updateTodoActualMinutes(todoId, minutes)
    TodoRepo->>LocalDS: updateTodoModel(...)
    LocalDS->>Storage: saveTodos(JSON)
    LocalDS-->>TodoRepo: ok
    TodoRepo-->>Timer: updated entity
  end
  Timer-->>UI: reset state
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Merge Conflict Detection ⚠️ Warning ⚠️ Unable to check for merge conflicts: Invalid branch name format
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목이 제공된 변경 사항의 주요 내용을 정확히 반영합니다. 캘린더 화면 구현과 할일 연동이 PR의 핵심 변경 사항입니다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 20260216_#17_캘린더_화면_구현_및_할일_연동
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch 20260216_#17_캘린더_화면_구현_및_할일_연동
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/core/widgets/space/todo_item.dart (1)

58-63: ⚠️ Potential issue | 🟠 Major

onTap이 설정된 경우 체크박스 탭으로 완료 토글이 불가능합니다.

_buildCheckbox()에서 GestureDetector가 제거되었고, 전체 아이템의 onTapUp에서 (widget.onTap ?? widget.onToggle).call()을 호출합니다. 이 로직으로 인해 onTap이 제공되면 체크박스를 포함한 모든 영역의 탭이 onTap(예: 상세 보기)을 실행하므로, 체크박스 영역을 탭해도 완료 토글이 동작하지 않습니다.

사용자는 체크박스 영역을 탭하면 완료 토글, 나머지 영역을 탭하면 상세 보기를 기대합니다. 체크박스에 별도의 탭 핸들러를 유지하는 것이 필요합니다.

🐛 체크박스 탭 핸들러 복원 제안
   Widget _buildCheckbox() {
-    return AnimatedContainer(
-      duration: TossDesignTokens.animationFast,
-      curve: TossDesignTokens.smoothCurve,
-      width: 24.w,
-      height: 24.w,
-      decoration: BoxDecoration(
-        color: widget.isCompleted ? AppColors.success : Colors.transparent,
-        borderRadius: AppRadius.small,
-        border: Border.all(
-          color: widget.isCompleted
-              ? AppColors.success
-              : AppColors.textTertiary,
-          width: 2,
+    return GestureDetector(
+      onTap: widget.onToggle,
+      behavior: HitTestBehavior.opaque,
+      child: AnimatedContainer(
+        duration: TossDesignTokens.animationFast,
+        curve: TossDesignTokens.smoothCurve,
+        width: 24.w,
+        height: 24.w,
+        decoration: BoxDecoration(
+          color: widget.isCompleted ? AppColors.success : Colors.transparent,
+          borderRadius: AppRadius.small,
+          border: Border.all(
+            color: widget.isCompleted
+                ? AppColors.success
+                : AppColors.textTertiary,
+            width: 2,
+          ),
         ),
-      ),
-      child: widget.isCompleted
-          ? Icon(Icons.check, size: 16.w, color: Colors.white)
-          : null,
+        child: widget.isCompleted
+            ? Icon(Icons.check, size: 16.w, color: Colors.white)
+            : null,
+      ),
     );
   }
🤖 Fix all issues with AI agents
In `@docs/plans/2026-02-14-guest-todo-implementation-plan.md`:
- Line 6: 문서에 단독으로 남아있는 한글 자음 "ㅇ" 오타가 포함되어 있으므로 해당 불필요한 문자("ㅇ")를 삭제해 주세요; 대상은 작성
중 잔여 문자로 남아 있는 단일 문자이며 파일의 문서(guest-todo-implementation-plan) 본문에서 해당 문자를 찾아
제거하면 됩니다.

In `@docs/plans/2026-02-14-timer-todo-integration.md`:
- Around line 356-366: The "연동 없이 시작" button currently calls
Navigator.of(context).pop(null) which conflates user dismiss with "start without
integration"; change the bottom sheet return type to Object? (so it can return
true, TodoEntity, or null) and update the AppButton onPressed to pop(true)
instead of pop(null); ensure anywhere the sheet is opened (referenced by the
widget containing this AppButton) and the handler in timer_screen.dart::_onStart
treats true as "start without integration", TodoEntity as a selected todo, and
null as a dismiss.

In `@docs/plans/2026-02-15-category-folder-management.md`:
- Around line 1114-1136: The TodoListScreen usage of CategoryFolderCard is
missing a Hero to match the existing Hero in CategoryTodoScreen (tag format
'category_<id>'); wrap the emoji+name Row in a Hero with tag
'category_${cat.id}' (or wrap the CategoryFolderCard in a Hero whose child
contains a Material(color: Colors.transparent) to avoid missing material) so the
origin and destination use the identical tag and widget structure; ensure the
onTap navigation to RoutePaths.categoryTodoPath(cat.id) and the extra payload
remain unchanged and that CategoryFolderCard (or its internal Row) is the same
widget structure as the Hero child in CategoryTodoScreen.

In `@docs/plans/2026-02-15-dismissible-delete-error-fix.md`:
- Around line 40-47: The deleteTodo example performs an optimistic update but
never captures or restores the previous state on failure; modify deleteTodo to
store the current list in a local variable (e.g., previousState) before setting
the optimistic AsyncData, then call useCase.execute(id) inside a try/catch and
on catch restore state = AsyncData(previousState) (and optionally rethrow or
surface the error); reference the deleteTodo function and the
useCase.execute(id) call and ensure the rollback uses the same state mutation
pattern (state = AsyncData(...)) so the example is complete and consistent.

In `@docs/plans/2026-02-16-calendar-todo-integration.md`:
- Line 43: The line contains a hardcoded absolute path
"/Users/luca/workspace/Flutter_Project/space_study_ship"; replace it with a
relative invocation or a placeholder (e.g., run the build command from the repo
root or use a variable like {PROJECT_ROOT}) so other developers can run `flutter
pub run build_runner build --delete-conflicting-outputs` without a user-specific
path; update the surrounding text to instruct running the command from the
project root or to substitute their local path.

In `@docs/plans/2026-02-16-coderabbit-review-fixes.md`:
- Around line 156-176: When SharedPreferences initialization fails, avoid
leaving localTodoDataSourceProvider unimplemented; create or use an in-memory
fallback implementation and inject it into ProviderScope overrides instead of
calling ProviderScope(child: MyApp()) with no overrides. Specifically, catch the
exception around SharedPreferences prefs init and in the catch block override
localTodoDataSourceProvider with a safe fallback (e.g.,
InMemoryLocalTodoDataSource or a disabled/no-op Todo data source) so that
LocalTodoDataSource(prefs) callers and the UnimplementedError('SharedPreferences
override 필요') are never reached; ensure the override uses the same provider
symbol localTodoDataSourceProvider and that MyApp is started via
ProviderScope(overrides: [...], child: const MyApp()) even in the fallback path.

In `@docs/plans/2026-02-16-todo-batch-delete.md`:
- Line 1: The document jumps from an h1 ("TodoListScreen 편집 모드 + 일괄 삭제 구현")
directly to an h3 (the current heading at line 13), triggering MD001; insert an
appropriate h2 heading between the h1 and that h3 (e.g., a short section title
that groups the h3 under it) so the heading levels progress H1 → H2 → H3 and the
MD001 lint warning is resolved.

In `@docs/plans/2026-02-16-todo-list-ui-ux-implementation.md`:
- Line 15: 문서의 마크다운 헤딩 레벨이 불일치합니다: 현재 "Task 1: CategoryFolderCard를 정사각형 그리드
레이아웃으로 변경" 등 항목들이 h3(###)로 되어 있으나 h1 다음에 바로 오므로 h2(##)로 변경해야 합니다; Task 1, Task
2, Task 3, Task 4 등 각 작업 제목(예: "Task 1: CategoryFolderCard를 정사각형 그리드 레이아웃으로
변경")의 앞서 붙은 ###를 모두 ##로 바꿔 일관된 헤딩 계층을 맞추고 문서의 다른 섹션들도 동일한 규칙(## 사용)으로 수정하세요.

In `@docs/plans/2026-02-16-todo-screen-transition-fix.md`:
- Around line 1-13: 문서의 헤딩 레벨이 건너뛰어 MD001 경고가 발생하므로 첫 번째 헤딩(`#`)과 이후 헤딩(`###`)
사이에 중간 단계인 `##` 헤딩을 추가하거나 `###`를 `##`로 내리도록 수정하여 h1 → h2 → h3 계층을 유지하세요; 문제 지점은
파일 시작의 `#` 표기와 13행의 `###` 표기로, 둘 사이에 적절한 `##` 제목(예: 섹션 소제목)을 삽입하거나 기존 `###`을
`##`로 변경하면 됩니다.

In `@lib/features/auth/presentation/providers/auth_provider.dart`:
- Around line 300-306: The logout flow currently calls await todoRepo.clearAll()
(via todoRepositoryProvider) and if that throws the state assignment state =
const AsyncValue.data(null) is never reached; wrap the clearAll call in a
try/catch (surround the await todoRepo.clearAll() call in a try block), log the
exception (use debugPrint or the existing logger) inside the catch, and ensure
the code still sets state = const AsyncValue.data(null) and returns regardless
of clearAll success so that failing to clear todos does not block logout.

In `@lib/features/timer/presentation/providers/timer_provider.dart`:
- Around line 103-117: _updateTodoActualMinutes silently skips updates when
todoListNotifierProvider.valueOrNull is null; change it to retry or persist the
pending update instead of dropping it: if
ref.read(todoListNotifierProvider).valueOrNull == null then either (a) enqueue
the (todoId, additionalMinutes) into a pendingUpdates map on the same provider
and schedule application when todoListNotifierProvider emits a non-null value,
or (b) use ref.listen(todoListNotifierProvider, ...) to attempt the update once
the provider transitions to data (call
todoListNotifierProvider.notifier.updateTodo with the computed new
actualMinutes), and ensure you log a warning when you defer and limit retries to
avoid infinite loops; update references to todoListNotifierProvider,
todoListNotifierProvider.notifier, _updateTodoActualMinutes, and updateTodo
accordingly.
- Around line 39-52: The start() method currently cancels _timer and overwrites
state when called while the timer is already running, losing the previous
session's measured time and preventing the previous linked todo's actualMinutes
update; fix by adding a guard or explicit flush: if state.status ==
TimerStatus.running then either return early to ignore duplicate starts or call
the existing stop() logic (or a new helper like _finalizeSession()) to persist
accumulated time and update linked todo actualMinutes before proceeding to
cancel/reset and start a new session; update references: start(),
stop()/_finalizeSession(), _timer, state, TimerStatus.running, and
_startPeriodicUiUpdate accordingly.

In `@lib/features/timer/presentation/widgets/todo_select_bottom_sheet.dart`:
- Around line 123-124: The error branch currently returns a silent SizedBox
(error: (_, _) => const SizedBox.shrink()) so users see no feedback on load
failure; replace that handler in the AsyncValue.when (in
todo_select_bottom_sheet.dart) with a visible error UI (e.g., Center with an
error Text and a retry ElevatedButton) that also surfaces the error message (use
the error object from the handler) and triggers a retry (call your retry method
or ref.refresh/fetchTodos from the button’s onPressed); also consider logging
the error for diagnostics.

In `@lib/features/todo/data/datasources/local_todo_datasource.dart`:
- Around line 18-26: Wrap the JSON decoding in getTodos() with a try-catch
around json.decode and the mapping so a FormatException or other parsing error
doesn't crash the app; on error log or silently handle it, return an empty
List<TodoModel> (or clear the corrupted value from SharedPreferences using
_prefs.remove(_todosKey)) and ensure you still return [] instead of throwing;
apply the same defensive decoding pattern to getCategories() (the same try-catch
around json.decode and the mapping, returning an empty list or removing the
corrupted _categories key) and reference TodoModel.fromJson / the _todosKey and
the categories mapping function when making the changes.

In `@lib/features/todo/presentation/screens/todo_list_screen.dart`:
- Around line 348-362: The code checks mounted before the deletion calls but not
after the awaits, risking _toggleEditMode() (which calls setState) being invoked
after dispose; update the flow in the on-confirm branch to re-check mounted
after each awaited operation (or after both awaits) and only call
_toggleEditMode() when mounted is still true. Specifically, around the calls to
todoListNotifierProvider.notifier.deleteTodos(...) and
categoryListNotifierProvider.notifier.deleteCategories(...), ensure you bail out
if mounted becomes false before calling _toggleEditMode(), referencing
_selectedTodoIds, _selectedCategoryIds, deleteTodos, deleteCategories and
_toggleEditMode to locate the affected logic.

In `@lib/main.dart`:
- Around line 172-194: When SharedPreferences initialization fails you currently
call runApp(ProviderScope(child: MyApp())) which leaves
localTodoDataSourceProvider un-overridden and will throw UnimplementedError when
watched; instead, always provide a safe fallback override: in the catch block
create and pass an in-memory/fallback implementation (e.g. an
EmptyLocalTodoDataSource or InMemoryLocalTodoDataSource) to
localTodoDataSourceProvider.overrideWithValue so ProviderScope always receives a
concrete LocalTodoDataSource instance; update the catch path to
runApp(ProviderScope(overrides:
[localTodoDataSourceProvider.overrideWithValue(FALLBACK_INSTANCE)], child: const
MyApp())) and add/locate the fallback class (EmptyLocalTodoDataSource or
similar) that implements the same interface as LocalTodoDataSource.

In `@pubspec.yaml`:
- Line 38: Update the table_calendar dependency version in pubspec.yaml from
^3.1.3 to the current stable ^3.2.0; locate the table_calendar entry in
pubspec.yaml and change the version specifier so package resolution uses the
latest stable release.
🧹 Nitpick comments (22)
docs/plans/2026-02-15-dismissible-delete-error-fix.md (1)

15-15: 코드 블록에 언어 지정 누락 (markdownlint MD040).

Line 15의 fenced code block에 언어가 지정되어 있지 않습니다. 가독성과 린트 통과를 위해 언어를 명시해주세요 (예: ```text).

lib/core/theme/app_theme.dart (1)

70-73: 바텀시트 테마에 하드코딩된 radius가 남아 있습니다.

다른 테마 요소들은 모두 AppRadius 상수로 전환되었지만, bottomSheetThemeRadius.circular(20)은 그대로입니다. 일관성을 위해 AppRadius 상수 사용을 고려해 주세요.

lib/routes/app_router.dart (1)

164-181: 할일 목록 및 카테고리 라우트 추가 - 잘 구성되었습니다.

한 가지 방어적 개선 사항: Line 173에서 state.extra as Map<String, dynamic>? 캐스팅은 extraMap이 아닌 다른 타입이 전달될 경우 TypeError가 발생합니다. 딥링크나 예상치 못한 네비게이션 경로에 대비해 안전한 캐스팅을 고려해 볼 수 있습니다.

🛡️ 방어적 캐스팅 제안
-                          final extra = state.extra as Map<String, dynamic>?;
+                          final extra = state.extra is Map<String, dynamic>
+                              ? state.extra as Map<String, dynamic>
+                              : null;
docs/plans/2026-02-15-bottomsheet-draggable-and-timer-dismiss-fix.md (2)

146-150: 에러 상태가 사용자에게 아무 피드백 없이 무시됩니다.

error: (_, _) => const SizedBox.shrink() 는 할일 목록 로드 실패 시 빈 화면만 표시합니다. 사용자가 문제 원인을 알 수 없으므로, 최소한 에러 메시지나 재시도 버튼을 표시하는 것이 좋습니다.


35-36: Object? 반환 타입은 타입 안전하지 않습니다.

null/true/TodoEntity 세 가지 케이스를 Object?로 구분하는 방식은 동작하지만, 호출 측에서 타입 실수가 발생하기 쉽습니다. 향후 sealed class나 enum 기반 result 타입으로 개선하면 컴파일 타임에 모든 케이스를 강제할 수 있습니다.

lib/features/todo/presentation/widgets/category_move_bottom_sheet.dart (1)

74-98: 카테고리 목록이 많을 경우 오버플로우 위험.

카테고리 목록이 Column 안에 직접 렌더링되어 있어, 카테고리 수가 많아지면 화면을 넘어 오버플로우가 발생할 수 있습니다. Flexible + ListView로 감싸거나, 전체 ColumnSingleChildScrollView로 래핑하는 것을 권장합니다.

또한, Line 97의 error 상태에서 SizedBox.shrink()를 반환하면 사용자에게 오류 피드백이 전혀 없습니다. 최소한 간단한 에러 메시지나 재시도 옵션을 제공하는 것이 좋습니다.

♻️ 오버플로우 방지 및 에러 처리 개선 제안
          // 카테고리 목록
-         categoriesAsync.when(
-           data: (categories) => Column(
-             children: categories.map((cat) {
-               final isSelected = cat.id == currentCategoryId;
-               return _CategoryOption(
-                 emoji: cat.emoji ?? '📁',
-                 name: cat.name,
-                 isSelected: isSelected,
-                 onTap: () {
-                   if (isSelected) {
-                     Navigator.of(context).pop();
-                   } else {
-                     Navigator.of(context).pop(cat.id);
-                   }
-                 },
-               );
-             }).toList(),
-           ),
-           loading: () => Padding(
-             padding: AppPadding.all16,
-             child: const Center(child: CircularProgressIndicator()),
-           ),
-           error: (e, st) => const SizedBox.shrink(),
-         ),
+         Flexible(
+           child: categoriesAsync.when(
+             data: (categories) => ListView(
+               shrinkWrap: true,
+               children: categories.map((cat) {
+                 final isSelected = cat.id == currentCategoryId;
+                 return _CategoryOption(
+                   emoji: cat.emoji ?? '📁',
+                   name: cat.name,
+                   isSelected: isSelected,
+                   onTap: () {
+                     if (isSelected) {
+                       Navigator.of(context).pop();
+                     } else {
+                       Navigator.of(context).pop(cat.id);
+                     }
+                   },
+                 );
+               }).toList(),
+             ),
+             loading: () => Padding(
+               padding: AppPadding.all16,
+               child: const Center(child: CircularProgressIndicator()),
+             ),
+             error: (e, st) => Padding(
+               padding: AppPadding.all16,
+               child: Center(
+                 child: Text(
+                   '카테고리를 불러올 수 없습니다',
+                   style: AppTextStyles.label_16.copyWith(
+                     color: AppColors.textTertiary,
+                   ),
+                 ),
+               ),
+             ),
+           ),
+         ),
docs/plans/2026-02-16-todo-batch-delete.md (1)

22-53: 순차적 삭제의 부분 실패 시 데이터 정합성 문제.

deleteTodosdeleteCategories 모두 for 루프에서 개별 await로 삭제를 수행합니다. 중간에 하나가 실패하면:

  • deleteTodos: 낙관적 업데이트로 UI에서는 이미 전부 제거되었지만, 실제로는 일부만 삭제된 상태에서 previousState로 롤백됩니다 (이 부분은 적절함).
  • deleteCategories: 낙관적 업데이트 없이 루프 후 invalidateSelf()만 호출하므로, 중간 실패 시 일부 카테고리만 삭제되고 에러 처리가 없습니다.

deleteCategories에도 try-catch와 에러 핸들링을 추가하는 것을 권장합니다.

lib/features/timer/presentation/providers/timer_state.dart (1)

19-25: elapsed getter의 DateTime.now() 사용 — 테스트 시 고려사항.

elapsedDateTime.now()에 의존하므로 비결정적이며 단위 테스트가 어렵습니다. 현재 타이머 구현에서는 합리적인 패턴이지만, 추후 테스트가 필요하다면 clock 패키지 등을 통해 시간을 주입할 수 있도록 하는 것을 고려해보세요.

또한, 이 getter는 호출 시점마다 값이 달라지므로, UI에서 주기적으로 리빌드를 트리거하는 메커니즘(예: Timer.periodic)이 필요합니다.

lib/features/todo/data/repositories/local_todo_repository_impl.dart (1)

45-47: 동시 쓰기 시 데이터 유실 가능성 (현재 환경에서는 낮은 위험)

getTodos()add()saveTodos() 패턴이 read-modify-write 방식이므로, 두 개의 비동기 쓰기 작업이 동시에 실행되면 먼저 읽은 쪽의 변경이 유실될 수 있습니다. 현재 Flutter 단일 UI 스레드 환경에서는 위험이 낮지만, 향후 동시 호출 가능성이 있다면 직렬화(큐)를 고려해 주세요.

lib/features/todo/presentation/widgets/category_add_bottom_sheet.dart (1)

57-61: 반환 타입을 Map<String, dynamic> 대신 타입이 있는 객체로 변경 고려

Navigator.pop({'name': name, 'emoji': _selectedEmoji})에서 Map<String, dynamic>을 반환하고 있습니다. 호출 측에서 키 문자열을 직접 참조해야 하므로 오타에 취약합니다. 간단한 클래스나 record를 사용하면 타입 안전성이 높아집니다.

lib/features/home/presentation/widgets/space_calendar.dart (1)

43-44: lastDay가 2030년으로 하드코딩되어 있습니다

DateTime.utc(2030, 12, 31)이 하드코딩되어 있어 2030년 이후 캘린더 탐색이 불가능합니다. 현재 날짜 기준 동적 계산을 고려해 주세요.

♻️ 동적 lastDay 예시
-      firstDay: DateTime.utc(2024, 1, 1),
-      lastDay: DateTime.utc(2030, 12, 31),
+      firstDay: DateTime.utc(2020, 1, 1),
+      lastDay: DateTime.now().add(const Duration(days: 365 * 2)),
lib/features/timer/presentation/widgets/todo_select_bottom_sheet.dart (1)

187-199: showTodoSelectBottomSheet의 반환 타입 Object?는 타입 안전성이 부족합니다.

null(dismiss), true(연동 없이 시작), TodoEntity(선택)를 하나의 Object?로 반환하는 패턴은 호출 측에서 런타임 타입 체크(is 검사)에 의존하게 됩니다. sealed class 또는 전용 result 타입을 도입하면 컴파일 타임에 모든 케이스를 보장할 수 있습니다.

♻️ sealed class 활용 예시
sealed class TodoSelectResult {}
class TodoSelectDismissed extends TodoSelectResult {}
class TodoSelectWithoutLink extends TodoSelectResult {}
class TodoSelectLinked extends TodoSelectResult {
  final TodoEntity todo;
  TodoSelectLinked(this.todo);
}
docs/plans/2026-02-14-guest-todo-implementation-plan.md (1)

3-3: AI 프롬프트 지시문이 설계 문서에 포함되어 있습니다.

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans... 내용은 AI 도구를 위한 프롬프트로, 프로젝트 설계 문서로서의 가독성을 떨어뜨릴 수 있습니다. 향후 관리를 위해 별도 분리하거나 제거를 고려해 주세요.

docs/plans/2026-02-16-dismissible-todo-item-dry.md (1)

92-94: onDismissed에서의 삭제가 실패할 경우 UI 불일치가 발생할 수 있습니다.

confirmDismisstrue를 반환하면 위젯이 이미 화면에서 제거된 후 onDismissed에서 deleteTodo가 호출됩니다. 만약 삭제가 실패하면 아이템은 화면에서 사라졌지만 데이터에는 남아있는 상태가 됩니다. 현재 로컬 SharedPreferences 기반이므로 실패 가능성은 낮지만, 향후 원격 연동 시 confirmDismiss 내에서 삭제까지 처리하고 결과에 따라 true/false를 반환하는 패턴이 더 안전합니다.

docs/plans/2026-02-16-todo-list-ui-ux-implementation.md (1)

306-420: 플랜 문서의 코드 스니펫이 현재 다중 날짜 모델과 불일치합니다.

todo.completed (Lines 313, 408)는 현재 엔티티에 존재하지 않는 레거시 필드입니다. 실제 구현은 todo.isFullyCompletedDismissibleTodoItem을 사용합니다. 또한 toggleTodo(todo) (Line 412)도 toggleTodoForDate(todo, date)로 변경되었습니다.

이 플랜 문서를 향후 참조용으로 유지한다면, 상단에 "⚠️ 이 문서는 초기 설계안이며, 실제 구현은 다중 날짜 모델(scheduledDates/completedDates)로 변경됨" 같은 안내를 추가하는 것을 권장합니다.

lib/features/todo/presentation/widgets/todo_add_bottom_sheet.dart (1)

302-376: 인라인 캘린더 스타일링이 우주 테마와 잘 어울립니다.

onDaySelected에서 _calendarFocusedDay = focusedDay (Line 317)가 setState 밖에서 할당되지만, _toggleDatesetState가 먼저 호출되어 리빌드 전에 값이 설정되므로 정상 동작합니다. 다만 명시적으로 setState 안에 포함시키면 의도가 더 명확해집니다.

♻️ 선택적 개선안
  onDaySelected: (selectedDay, focusedDay) {
-   _toggleDate(selectedDay);
-   _calendarFocusedDay = focusedDay;
+   setState(() {
+     _calendarFocusedDay = focusedDay;
+     final normalized = DateTime(selectedDay.year, selectedDay.month, selectedDay.day);
+     final index = _selectedScheduledDates.indexWhere((d) => d == normalized);
+     if (index >= 0) {
+       _selectedScheduledDates.removeAt(index);
+     } else {
+       _selectedScheduledDates.add(normalized);
+     }
+   });
  },
docs/plans/2026-02-16-calendar-todo-integration-design.md (1)

17-41: 설계 문서의 데이터 모델이 실제 구현과 불일치합니다.

이 문서는 scheduledDate: DateTime? (단일 날짜)와 completed: bool을 기술하고 있지만, 실제 구현은 다중 날짜 모델(scheduledDates: List<DateTime>, completedDates: List<DateTime>)로 진행되었습니다.

향후 혼선을 방지하기 위해, 문서 상단에 superseded 안내를 추가하거나, 실제 구현에 맞게 모델 섹션을 갱신하는 것을 권장합니다.

lib/features/timer/presentation/screens/timer_screen.dart (1)

25-31: 타이머 실행 중 매초 전체 위젯 트리가 리빌드됩니다.

ref.watch(timerNotifierProvider)build 메서드 최상단에서 호출되어 매초 elapsed가 변경될 때마다 전체 Scaffold 트리(통계 카드, AppBar 등 포함)가 리빌드됩니다.

타이머 링과 시간 표시 부분만 Consumer 위젯으로 감싸면 불필요한 리빌드를 줄일 수 있습니다.

docs/plans/2026-02-16-calendar-todo-integration.md (1)

22-38: 계획서의 scheduledDate (단수)와 실제 구현의 scheduledDates (복수)가 불일치합니다.

계획서 전반에서 DateTime? scheduledDate로 기술되어 있지만, 실제 구현(todo_model.freezed.dart, todo_repository.dart)에서는 List<DateTime> scheduledDatesList<DateTime> completedDates로 복수 날짜를 지원합니다. 향후 참조 시 혼동을 줄이기 위해 계획서를 실제 구현에 맞게 업데이트하거나, 상단에 "이 계획서는 초기 설계이며, 실제 구현은 multi-date 방식으로 변경되었음"을 명시해 주세요.

lib/features/todo/presentation/providers/todo_provider.dart (1)

197-211: deleteTodos에서 순차 삭제 실패 시 데이터 불일치 가능성.

루프 내에서 순차적으로 useCase.execute(id)를 호출하므로, 중간에 실패하면 일부만 삭제되고 state는 이전 상태로 롤백됩니다. 로컬 SharedPreferences 기반이라 실패 확률은 낮지만, 향후 원격 저장소 전환 시 주의가 필요합니다.

docs/plans/2026-02-15-category-folder-management.md (2)

1045-1046: 타입 안정성을 개선할 수 있습니다.

메서드 시그니처에서 AsyncValue<List<dynamic>>을 사용하고 있습니다. 더 구체적인 엔티티 타입을 사용하면 타입 안정성이 향상되고 IDE 자동완성 및 컴파일 타임 오류 검출이 개선됩니다.

// 현재
AsyncValue<List<dynamic>> todosAsync,
AsyncValue<List<dynamic>> categoriesAsync,

// 권장
AsyncValue<List<TodoEntity>> todosAsync,
AsyncValue<List<TodoCategoryEntity>> categoriesAsync,

provider에서 반환하는 실제 타입이 이미 TodoEntityTodoCategoryEntity라면, 이를 명시적으로 선언하는 것이 좋습니다.


348-353: 에러 처리를 개선하는 것을 고려하세요.

CategoryMoveBottomSheet에서 카테고리 목록 로딩 실패 시 SizedBox.shrink()를 반환하여 에러를 조용히 무시합니다(352줄). 이는 디버깅을 어렵게 만들 수 있습니다.

사용자에게 간단한 에러 메시지를 표시하거나 최소한 로그를 남기는 것을 권장합니다:

error: (e, st) => Padding(
  padding: AppPadding.all16,
  child: Text(
    '카테고리를 불러올 수 없습니다',
    style: AppTextStyles.tag_12.copyWith(
      color: AppColors.textTertiary,
    ),
    textAlign: TextAlign.center,
  ),
),


**Goal:** 게스트 모드에서 SharedPreferences 기반 로컬 할일(Todo) CRUD + 카테고리(폴더) 기능을 구현하고, HomeScreen의 하드코딩된 할일을 실제 데이터로 교체한다.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

오타: 불필요한 "ㅇ" 문자가 남아 있습니다.

Line 6에 한글 자음 "ㅇ"가 단독으로 남아 있습니다. 타이핑 중 잔여 문자로 보이며 삭제가 필요합니다.

🤖 Prompt for AI Agents
In `@docs/plans/2026-02-14-guest-todo-implementation-plan.md` at line 6, 문서에 단독으로
남아있는 한글 자음 "ㅇ" 오타가 포함되어 있으므로 해당 불필요한 문자("ㅇ")를 삭제해 주세요; 대상은 작성 중 잔여 문자로 남아 있는 단일
문자이며 파일의 문서(guest-todo-implementation-plan) 본문에서 해당 문자를 찾아 제거하면 됩니다.

Comment on lines +356 to +366

// 할일 연동 없이 시작 버튼
Padding(
padding: AppPadding.horizontal20,
child: AppButton(
text: '연동 없이 시작',
onPressed: () => Navigator.of(context).pop(null),
width: double.infinity,
style: AppButtonStyle.outlined,
),
),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

"연동 없이 시작" 버튼이 null을 반환하여 dismiss와 구분 불가합니다.

Line 362에서 Navigator.of(context).pop(null)을 사용하면, 바텀시트 dismiss(백드롭 탭)와 동일한 값이 반환됩니다. timer_screen.dart_onStart에서 null이면 타이머를 시작하지 않으므로, "연동 없이 시작" 기능이 동작하지 않게 됩니다.

반환 타입을 Object?로 변경하고, "연동 없이 시작"은 true를, 할일 선택은 TodoEntity를, dismiss는 null을 반환하도록 설계해야 합니다.

🤖 Prompt for AI Agents
In `@docs/plans/2026-02-14-timer-todo-integration.md` around lines 356 - 366, The
"연동 없이 시작" button currently calls Navigator.of(context).pop(null) which
conflates user dismiss with "start without integration"; change the bottom sheet
return type to Object? (so it can return true, TodoEntity, or null) and update
the AppButton onPressed to pop(true) instead of pop(null); ensure anywhere the
sheet is opened (referenced by the widget containing this AppButton) and the
handler in timer_screen.dart::_onStart treats true as "start without
integration", TodoEntity as a selected todo, and null as a dismiss.

Comment on lines +1114 to +1136
...categories.map((cat) {
final catTodos =
todos.where((t) => t.categoryId == cat.id).toList();
final completedCount =
catTodos.where((t) => t.completed).length;

return Padding(
padding: EdgeInsets.only(bottom: 8.h),
child: CategoryFolderCard(
name: cat.name,
emoji: cat.emoji,
todoCount: catTodos.length,
completedCount: completedCount,
onTap: () {
context.push(
RoutePaths.categoryTodoPath(cat.id),
extra: {'name': cat.name, 'emoji': cat.emoji},
);
},
onDelete: () =>
_deleteCategory(context, ref, cat.id, cat.name),
),
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Hero 애니메이션 구현이 누락되었습니다.

Task 5의 TodoListScreen 코드에서 CategoryFolderCard를 사용하는 부분(1114-1136줄)에 Hero 위젯이 없습니다. Hero 애니메이션이 작동하려면 출발점(TodoListScreen)과 도착점(CategoryTodoScreen)에 모두 동일한 태그를 가진 Hero 위젯이 필요합니다.

  • 현재 상태: CategoryTodoScreen의 AppBar에만 Hero(tag: 'category_$categoryId') 구현됨 (778-797줄)
  • 문제: TodoListScreen에서 해당 태그를 가진 Hero 위젯이 없음
  • v1 수정사항(29줄): "Hero를 이모지+이름 Row만 감싸기 (양쪽 동일 위젯)"
  • Task 5 요구사항(975줄): "Hero child: 이모지+이름 Row만 감싸기 (CategoryTodoScreen과 동일 구조)"

CategoryFolderCard는 기존 위젯(20줄)이지만, Hero 애니메이션을 위해서는:

  1. CategoryFolderCard 내부에 Hero 위젯을 추가하거나,
  2. CategoryFolderCard 사용 시 Hero로 감싸야 합니다.

통합 테스트 체크리스트(1325줄)에서도 "폴더 카드 탭 → Hero 애니메이션 → CategoryTodoScreen 전환"을 기대하고 있어, 이 구현이 필수적입니다.

🎭 제안하는 수정 방안

CategoryFolderCard를 Hero로 감싸도록 수정:

return Padding(
  padding: EdgeInsets.only(bottom: 8.h),
  child: Hero(
    tag: 'category_${cat.id}',
    child: Material(
      color: Colors.transparent,
      child: CategoryFolderCard(
        name: cat.name,
        emoji: cat.emoji,
        todoCount: catTodos.length,
        completedCount: completedCount,
        onTap: () {
          context.push(
            RoutePaths.categoryTodoPath(cat.id),
            extra: {'name': cat.name, 'emoji': cat.emoji},
          );
        },
        onDelete: () =>
            _deleteCategory(context, ref, cat.id, cat.name),
      ),
    ),
  ),
);

또는 CategoryFolderCard 위젯 자체를 수정하여 내부의 이모지+이름 Row를 Hero로 감싸는 방법도 고려할 수 있습니다.

🤖 Prompt for AI Agents
In `@docs/plans/2026-02-15-category-folder-management.md` around lines 1114 -
1136, The TodoListScreen usage of CategoryFolderCard is missing a Hero to match
the existing Hero in CategoryTodoScreen (tag format 'category_<id>'); wrap the
emoji+name Row in a Hero with tag 'category_${cat.id}' (or wrap the
CategoryFolderCard in a Hero whose child contains a Material(color:
Colors.transparent) to avoid missing material) so the origin and destination use
the identical tag and widget structure; ensure the onTap navigation to
RoutePaths.categoryTodoPath(cat.id) and the extra payload remain unchanged and
that CategoryFolderCard (or its internal Row) is the same widget structure as
the Hero child in CategoryTodoScreen.

Comment on lines +40 to +47
Future<void> deleteTodo(String id) async {
// 낙관적 업데이트: Loading 없이 즉시 리스트에서 제거
state = AsyncData(
state.valueOrNull?.where((t) => t.id != id).toList() ?? [],
);
final useCase = ref.read(deleteTodoUseCaseProvider);
await useCase.execute(id);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

낙관적 업데이트 후 실패 시 롤백 처리가 코드 스니펫에 누락되어 있습니다.

변경 후 코드에서 useCase.execute(id) 실패 시 이전 상태로 복원하는 로직이 없습니다. 다른 계획 문서에서 롤백이 완료되었다고 언급하고 있으나, 이 문서의 코드 예시가 불완전하면 구현 시 혼란을 줄 수 있습니다. 실패 시 previousState 복원 패턴을 예시에 포함하는 것이 좋겠습니다.

🤖 Prompt for AI Agents
In `@docs/plans/2026-02-15-dismissible-delete-error-fix.md` around lines 40 - 47,
The deleteTodo example performs an optimistic update but never captures or
restores the previous state on failure; modify deleteTodo to store the current
list in a local variable (e.g., previousState) before setting the optimistic
AsyncData, then call useCase.execute(id) inside a try/catch and on catch restore
state = AsyncData(previousState) (and optionally rethrow or surface the error);
reference the deleteTodo function and the useCase.execute(id) call and ensure
the rollback uses the same state mutation pattern (state = AsyncData(...)) so
the example is complete and consistent.


**Step 2: build_runner 실행**

Run: `cd /Users/luca/workspace/Flutter_Project/space_study_ship && flutter pub run build_runner build --delete-conflicting-outputs`
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

로컬 절대 경로가 하드코딩되어 있습니다.

/Users/luca/workspace/Flutter_Project/space_study_ship 경로가 포함되어 있어, 다른 개발자 환경에서는 의미가 없습니다. 상대 경로나 플레이스홀더로 대체하는 것이 좋습니다.

🤖 Prompt for AI Agents
In `@docs/plans/2026-02-16-calendar-todo-integration.md` at line 43, The line
contains a hardcoded absolute path
"/Users/luca/workspace/Flutter_Project/space_study_ship"; replace it with a
relative invocation or a placeholder (e.g., run the build command from the repo
root or use a variable like {PROJECT_ROOT}) so other developers can run `flutter
pub run build_runner build --delete-conflicting-outputs` without a user-specific
path; update the surrounding text to instruct running the command from the
project root or to substitute their local path.

Comment on lines +123 to +124
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => const SizedBox.shrink(),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

에러 상태를 조용히 무시하고 있습니다.

error 핸들러가 SizedBox.shrink()만 반환하여 사용자에게 아무런 피드백을 주지 않습니다. 데이터 로딩 실패 시 사용자가 원인을 알 수 없고, 빈 화면만 보게 됩니다. 최소한 에러 메시지나 재시도 옵션을 제공하는 것이 좋습니다.

🛠️ 제안하는 수정
-            error: (_, _) => const SizedBox.shrink(),
+            error: (e, _) => Center(
+              child: Padding(
+                padding: AppPadding.all20,
+                child: Text(
+                  '할 일 목록을 불러오지 못했어요',
+                  style: AppTextStyles.label_16.copyWith(color: AppColors.textTertiary),
+                ),
+              ),
+            ),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => const SizedBox.shrink(),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Padding(
padding: AppPadding.all20,
child: Text(
'할 일 목록을 불러오지 못했어요',
style: AppTextStyles.label_16.copyWith(color: AppColors.textTertiary),
),
),
),
🤖 Prompt for AI Agents
In `@lib/features/timer/presentation/widgets/todo_select_bottom_sheet.dart` around
lines 123 - 124, The error branch currently returns a silent SizedBox (error:
(_, _) => const SizedBox.shrink()) so users see no feedback on load failure;
replace that handler in the AsyncValue.when (in todo_select_bottom_sheet.dart)
with a visible error UI (e.g., Center with an error Text and a retry
ElevatedButton) that also surfaces the error message (use the error object from
the handler) and triggers a retry (call your retry method or
ref.refresh/fetchTodos from the button’s onPressed); also consider logging the
error for diagnostics.

Comment on lines 172 to 194
// ============================================================
// 7. SharedPreferences 초기화 (Todo 로컬 저장용)
// ============================================================
late final SharedPreferences prefs;
try {
prefs = await SharedPreferences.getInstance();
} catch (e) {
debugPrint('❌ [SharedPreferences] 초기화 실패: $e');
// SharedPreferences 실패해도 앱은 시작 — Todo 기능만 비활성
runApp(const ProviderScope(child: MyApp()));
return;
}

runApp(
ProviderScope(
overrides: [
localTodoDataSourceProvider.overrideWithValue(
LocalTodoDataSource(prefs),
),
],
child: const MyApp(),
),
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

SharedPreferences 초기화 실패 시 앱 크래시 위험이 있습니다.

SharedPreferences 초기화가 실패하면 localTodoDataSourceProvider override 없이 ProviderScope가 생성됩니다. 이 경우 localTodoDataSourceProviderUnimplementedError를 throw하므로, todo 관련 provider를 watch하는 모든 화면(HomeScreen, TodoListScreen 등)에서 처리되지 않은 예외가 발생할 수 있습니다.

실패 경로에서도 todo provider들이 안전하게 빈 상태를 반환하도록 보호해야 합니다.

🛠️ 제안하는 수정 방안
   } catch (e) {
     debugPrint('❌ [SharedPreferences] 초기화 실패: $e');
-    // SharedPreferences 실패해도 앱은 시작 — Todo 기능만 비활성
-    runApp(const ProviderScope(child: MyApp()));
-    return;
+    // SharedPreferences 실패 시에도 빈 DataSource를 제공하거나,
+    // todo 화면에서 provider 접근 시 에러 처리를 보장해야 합니다.
+    // 옵션 1: 재시도 로직 추가
+    // 옵션 2: InMemory 대체 DataSource 제공
+    // 현재 방식은 todo 화면 진입 시 UnimplementedError 크래시 유발
+    runApp(const ProviderScope(child: MyApp()));
+    return;
   }
🤖 Prompt for AI Agents
In `@lib/main.dart` around lines 172 - 194, When SharedPreferences initialization
fails you currently call runApp(ProviderScope(child: MyApp())) which leaves
localTodoDataSourceProvider un-overridden and will throw UnimplementedError when
watched; instead, always provide a safe fallback override: in the catch block
create and pass an in-memory/fallback implementation (e.g. an
EmptyLocalTodoDataSource or InMemoryLocalTodoDataSource) to
localTodoDataSourceProvider.overrideWithValue so ProviderScope always receives a
concrete LocalTodoDataSource instance; update the catch path to
runApp(ProviderScope(overrides:
[localTodoDataSourceProvider.overrideWithValue(FALLBACK_INSTANCE)], child: const
MyApp())) and add/locate the fallback class (EmptyLocalTodoDataSource or
similar) that implements the same interface as LocalTodoDataSource.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@docs/plans/2026-02-16-coderabbit-defensive-fixes.md`:
- Around line 170-172: The fenced code block containing the text `fix:
SharedPreferences 실패 시 graceful degradation 적용 `#17`` needs a language tag to
satisfy MD040; edit the block delimiter from ``` to a language-specific fence
such as ```text or ```bash so the markdown linter stops warning about a missing
language identifier.
- Around line 208-210: The fenced code block containing "fix: 일괄 삭제 await 후
mounted 체크 추가 `#17`" lacks a language tag which triggers MD040; update the
triple-backtick fence to include an appropriate language hint (for example
```text or ```bash) directly after the opening backticks so the code block
specifies its language and the linter warning is resolved.
- Around line 76-78: The fenced code block containing the text "fix:
LocalTodoDataSource JSON 디코딩 방어 처리 추가 `#17`" needs a language tag to satisfy
MD040; update the opening fence from ``` to a tagged fence such as ```text or
```bash so the block is explicitly labeled (e.g., change the line that starts
with ``` immediately before that string to ```text).
- Around line 220-222: The fenced code block containing the commit message
string "fix: CodeRabbit 방어적 코딩 개선 (JSON 방어, SP 실패 처리, mounted 체크) `#17`" lacks a
language tag and triggers MD040; update that specific triple-backtick block by
adding an appropriate language identifier (e.g., ```text or ```bash) immediately
after the opening backticks so the markdown linter recognizes the language for
that fenced code block.

In `@lib/features/home/presentation/screens/home_screen.dart`:
- Around line 288-290: The onPageChanged handlers are mutating _focusedDay
directly without calling setState, causing inconsistent UI/logic that depends on
_focusedDay; update both onPageChanged callbacks (the ones currently doing
"_focusedDay = focusedDay;") to assign _focusedDay inside a setState(() {
_focusedDay = focusedDay; }) so the widget rebuilds and any calendar/sheet logic
depending on _focusedDay stays consistent.
🧹 Nitpick comments (4)
lib/features/todo/data/datasources/local_todo_datasource.dart (1)

27-29: _prefs.remove() 호출의 Future가 처리되지 않습니다.

getTodos()getCategories()는 동기 메서드이므로 await할 수 없지만, 반환된 Future가 무시됩니다. 실패 시 손상된 데이터가 남아있을 수 있습니다. unawaited()로 의도를 명시하거나, 메서드를 Future로 변경하는 것을 고려해 주세요.

Also applies to: 49-51

lib/features/home/presentation/screens/home_screen.dart (1)

396-410: showTodoAddBottomSheet 결과 타입이 Map으로 반환됩니다 — 타입 안전성 확인 필요.

result['title'], result['categoryId'], result['scheduledDates'] 등을 as 캐스팅하고 있습니다. showTodoAddBottomSheet가 예상과 다른 키/타입을 반환하면 런타임 캐스팅 에러가 발생합니다. 타입 안전을 위해 전용 result 클래스 도입을 고려해 주세요.

lib/features/todo/presentation/screens/todo_list_screen.dart (2)

196-236: ListView 내부 GridViewshrinkWrap: true 사용.

카테고리 수가 적다면 현재 방식도 문제 없지만, 카테고리가 많아질 경우 shrinkWrap: true는 모든 아이템을 한 번에 레이아웃하여 성능 저하를 유발할 수 있습니다. 향후 CustomScrollView + SliverGrid 조합으로 전환을 고려해 주세요.


249-260: 바텀시트 결과를 Map으로 처리하는 패턴이 반복됩니다.

home_screen.dart와 동일하게 result['title'] as String 등의 캐스팅 패턴이 사용됩니다. 키 오타나 타입 불일치 시 런타임 에러가 발생하므로, 공유 result 모델 클래스로 통일하면 안전성과 유지보수성이 향상됩니다.

Comment on lines +288 to +290
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

onPageChanged에서 setState 없이 _focusedDay를 변경하고 있습니다.

Line 289와 Line 377에서 _focusedDay = focusedDay;setState 밖에서 호출됩니다. 시트가 접히거나 펼쳐질 때 rebuild가 발생하면 이전 _focusedDay 값이 아닌 변경된 값이 사용되긴 하지만, 페이지 변경 직후에는 위젯이 rebuild되지 않으므로 _focusedDay에 의존하는 다른 로직(예: 시트 상태 전환 시 캘린더 위치)이 일관되지 않을 수 있습니다.

🔧 수정 제안
  onPageChanged: (focusedDay) {
-   _focusedDay = focusedDay;
+   setState(() {
+     _focusedDay = focusedDay;
+   });
  },

Line 377의 onPageChanged에도 동일하게 적용해 주세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
},
onPageChanged: (focusedDay) {
setState(() {
_focusedDay = focusedDay;
});
},
🤖 Prompt for AI Agents
In `@lib/features/home/presentation/screens/home_screen.dart` around lines 288 -
290, The onPageChanged handlers are mutating _focusedDay directly without
calling setState, causing inconsistent UI/logic that depends on _focusedDay;
update both onPageChanged callbacks (the ones currently doing "_focusedDay =
focusedDay;") to assign _focusedDay inside a setState(() { _focusedDay =
focusedDay; }) so the widget rebuilds and any calendar/sheet logic depending on
_focusedDay stays consistent.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
lib/features/home/presentation/screens/home_screen.dart (2)

70-81: 시트 확장 판별 임계값(0.4)과 snap 위치(0.30, 0.85) 간 불일치 가능성.

snapSizes[0.30, 0.85]인데, _onSheetChanged에서 size > 0.4이면 확장으로 판별합니다. 시트가 0.30↔0.85 사이를 전환할 때, size가 0.4를 넘는 순간 _isSheetExpandedtrue로 바뀌면서 collapsed → expanded 콘텐츠가 중간 애니메이션 도중에 전환됩니다. 이로 인해 캘린더 레이아웃이 갑자기 바뀌며 시각적 점프가 발생할 수 있습니다.

중간점인 0.575 부근이나, 확장 snap에 더 가까운 값(예: 0.6)을 사용하면 전환이 좀 더 자연스러울 수 있습니다.

💡 제안
  void _onSheetChanged() {
-   final expanded = _sheetController.size > 0.4;
+   final expanded = _sheetController.size > 0.6;
    if (expanded != _isSheetExpanded) {

394-408: showTodoAddBottomSheet 결과의 타입 안전성이 부족합니다.

resultMap<String, dynamic>으로 반환되며, result['title'] as String 등의 캐스팅이 런타임에 실패할 수 있습니다. 키가 누락되거나 타입이 다를 경우 TypeError가 발생합니다.

전용 DTO/클래스(예: TodoAddResult)를 정의하여 showTodoAddBottomSheet가 타입 안전한 객체를 반환하도록 하면, 런타임 캐스팅 오류를 방지하고 리팩터링 시 컴파일 타임 검증이 가능합니다.

docs/plans/2026-02-16-coderabbit-defensive-fixes.md (1)

123-147: 선택사항: late 키워드를 제거하면 더 관용적입니다.

Line 124의 late final SharedPreferences? prefs;는 기술적으로 올바르지만, nullable 타입에 late를 사용하는 것은 다소 비관용적입니다. catch 블록에서 null을 할당할 계획이라면, late 없이 SharedPreferences? prefs;로 선언하는 것이 더 자연스럽습니다 (자동으로 null로 초기화됨).

♻️ 더 관용적인 코드
-late final SharedPreferences? prefs;
+SharedPreferences? prefs;
 try {
   prefs = await SharedPreferences.getInstance();
 } catch (e) {
   debugPrint('❌ [SharedPreferences] 초기화 실패: $e');
-  prefs = null;
 }

Note: prefs = null; 라인도 제거 가능 (이미 null로 초기화되므로)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments