diff --git a/docs/plans/2026-03-09-todo-list-calendar-redesign.md b/docs/plans/2026-03-09-todo-list-calendar-redesign.md new file mode 100644 index 0000000..67f229c --- /dev/null +++ b/docs/plans/2026-03-09-todo-list-calendar-redesign.md @@ -0,0 +1,46 @@ +# 홈 화면 Todo Bank + Slidable 스와이프 액션 구현 + +**Goal:** 홈 화면 바텀시트에 todo bank 섹션을 추가하고, 할일 스와이프 액션을 flutter_slidable로 개선하여 날짜별 할일 관리 UX를 향상시킨다. + +**Architecture:** 기존 HomeScreen 바텀시트에 todo bank 섹션 추가. TodoListScreen은 변경 없음. DismissibleTodoItem을 Dismissible에서 flutter_slidable의 Slidable로 교체. + +**Tech Stack:** Flutter · Riverpod · flutter_slidable · table_calendar + +**관련 이슈:** #51 + +--- + +## 구현 내용 + +### 1. Todo Bank (홈 화면 바텀시트) + +**변경 파일:** +- `lib/features/todo/presentation/providers/todo_provider.dart` — `todosNotForDate` provider 추가 +- `lib/features/todo/presentation/providers/todo_provider.g.dart` — 코드 생성 +- `lib/features/home/presentation/screens/home_screen.dart` — `_buildTodoBankSection`, `_addTodoToDate` 추가 + +**동작:** +- 홈 화면 캘린더에서 날짜 선택 후, 해당 날짜에 배정되지 않은 할일 목록을 "할 일 추가" 섹션으로 표시 +- 할일을 탭하면 선택된 날짜의 scheduledDates에 추가되어 즉시 해당 날짜 할일 목록에 나타남 +- 다른 날짜를 선택하면 해당 날짜에 미배정된 할일이 다시 bank에 표시됨 +- scheduledDates가 비어있는 할일(미지정)은 bank에 표시하지 않음 + +### 2. Slidable 스와이프 액션 + +**변경 파일:** +- `lib/features/todo/presentation/widgets/dismissible_todo_item.dart` — Dismissible → Slidable 교체 +- `pubspec.yaml` / `pubspec.lock` — flutter_slidable 패키지 추가 + +**동작:** +- **좌→우 (startActionPane):** 카테고리 이동 — `primaryLight` 색상 +- **우→좌 (endActionPane):** + - 날짜에서 제거 (contextDate가 있을 때만) — `accentGoldLight` 색상 + - 삭제 — `error` 색상 +- CustomSlidableAction + 투명 배경 + 아이콘/텍스트만 표시 (다크 우주 테마에 어울리는 미니멀 스타일) +- 타이머 연동 중인 할일은 제거/삭제 차단 + +### 3. 변경하지 않은 것 + +- TodoListScreen (카테고리 그리드 중심 레이아웃 유지) +- CategoryTodoScreen 및 라우팅 +- 기존 할일 CRUD 로직 diff --git a/lib/features/auth/presentation/providers/auth_provider.g.dart b/lib/features/auth/presentation/providers/auth_provider.g.dart index ebbc020..288f127 100644 --- a/lib/features/auth/presentation/providers/auth_provider.g.dart +++ b/lib/features/auth/presentation/providers/auth_provider.g.dart @@ -244,7 +244,7 @@ final activeLoginNotifierProvider = ); typedef _$ActiveLoginNotifier = AutoDisposeNotifier; -String _$authNotifierHash() => r'fc03859db1dd948d709fdb96f0edf483be7998a0'; +String _$authNotifierHash() => r'ac2268510acb44e219020f7de48858e9a85cc68d'; /// 인증 상태를 관리하는 Notifier /// diff --git a/lib/features/badge/presentation/providers/badge_provider.g.dart b/lib/features/badge/presentation/providers/badge_provider.g.dart index df21d0a..c98bc2d 100644 --- a/lib/features/badge/presentation/providers/badge_provider.g.dart +++ b/lib/features/badge/presentation/providers/badge_provider.g.dart @@ -61,7 +61,7 @@ final unlockedBadgeCountProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef UnlockedBadgeCountRef = AutoDisposeProviderRef; -String _$badgeNotifierHash() => r'ad13e70d5d21f2d41098e468f25fdacdcca38080'; +String _$badgeNotifierHash() => r'121f7003985d1c63cac4dc17728c23c75a31529b'; /// See also [BadgeNotifier]. @ProviderFor(BadgeNotifier) diff --git a/lib/features/exploration/presentation/providers/exploration_provider.g.dart b/lib/features/exploration/presentation/providers/exploration_provider.g.dart index 97bfeb8..6e870b6 100644 --- a/lib/features/exploration/presentation/providers/exploration_provider.g.dart +++ b/lib/features/exploration/presentation/providers/exploration_provider.g.dart @@ -208,7 +208,7 @@ class _ExplorationProgressProviderElement } String _$explorationNotifierHash() => - r'c774ca8225cc050cb9e6f247f36e9e0493e4874c'; + r'0934da4a60377bef28a4a744d9c5fce5acd4cb32'; /// 행성 목록 상태 /// @@ -230,7 +230,7 @@ final explorationNotifierProvider = typedef _$ExplorationNotifier = Notifier>; String _$regionListNotifierHash() => - r'24380eb73e0ae93bc2cce7228fa4097bb211ba24'; + r'aee531fb3209715c68491f42d65e86bdfc8adee6'; abstract class _$RegionListNotifier extends BuildlessAutoDisposeNotifier> { diff --git a/lib/features/home/presentation/screens/home_screen.dart b/lib/features/home/presentation/screens/home_screen.dart index ceef287..5a75c2e 100644 --- a/lib/features/home/presentation/screens/home_screen.dart +++ b/lib/features/home/presentation/screens/home_screen.dart @@ -19,6 +19,7 @@ import '../../../../routes/route_paths.dart'; import '../../../timer/presentation/providers/study_stats_provider.dart'; import '../../../todo/domain/entities/todo_entity.dart'; import '../../../todo/presentation/providers/todo_provider.dart'; +import '../../../../core/widgets/space/todo_item.dart'; import '../../../todo/presentation/widgets/dismissible_todo_item.dart'; import '../../../todo/presentation/widgets/todo_add_bottom_sheet.dart'; import '../widgets/space_calendar.dart'; @@ -346,6 +347,7 @@ class _HomeScreenState extends ConsumerState { _selectedDay.day, ); final todosForSelected = ref.watch(todosForDateProvider(selectedKey)); + final bankTodos = ref.watch(todosNotForDateProvider(selectedKey)); final unscheduled = ref.watch(unscheduledTodosProvider); final todosByDate = ref.watch(todosByDateMapProvider); final dateLabel = DateFormat('M/d', 'ko_KR').format(_selectedDay); @@ -421,16 +423,19 @@ class _HomeScreenState extends ConsumerState { ), SizedBox(height: AppSpacing.s8), - if (todosForSelected.isEmpty) + if (todosForSelected.isEmpty && bankTodos.isEmpty) Padding( padding: AppPadding.horizontal20, child: _buildEmptyTodoCard(), ) - else + else if (todosForSelected.isNotEmpty) ...todosForSelected.map( (todo) => _buildTodoRow(todo, contextDate: _selectedDay), ), + // ── 할 일 추가 (todo bank) ── + _buildTodoBankSection(selectedKey, bankTodos), + // ── 카테고리 관리 버튼 ── Padding( padding: AppPadding.horizontal20, @@ -485,6 +490,53 @@ class _HomeScreenState extends ConsumerState { ); } + /// 할 일 추가 (todo bank): 선택된 날짜에 배정되지 않은 할일 목록 + /// + /// 탭하면 해당 날짜에 즉시 추가된다. + Widget _buildTodoBankSection( + DateTime selectedDate, + List bankTodos, + ) { + if (bankTodos.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: AppSpacing.s16), + Padding( + padding: AppPadding.horizontal20, + child: _buildSectionTitle('할 일 추가'), + ), + SizedBox(height: AppSpacing.s8), + ...bankTodos.map( + (todo) => Padding( + padding: EdgeInsets.fromLTRB(20.w, 0, 20.w, AppSpacing.s8), + child: TodoItem( + title: todo.title, + subtitle: todo.studyTimeLabel, + isCompleted: false, + leading: Icon( + Icons.add_circle_outline_rounded, + color: AppColors.primary, + size: 24.w, + ), + onToggle: () => _addTodoToDate(ref, todo, selectedDate), + onTap: () => _addTodoToDate(ref, todo, selectedDate), + ), + ), + ), + ], + ); + } + + Future _addTodoToDate( + WidgetRef ref, + TodoEntity todo, + DateTime date, + ) async { + await ref.read(todoListNotifierProvider.notifier).addDateToTodo(todo, date); + } + Widget _buildEmptyTodoCard() { return AppCard( style: AppCardStyle.outlined, diff --git a/lib/features/settings/presentation/providers/settings_provider.g.dart b/lib/features/settings/presentation/providers/settings_provider.g.dart index 7f928f7..658356d 100644 --- a/lib/features/settings/presentation/providers/settings_provider.g.dart +++ b/lib/features/settings/presentation/providers/settings_provider.g.dart @@ -63,7 +63,7 @@ final starTwinkleEnabledProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef StarTwinkleEnabledRef = AutoDisposeProviderRef; -String _$settingsNotifierHash() => r'e5590548d61a207a0e84fc69ef25dea67626ca2f'; +String _$settingsNotifierHash() => r'09dd0a8d82513eb4a2b24203f0048bf9fb748fea'; /// See also [SettingsNotifier]. @ProviderFor(SettingsNotifier) diff --git a/lib/features/timer/presentation/providers/timer_provider.g.dart b/lib/features/timer/presentation/providers/timer_provider.g.dart index 2921af7..0ebc2d9 100644 --- a/lib/features/timer/presentation/providers/timer_provider.g.dart +++ b/lib/features/timer/presentation/providers/timer_provider.g.dart @@ -6,7 +6,7 @@ part of 'timer_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$timerNotifierHash() => r'3aff7b6060abf1214b4dd19e8384010bbec83a8a'; +String _$timerNotifierHash() => r'0aac0c38f3140e893027e6775032b82c0f09df64'; /// See also [TimerNotifier]. @ProviderFor(TimerNotifier) diff --git a/lib/features/todo/domain/entities/todo_entity.dart b/lib/features/todo/domain/entities/todo_entity.dart index b9edcd9..82b68d6 100644 --- a/lib/features/todo/domain/entities/todo_entity.dart +++ b/lib/features/todo/domain/entities/todo_entity.dart @@ -30,4 +30,7 @@ class TodoEntity with _$TodoEntity { if (scheduledDates.isEmpty) return completedDates.isNotEmpty; return scheduledDates.every((d) => isCompletedForDate(d)); } + + String? get studyTimeLabel => + actualMinutes != null && actualMinutes! > 0 ? '$actualMinutes분 공부' : null; } diff --git a/lib/features/todo/presentation/providers/todo_provider.dart b/lib/features/todo/presentation/providers/todo_provider.dart index 8c31b1a..17ec941 100644 --- a/lib/features/todo/presentation/providers/todo_provider.dart +++ b/lib/features/todo/presentation/providers/todo_provider.dart @@ -128,6 +128,20 @@ class TodoListNotifier extends _$TodoListNotifier { } } + /// 특정 날짜를 할일에 추가 (todo bank → 날짜 배정) + Future addDateToTodo(TodoEntity todo, DateTime date) async { + final normalized = TodoEntity.normalizeDate(date); + if (todo.scheduledDates.any( + (d) => TodoEntity.normalizeDate(d) == normalized, + )) { + return; + } + final updated = todo.copyWith( + scheduledDates: [...todo.scheduledDates, normalized], + ); + await updateTodo(updated); + } + Future updateTodo(TodoEntity todo) async { final previousState = state; state = AsyncData( @@ -291,12 +305,25 @@ class CategoryListNotifier extends _$CategoryListNotifier { @riverpod List todosForDate(Ref ref, DateTime date) { final todos = ref.watch(todoListNotifierProvider).valueOrNull ?? []; - final normalizedDate = DateTime(date.year, date.month, date.day); + final normalizedDate = TodoEntity.normalizeDate(date); + return todos.where((t) { + return t.scheduledDates.any( + (d) => TodoEntity.normalizeDate(d) == normalizedDate, + ); + }).toList(); +} + +// === 해당 날짜에 배정되지 않은 할일 (todo bank용) === + +@riverpod +List todosNotForDate(Ref ref, DateTime date) { + final todos = ref.watch(todoListNotifierProvider).valueOrNull ?? []; + final normalizedDate = TodoEntity.normalizeDate(date); return todos.where((t) { - return t.scheduledDates.any((d) { - final scheduled = DateTime(d.year, d.month, d.day); - return scheduled == normalizedDate; - }); + if (t.scheduledDates.isEmpty) return false; + return !t.scheduledDates.any( + (d) => TodoEntity.normalizeDate(d) == normalizedDate, + ); }).toList(); } @@ -316,7 +343,7 @@ Map> todosByDateMap(Ref ref) { final map = >{}; for (final todo in todos) { for (final date in todo.scheduledDates) { - final key = DateTime(date.year, date.month, date.day); + final key = TodoEntity.normalizeDate(date); map.putIfAbsent(key, () => []).add(todo); } } diff --git a/lib/features/todo/presentation/providers/todo_provider.g.dart b/lib/features/todo/presentation/providers/todo_provider.g.dart index 605bbf9..f56012d 100644 --- a/lib/features/todo/presentation/providers/todo_provider.g.dart +++ b/lib/features/todo/presentation/providers/todo_provider.g.dart @@ -194,7 +194,7 @@ final deleteCategoryUseCaseProvider = // ignore: unused_element typedef DeleteCategoryUseCaseRef = AutoDisposeProviderRef; -String _$todosForDateHash() => r'9af8ac0f2a2d3d7d8d191238f0d4f23c52da6ddf'; +String _$todosForDateHash() => r'5b3fdfd35342374991d2a3254a2b58755b3c3048'; /// Copied from Dart SDK class _SystemHash { @@ -335,6 +335,126 @@ class _TodosForDateProviderElement DateTime get date => (origin as TodosForDateProvider).date; } +String _$todosNotForDateHash() => r'eea5324e8b8ac04c8d396fa8837c97a56dba259f'; + +/// See also [todosNotForDate]. +@ProviderFor(todosNotForDate) +const todosNotForDateProvider = TodosNotForDateFamily(); + +/// See also [todosNotForDate]. +class TodosNotForDateFamily extends Family> { + /// See also [todosNotForDate]. + const TodosNotForDateFamily(); + + /// See also [todosNotForDate]. + TodosNotForDateProvider call(DateTime date) { + return TodosNotForDateProvider(date); + } + + @override + TodosNotForDateProvider getProviderOverride( + covariant TodosNotForDateProvider provider, + ) { + return call(provider.date); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'todosNotForDateProvider'; +} + +/// See also [todosNotForDate]. +class TodosNotForDateProvider extends AutoDisposeProvider> { + /// See also [todosNotForDate]. + TodosNotForDateProvider(DateTime date) + : this._internal( + (ref) => todosNotForDate(ref as TodosNotForDateRef, date), + from: todosNotForDateProvider, + name: r'todosNotForDateProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$todosNotForDateHash, + dependencies: TodosNotForDateFamily._dependencies, + allTransitiveDependencies: + TodosNotForDateFamily._allTransitiveDependencies, + date: date, + ); + + TodosNotForDateProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.date, + }) : super.internal(); + + final DateTime date; + + @override + Override overrideWith( + List Function(TodosNotForDateRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: TodosNotForDateProvider._internal( + (ref) => create(ref as TodosNotForDateRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + date: date, + ), + ); + } + + @override + AutoDisposeProviderElement> createElement() { + return _TodosNotForDateProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is TodosNotForDateProvider && other.date == date; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, date.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin TodosNotForDateRef on AutoDisposeProviderRef> { + /// The parameter `date` of this provider. + DateTime get date; +} + +class _TodosNotForDateProviderElement + extends AutoDisposeProviderElement> + with TodosNotForDateRef { + _TodosNotForDateProviderElement(super.provider); + + @override + DateTime get date => (origin as TodosNotForDateProvider).date; +} + String _$unscheduledTodosHash() => r'6392feb486da0deccf3be9ca35e32a2eba1a66e1'; /// See also [unscheduledTodos]. @@ -352,7 +472,7 @@ final unscheduledTodosProvider = AutoDisposeProvider>.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef UnscheduledTodosRef = AutoDisposeProviderRef>; -String _$todosByDateMapHash() => r'63550762dadad72a407562f09198045aafdb1ece'; +String _$todosByDateMapHash() => r'6dc1d0fabc90511db8196e00c2d42897b473afc8'; /// See also [todosByDateMap]. @ProviderFor(todosByDateMap) @@ -618,7 +738,7 @@ class _CategoryTodoStatsProviderElement String? get categoryId => (origin as CategoryTodoStatsProvider).categoryId; } -String _$todoListNotifierHash() => r'7a2600651b82a5cf90efe6d25e37eefaadbed87e'; +String _$todoListNotifierHash() => r'157130d482c19d25a2c1adeaa51e4824b408e79f'; /// See also [TodoListNotifier]. @ProviderFor(TodoListNotifier) diff --git a/lib/features/todo/presentation/widgets/dismissible_todo_item.dart b/lib/features/todo/presentation/widgets/dismissible_todo_item.dart index 3900776..2d31d5d 100644 --- a/lib/features/todo/presentation/widgets/dismissible_todo_item.dart +++ b/lib/features/todo/presentation/widgets/dismissible_todo_item.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:intl/intl.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; import '../../../../core/constants/app_colors.dart'; import '../../../../core/constants/spacing_and_radius.dart'; import '../../../../core/constants/text_styles.dart'; -import '../../../../core/widgets/atoms/drag_handle.dart'; import '../../../../core/widgets/dialogs/app_dialog.dart'; import '../../../../core/widgets/feedback/app_snackbar.dart'; import '../../../../core/widgets/space/todo_item.dart'; @@ -17,11 +16,11 @@ import '../providers/todo_provider.dart'; import 'category_select_bottom_sheet.dart'; import 'todo_add_bottom_sheet.dart'; -/// 양방향 스와이프 Dismissible + TodoItem 통합 위젯 +/// Slidable + TodoItem 통합 위젯 /// -/// - 좌→우: 카테고리 선택 바텀시트 -/// - 우→좌: 삭제 확인 (다중 날짜면 3가지 선택) -/// - 탭: 완료 토글 (onTap 미지정 시) +/// - 좌→우 (startActionPane): 카테고리 이동 +/// - 우→좌 (endActionPane): 날짜에서 제거 + 삭제 +/// - 탭: 할일 수정 바텀시트 (onTap 미지정 시) /// /// [contextDate] 캘린더에서 선택된 날짜. null이면 글로벌(isFullyCompleted) 사용. class DismissibleTodoItem extends ConsumerWidget { @@ -42,108 +41,182 @@ class DismissibleTodoItem extends ConsumerWidget { ? todo.isCompletedForDate(contextDate!) : todo.isFullyCompleted; - return Dismissible( + return Slidable( key: Key('${todo.id}_${contextDate?.toIso8601String() ?? "global"}'), - direction: DismissDirection.horizontal, - confirmDismiss: (direction) async { - if (direction == DismissDirection.startToEnd) { - final newCategoryIds = await showCategorySelectBottomSheet( - context: context, - currentCategoryIds: todo.categoryIds, - ); - if (newCategoryIds != null && context.mounted) { + groupTag: 'todos', + + // ── 좌→우: 카테고리 이동 ── + startActionPane: ActionPane( + motion: const DrawerMotion(), + extentRatio: 0.25, + children: [ + CustomSlidableAction( + onPressed: (slidableContext) => + _moveCategory(slidableContext, context, ref), + backgroundColor: Colors.transparent, + child: _buildActionContent( + icon: Icons.drive_file_move_outline, + label: '이동', + color: AppColors.primaryLight, + ), + ), + ], + ), + + // ── 우→좌: 날짜에서 제거 + 삭제 ── + endActionPane: ActionPane( + motion: const DrawerMotion(), + extentRatio: contextDate != null ? 0.5 : 0.25, + children: [ + if (contextDate != null) + CustomSlidableAction( + onPressed: (slidableContext) => + _removeFromDate(slidableContext, context, ref), + backgroundColor: Colors.transparent, + child: _buildActionContent( + icon: Icons.event_busy_rounded, + label: '제거', + color: AppColors.accentGoldLight, + ), + ), + CustomSlidableAction( + onPressed: (slidableContext) => + _deleteTodo(slidableContext, context, ref), + backgroundColor: Colors.transparent, + child: _buildActionContent( + icon: Icons.delete_outline, + label: '삭제', + color: AppColors.error, + ), + ), + ], + ), + + child: Builder( + builder: (slidableChildContext) => TodoItem( + title: todo.title, + subtitle: todo.studyTimeLabel, + isCompleted: isCompleted, + onToggle: () { + if (contextDate == null) return; ref .read(todoListNotifierProvider.notifier) - .updateTodo(todo.copyWith(categoryIds: newCategoryIds)); - } - return false; - } - // 타이머에 연동된 할 일이면 삭제 차단 - final timerState = ref.read(timerNotifierProvider); - if (timerState.status != TimerStatus.idle && - timerState.linkedTodoId == todo.id) { - AppSnackBar.warning(context, '타이머에 연동된 할 일은 삭제할 수 없어요'); - return false; - } - // 삭제 방향: 다중 날짜 할일이면 선택지 표시 - if (todo.scheduledDates.length > 1 && contextDate != null) { - final action = await _showMultiDateDeleteSheet(context); - if (action == null) return false; - if (!context.mounted) return false; - final notifier = ref.read(todoListNotifierProvider.notifier); - switch (action) { - case _DeleteAction.thisDateOnly: - await notifier.removeDateFromTodo(todo, contextDate!); - return false; // 직접 처리함 - case _DeleteAction.thisAndAfter: - await notifier.removeDateAndAfterFromTodo(todo, contextDate!); - return false; - case _DeleteAction.all: - return true; // onDismissed에서 삭제 - } - } - // 단일 날짜 또는 글로벌 뷰: 기존 삭제 확인 - if (!context.mounted) return false; - final confirmed = await AppDialog.confirm( - context: context, - title: '할일 삭제', - message: "'${todo.title}'을(를) 삭제하시겠습니까?\n삭제된 항목은 복구할 수 없습니다.", - emotion: AppDialogEmotion.warning, - confirmText: '삭제', - cancelText: '취소', - isDestructive: true, - ); - return confirmed == true; - }, - onDismissed: (_) { - ref.read(todoListNotifierProvider.notifier).deleteTodo(todo.id); - }, - background: Container( - alignment: Alignment.centerLeft, - padding: AppPadding.horizontal20, - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.2), - borderRadius: AppRadius.large, - ), - child: Icon( - Icons.drive_file_move_outline, - color: AppColors.primary, - size: 24.w, - ), - ), - secondaryBackground: Container( - alignment: Alignment.centerRight, - padding: AppPadding.horizontal20, - decoration: BoxDecoration( - color: AppColors.error.withValues(alpha: 0.2), - borderRadius: AppRadius.large, + .toggleTodoForDate(todo, contextDate!); + }, + onTap: + onTap ?? + () { + final slidable = Slidable.of(slidableChildContext); + if (slidable != null && + (slidable.animation.value > 0 || + slidable.animation.isAnimating)) { + slidable.close(); + return; + } + _openEditSheet(context, ref); + }, ), - child: Icon(Icons.delete_outline, color: AppColors.error, size: 24.w), - ), - child: TodoItem( - title: todo.title, - subtitle: todo.actualMinutes != null && todo.actualMinutes! > 0 - ? '${todo.actualMinutes}분 공부' - : null, - isCompleted: isCompleted, - onToggle: () { - final date = contextDate ?? DateTime.now(); - ref - .read(todoListNotifierProvider.notifier) - .toggleTodoForDate(todo, date); - }, - onTap: onTap ?? () => _openEditSheet(context, ref), ), ); } - void _openEditSheet(BuildContext context, WidgetRef ref) async { + Widget _buildActionContent({ + required IconData icon, + required String label, + required Color color, + }) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 22.w), + SizedBox(height: AppSpacing.s4), + Text(label, style: AppTextStyles.tag_12.copyWith(color: color)), + ], + ); + } + + /// 카테고리 이동 + Future _moveCategory( + BuildContext slidableContext, + BuildContext context, + WidgetRef ref, + ) async { + Slidable.of(slidableContext)?.close(); + if (!context.mounted) return; + final newCategoryIds = await showCategorySelectBottomSheet( + context: context, + currentCategoryIds: todo.categoryIds, + ); + if (newCategoryIds != null && context.mounted) { + await ref + .read(todoListNotifierProvider.notifier) + .updateTodo(todo.copyWith(categoryIds: newCategoryIds)); + } + } + + /// 선택된 날짜에서 제거 (bank로 복귀) + Future _removeFromDate( + BuildContext slidableContext, + BuildContext context, + WidgetRef ref, + ) async { + Slidable.of(slidableContext)?.close(); + if (contextDate == null) return; + if (_isLinkedToTimer(ref)) { + AppSnackBar.warning(context, '타이머에 연동된 할 일은 제거할 수 없어요'); + return; + } + // 마지막 날짜인 경우 확인 다이얼로그 + if (todo.scheduledDates.length == 1) { + final confirmed = await AppDialog.confirm( + context: context, + title: '할일 제거', + message: "'${todo.title}'의 마지막 배정 날짜입니다.\n제거하면 할일이 삭제됩니다.", + emotion: AppDialogEmotion.warning, + confirmText: '제거', + cancelText: '취소', + isDestructive: true, + ); + if (confirmed != true || !context.mounted) return; + } + await ref + .read(todoListNotifierProvider.notifier) + .removeDateFromTodo(todo, contextDate!); + } + + /// 할일 완전 삭제 + Future _deleteTodo( + BuildContext slidableContext, + BuildContext context, + WidgetRef ref, + ) async { + Slidable.of(slidableContext)?.close(); + if (_isLinkedToTimer(ref)) { + AppSnackBar.warning(context, '타이머에 연동된 할 일은 삭제할 수 없어요'); + return; + } + final confirmed = await AppDialog.confirm( + context: context, + title: '할일 삭제', + message: "'${todo.title}'을(를) 삭제하시겠습니까?\n삭제된 항목은 복구할 수 없습니다.", + emotion: AppDialogEmotion.warning, + confirmText: '삭제', + cancelText: '취소', + isDestructive: true, + ); + if (confirmed == true && context.mounted) { + await ref.read(todoListNotifierProvider.notifier).deleteTodo(todo.id); + } + } + + /// 할일 수정 바텀시트 + Future _openEditSheet(BuildContext context, WidgetRef ref) async { final result = await showTodoAddBottomSheet( context: context, initialTodo: todo, ); if (result != null && context.mounted) { - ref + await ref .read(todoListNotifierProvider.notifier) .updateTodo( todo.copyWith( @@ -156,118 +229,10 @@ class DismissibleTodoItem extends ConsumerWidget { } } - Future<_DeleteAction?> _showMultiDateDeleteSheet(BuildContext context) { - return showModalBottomSheet<_DeleteAction>( - context: context, - backgroundColor: Colors.transparent, - builder: (context) => _MultiDateDeleteSheet(contextDate: contextDate!), - ); - } -} - -enum _DeleteAction { thisDateOnly, thisAndAfter, all } - -class _MultiDateDeleteSheet extends StatelessWidget { - const _MultiDateDeleteSheet({required this.contextDate}); - - final DateTime contextDate; - - @override - Widget build(BuildContext context) { - final dateLabel = DateFormat('M/d (E)', 'ko_KR').format(contextDate); - - return Container( - decoration: BoxDecoration( - color: AppColors.spaceSurface, - borderRadius: AppRadius.modal, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 드래그 핸들 - const DragHandle(), - Padding( - padding: AppPadding.bottomSheetTitlePadding, - child: Text( - '반복 할일 삭제', - style: AppTextStyles.subHeading_18.copyWith(color: Colors.white), - ), - ), - _buildOption( - context, - icon: Icons.today, - label: '$dateLabel만 삭제', - action: _DeleteAction.thisDateOnly, - ), - _buildOption( - context, - icon: Icons.event, - label: '$dateLabel 이후 모두 삭제', - action: _DeleteAction.thisAndAfter, - ), - _buildOption( - context, - icon: Icons.delete_forever, - label: '전체 삭제', - action: _DeleteAction.all, - isDestructive: true, - ), - SizedBox(height: AppSpacing.s8), - // 취소 버튼 - Padding( - padding: AppPadding.horizontal20, - child: SizedBox( - width: double.infinity, - child: TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text( - '취소', - style: AppTextStyles.label_16.copyWith( - color: AppColors.textSecondary, - ), - ), - ), - ), - ), - SizedBox(height: MediaQuery.of(context).padding.bottom + 12.h), - ], - ), - ); - } - - Widget _buildOption( - BuildContext context, { - required IconData icon, - required String label, - required _DeleteAction action, - bool isDestructive = false, - }) { - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => Navigator.of(context).pop(action), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 14.h), - child: Row( - children: [ - Icon( - icon, - color: isDestructive - ? AppColors.error - : AppColors.textSecondary, - size: 20.w, - ), - SizedBox(width: AppSpacing.s12), - Text( - label, - style: AppTextStyles.label_16.copyWith( - color: isDestructive ? AppColors.error : Colors.white, - ), - ), - ], - ), - ), - ), - ); + /// 타이머에 연동된 할일인지 확인 + bool _isLinkedToTimer(WidgetRef ref) { + final timerState = ref.read(timerNotifierProvider); + return timerState.status != TimerStatus.idle && + timerState.linkedTodoId == todo.id; } } diff --git a/pubspec.lock b/pubspec.lock index 5ba7096..40ae911 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -590,6 +590,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_slidable: + dependency: "direct main" + description: + name: flutter_slidable + sha256: ea369262929d3cc6ebf9d8a00c196127966f117fe433a5e5cb47fb08008ca203 + url: "https://pub.dev" + source: hosted + version: "4.0.3" flutter_staggered_grid_view: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4442385..a82fff0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: flutter_expandable_fab: ^2.5.2 # 확장 가능한 FAB (Floating Action Button) showcaseview: ^5.0.1 # 튜토리얼 table_calendar: ^3.2.0 # 캘린더 위젯 (월간/주간 뷰) + flutter_slidable: ^4.0.3 # 슬라이드 가능한 리스트 아이템 # 리스트 & 데이터 표시 infinite_scroll_pagination: ^5.1.1 # 무한 스크롤 페이징 처리 # 알림 & 실시간 통신