From 5d0db60f7b21b75d479d92f02245ffc09ac9ab6a Mon Sep 17 00:00:00 2001 From: Jun Seo Date: Sat, 6 Jun 2026 13:36:51 +0900 Subject: [PATCH 1/7] feat(domain): add recurrence rule entity and recurring todo usecases Introduces RecurrenceRule with daily/weekly/monthly/yearly frequency, interval, weekday selection, and a sealed RecurrenceEnd type (never/onDate/afterCount). Todo gains nullable recurrence, seriesId, and occurrenceDate so a single entity can represent both a series template and a materialized occurrence. Adds four usecases (create/update/delete/expand) and extends the TodoRepository contract with series + occurrence lookups. Repository impl uses stubs that throw UnimplementedError; full persistence lands in step 2/3. Co-Authored-By: Claude Opus 4.7 --- .../repositories/todo_repository_impl.dart | 23 +++ .../domain/lib/entities/recurrence_rule.dart | 94 ++++++++++ toondo/packages/domain/lib/entities/todo.dart | 26 ++- .../lib/repositories/todo_repository.dart | 11 ++ .../usecases/todo/create_recurring_todo.dart | 18 ++ .../usecases/todo/delete_recurring_todo.dart | 13 ++ .../todo/expand_recurring_todos_for_date.dart | 131 ++++++++++++++ .../usecases/todo/update_recurring_todo.dart | 15 ++ .../test/entities/recurrence_rule_test.dart | 48 +++++ .../test/mocks/mock_todo_repository.dart | 40 +++++ .../expand_recurring_todos_for_date_test.dart | 165 ++++++++++++++++++ 11 files changed, 578 insertions(+), 6 deletions(-) create mode 100644 toondo/packages/domain/lib/entities/recurrence_rule.dart create mode 100644 toondo/packages/domain/lib/usecases/todo/create_recurring_todo.dart create mode 100644 toondo/packages/domain/lib/usecases/todo/delete_recurring_todo.dart create mode 100644 toondo/packages/domain/lib/usecases/todo/expand_recurring_todos_for_date.dart create mode 100644 toondo/packages/domain/lib/usecases/todo/update_recurring_todo.dart create mode 100644 toondo/packages/domain/test/entities/recurrence_rule_test.dart create mode 100644 toondo/packages/domain/test/usecases/todo/expand_recurring_todos_for_date_test.dart diff --git a/toondo/packages/data/lib/repositories/todo_repository_impl.dart b/toondo/packages/data/lib/repositories/todo_repository_impl.dart index bff2011a..6996fdd9 100644 --- a/toondo/packages/data/lib/repositories/todo_repository_impl.dart +++ b/toondo/packages/data/lib/repositories/todo_repository_impl.dart @@ -223,4 +223,27 @@ class TodoRepositoryImpl implements TodoRepository { if (goalId == null) return null; return int.tryParse(goalId); } + + @override + Future> getRecurringSeries() async { + throw UnimplementedError('Implemented in F2 step 3'); + } + + @override + Future deleteSeries(String seriesId) async { + throw UnimplementedError('Implemented in F2 step 3'); + } + + @override + Future findOccurrence({ + required String seriesId, + required DateTime occurrenceDate, + }) async { + throw UnimplementedError('Implemented in F2 step 3'); + } + + @override + Future materializeOccurrence(Todo occurrence) async { + throw UnimplementedError('Implemented in F2 step 3'); + } } diff --git a/toondo/packages/domain/lib/entities/recurrence_rule.dart b/toondo/packages/domain/lib/entities/recurrence_rule.dart new file mode 100644 index 00000000..3d4c35e6 --- /dev/null +++ b/toondo/packages/domain/lib/entities/recurrence_rule.dart @@ -0,0 +1,94 @@ +enum RecurrenceFrequency { daily, weekly, monthly, yearly } + +sealed class RecurrenceEnd { + const RecurrenceEnd(); +} + +class EndNever extends RecurrenceEnd { + const EndNever(); + + @override + bool operator ==(Object other) => other is EndNever; + + @override + int get hashCode => 0; +} + +class EndOnDate extends RecurrenceEnd { + final DateTime date; + const EndOnDate(this.date); + + @override + bool operator ==(Object other) => + other is EndOnDate && other.date == date; + + @override + int get hashCode => date.hashCode; +} + +class EndAfterCount extends RecurrenceEnd { + final int count; + const EndAfterCount(this.count); + + @override + bool operator ==(Object other) => + other is EndAfterCount && other.count == count; + + @override + int get hashCode => count.hashCode; +} + +class RecurrenceRule { + final RecurrenceFrequency frequency; + final int interval; + final List byWeekdays; + final int? byMonthDay; + final RecurrenceEnd end; + + const RecurrenceRule({ + required this.frequency, + this.interval = 1, + this.byWeekdays = const [], + this.byMonthDay, + this.end = const EndNever(), + }); + + RecurrenceRule copyWith({ + RecurrenceFrequency? frequency, + int? interval, + List? byWeekdays, + int? byMonthDay, + RecurrenceEnd? end, + }) { + return RecurrenceRule( + frequency: frequency ?? this.frequency, + interval: interval ?? this.interval, + byWeekdays: byWeekdays ?? this.byWeekdays, + byMonthDay: byMonthDay ?? this.byMonthDay, + end: end ?? this.end, + ); + } + + @override + bool operator ==(Object other) { + if (other is! RecurrenceRule) return false; + if (other.frequency != frequency) return false; + if (other.interval != interval) return false; + if (other.byMonthDay != byMonthDay) return false; + if (other.end != end) return false; + if (other.byWeekdays.length != byWeekdays.length) return false; + for (var i = 0; i < byWeekdays.length; i++) { + if (other.byWeekdays[i] != byWeekdays[i]) return false; + } + return true; + } + + @override + int get hashCode => Object.hash( + frequency, + interval, + byMonthDay, + end, + Object.hashAll(byWeekdays), + ); +} diff --git a/toondo/packages/domain/lib/entities/todo.dart b/toondo/packages/domain/lib/entities/todo.dart index 559949b1..e92e72f0 100644 --- a/toondo/packages/domain/lib/entities/todo.dart +++ b/toondo/packages/domain/lib/entities/todo.dart @@ -1,15 +1,21 @@ +import 'package:domain/entities/recurrence_rule.dart'; + class Todo { - static int currentId = 1; // Global ID variable + static int currentId = 1; final String id; final String title; final String? goalId; - final double status; // TODO: 서버 API에 따라 0.0(진행) 또는 1.0(완료)만 사용 + final double status; final String comment; final DateTime startDate; final DateTime endDate; - final int eisenhower; // TODO: 0,1,2,3 인지 1,2,3,4 인지 서버 API 스펙 확인 필요 - final bool showOnHome; // 메인화면 노출 여부 + final int eisenhower; + final bool showOnHome; + + final RecurrenceRule? recurrence; + final String? seriesId; + final DateTime? occurrenceDate; Todo({ required this.id, @@ -21,6 +27,9 @@ class Todo { this.comment = '', this.eisenhower = 0, this.showOnHome = false, + this.recurrence, + this.seriesId, + this.occurrenceDate, }); bool isDDayTodo() { @@ -30,11 +39,16 @@ class Todo { } bool isFinished() { - return status == 1.0; // 서버 API에 맞춰 1.0으로 수정 + return status == 1.0; } - // 상태를 토글하는 헬퍼 메서드 double getToggledStatus() { return status == 0.0 ? 1.0 : 0.0; } + + bool get isRecurring => recurrence != null; + + bool get isRecurringSeries => isRecurring && seriesId == null; + + bool get isRecurringOccurrence => seriesId != null && occurrenceDate != null; } diff --git a/toondo/packages/domain/lib/repositories/todo_repository.dart b/toondo/packages/domain/lib/repositories/todo_repository.dart index bdb01d04..e870a731 100644 --- a/toondo/packages/domain/lib/repositories/todo_repository.dart +++ b/toondo/packages/domain/lib/repositories/todo_repository.dart @@ -13,4 +13,15 @@ abstract class TodoRepository { DateTime newEndDate, ); Future updateTodoStatus(Todo todo, double status); + + Future> getRecurringSeries(); + + Future deleteSeries(String seriesId); + + Future findOccurrence({ + required String seriesId, + required DateTime occurrenceDate, + }); + + Future materializeOccurrence(Todo occurrence); } diff --git a/toondo/packages/domain/lib/usecases/todo/create_recurring_todo.dart b/toondo/packages/domain/lib/usecases/todo/create_recurring_todo.dart new file mode 100644 index 00000000..86410cef --- /dev/null +++ b/toondo/packages/domain/lib/usecases/todo/create_recurring_todo.dart @@ -0,0 +1,18 @@ +import 'package:domain/entities/todo.dart'; +import 'package:domain/repositories/todo_repository.dart'; +import 'package:injectable/injectable.dart'; + +@injectable +class CreateRecurringTodoUseCase { + final TodoRepository repository; + + CreateRecurringTodoUseCase(this.repository); + + Future call(Todo seriesTemplate) async { + assert(seriesTemplate.recurrence != null, + 'CreateRecurringTodoUseCase requires a recurrence rule'); + assert(seriesTemplate.seriesId == null, + 'Series template must not carry a seriesId'); + return repository.createTodo(seriesTemplate); + } +} diff --git a/toondo/packages/domain/lib/usecases/todo/delete_recurring_todo.dart b/toondo/packages/domain/lib/usecases/todo/delete_recurring_todo.dart new file mode 100644 index 00000000..c613478c --- /dev/null +++ b/toondo/packages/domain/lib/usecases/todo/delete_recurring_todo.dart @@ -0,0 +1,13 @@ +import 'package:domain/repositories/todo_repository.dart'; +import 'package:injectable/injectable.dart'; + +@injectable +class DeleteRecurringTodoUseCase { + final TodoRepository repository; + + DeleteRecurringTodoUseCase(this.repository); + + Future call(String seriesId) async { + await repository.deleteSeries(seriesId); + } +} diff --git a/toondo/packages/domain/lib/usecases/todo/expand_recurring_todos_for_date.dart b/toondo/packages/domain/lib/usecases/todo/expand_recurring_todos_for_date.dart new file mode 100644 index 00000000..5e2e7332 --- /dev/null +++ b/toondo/packages/domain/lib/usecases/todo/expand_recurring_todos_for_date.dart @@ -0,0 +1,131 @@ +import 'package:domain/entities/recurrence_rule.dart'; +import 'package:domain/entities/todo.dart'; +import 'package:domain/repositories/todo_repository.dart'; +import 'package:injectable/injectable.dart'; + +@injectable +class ExpandRecurringTodosForDateUseCase { + final TodoRepository repository; + + ExpandRecurringTodosForDateUseCase(this.repository); + + Future> call(DateTime targetDate) async { + final dayOnly = DateTime(targetDate.year, targetDate.month, targetDate.day); + final series = await repository.getRecurringSeries(); + final result = []; + for (final s in series) { + if (!_isOccurrence(s, dayOnly)) continue; + final materialized = await repository.findOccurrence( + seriesId: s.id, + occurrenceDate: dayOnly, + ); + result.add(materialized ?? _virtualOccurrence(s, dayOnly)); + } + return result; + } + + Todo _virtualOccurrence(Todo series, DateTime date) { + return Todo( + id: '${series.id}@${date.toIso8601String()}', + title: series.title, + goalId: series.goalId, + startDate: date, + endDate: date, + comment: series.comment, + eisenhower: series.eisenhower, + showOnHome: series.showOnHome, + recurrence: series.recurrence, + seriesId: series.id, + occurrenceDate: date, + ); + } + + bool _isOccurrence(Todo series, DateTime date) { + final rule = series.recurrence!; + final start = DateTime( + series.startDate.year, + series.startDate.month, + series.startDate.day, + ); + if (date.isBefore(start)) return false; + if (!_isBeforeEnd(rule.end, date, start)) return false; + + switch (rule.frequency) { + case RecurrenceFrequency.daily: + final diffDays = date.difference(start).inDays; + if (diffDays % rule.interval != 0) return false; + return _isWithinCount(rule.end, diffDays ~/ rule.interval + 1); + case RecurrenceFrequency.weekly: + final weekdays = rule.byWeekdays.isEmpty + ? [start.weekday] + : rule.byWeekdays; + if (!weekdays.contains(date.weekday)) return false; + final weekDiff = _weeksBetween(start, date); + if (weekDiff % rule.interval != 0) return false; + if (rule.end is EndAfterCount) { + final count = (rule.end as EndAfterCount).count; + final occurrence = _occurrenceIndexWeekly( + start: start, + date: date, + weekdays: weekdays, + interval: rule.interval, + ); + return occurrence <= count; + } + return true; + case RecurrenceFrequency.monthly: + final day = rule.byMonthDay ?? start.day; + if (date.day != day) return false; + final monthDiff = (date.year - start.year) * 12 + date.month - start.month; + if (monthDiff < 0 || monthDiff % rule.interval != 0) return false; + return _isWithinCount(rule.end, monthDiff ~/ rule.interval + 1); + case RecurrenceFrequency.yearly: + if (date.month != start.month || date.day != start.day) return false; + final yearDiff = date.year - start.year; + if (yearDiff < 0 || yearDiff % rule.interval != 0) return false; + return _isWithinCount(rule.end, yearDiff ~/ rule.interval + 1); + } + } + + bool _isBeforeEnd(RecurrenceEnd end, DateTime date, DateTime start) { + switch (end) { + case EndNever(): + return true; + case EndOnDate(date: final endDate): + final endDay = DateTime(endDate.year, endDate.month, endDate.day); + return !date.isAfter(endDay); + case EndAfterCount(): + return true; + } + } + + bool _isWithinCount(RecurrenceEnd end, int occurrenceIndex) { + if (end is EndAfterCount) return occurrenceIndex <= end.count; + if (end is EndOnDate) return true; + return true; + } + + int _weeksBetween(DateTime start, DateTime date) { + final startWeekStart = start.subtract(Duration(days: start.weekday % 7)); + final dateWeekStart = date.subtract(Duration(days: date.weekday % 7)); + return dateWeekStart.difference(startWeekStart).inDays ~/ 7; + } + + int _occurrenceIndexWeekly({ + required DateTime start, + required DateTime date, + required List weekdays, + required int interval, + }) { + var count = 0; + var cursor = start; + while (!cursor.isAfter(date)) { + final weekDiff = _weeksBetween(start, cursor); + if (weekDiff % interval == 0 && weekdays.contains(cursor.weekday)) { + count++; + } + cursor = cursor.add(const Duration(days: 1)); + } + return count; + } +} diff --git a/toondo/packages/domain/lib/usecases/todo/update_recurring_todo.dart b/toondo/packages/domain/lib/usecases/todo/update_recurring_todo.dart new file mode 100644 index 00000000..0a378407 --- /dev/null +++ b/toondo/packages/domain/lib/usecases/todo/update_recurring_todo.dart @@ -0,0 +1,15 @@ +import 'package:domain/entities/todo.dart'; +import 'package:domain/repositories/todo_repository.dart'; +import 'package:injectable/injectable.dart'; + +@injectable +class UpdateRecurringTodoUseCase { + final TodoRepository repository; + + UpdateRecurringTodoUseCase(this.repository); + + Future call(Todo series) async { + assert(series.recurrence != null); + await repository.updateTodo(series); + } +} diff --git a/toondo/packages/domain/test/entities/recurrence_rule_test.dart b/toondo/packages/domain/test/entities/recurrence_rule_test.dart new file mode 100644 index 00000000..4d6c9ba5 --- /dev/null +++ b/toondo/packages/domain/test/entities/recurrence_rule_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:domain/entities/recurrence_rule.dart'; + +void main() { + group('RecurrenceRule', () { + test('기본값으로 생성되어야 한다', () { + final rule = RecurrenceRule(frequency: RecurrenceFrequency.daily); + expect(rule.interval, 1); + expect(rule.byWeekdays, isEmpty); + expect(rule.byMonthDay, isNull); + expect(rule.end, const EndNever()); + }); + + test('동일 값 규칙은 같아야 한다', () { + final a = RecurrenceRule( + frequency: RecurrenceFrequency.weekly, + interval: 2, + byWeekdays: const [1, 3, 5], + end: const EndAfterCount(10), + ); + final b = RecurrenceRule( + frequency: RecurrenceFrequency.weekly, + interval: 2, + byWeekdays: const [1, 3, 5], + end: const EndAfterCount(10), + ); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('copyWith로 일부 필드만 변경 가능', () { + final rule = RecurrenceRule(frequency: RecurrenceFrequency.daily); + final updated = rule.copyWith(interval: 3); + expect(updated.frequency, RecurrenceFrequency.daily); + expect(updated.interval, 3); + }); + }); + + group('RecurrenceEnd', () { + test('EndOnDate 동일 날짜는 같아야 한다', () { + expect(EndOnDate(DateTime(2025, 1, 1)), EndOnDate(DateTime(2025, 1, 1))); + }); + test('EndAfterCount 동일 횟수는 같아야 한다', () { + expect(const EndAfterCount(5), const EndAfterCount(5)); + expect(const EndAfterCount(5) == const EndAfterCount(6), isFalse); + }); + }); +} diff --git a/toondo/packages/domain/test/mocks/mock_todo_repository.dart b/toondo/packages/domain/test/mocks/mock_todo_repository.dart index 8fee4703..f9c6b821 100644 --- a/toondo/packages/domain/test/mocks/mock_todo_repository.dart +++ b/toondo/packages/domain/test/mocks/mock_todo_repository.dart @@ -74,4 +74,44 @@ class MockTodoRepository extends Mock implements TodoRepository { returnValueForMissingStub: Future.value(), ); } + + @override + Future> getRecurringSeries() { + return super.noSuchMethod( + Invocation.method(#getRecurringSeries, []), + returnValue: Future.value([]), + returnValueForMissingStub: Future.value([]), + ); + } + + @override + Future deleteSeries(String seriesId) { + return super.noSuchMethod( + Invocation.method(#deleteSeries, [seriesId]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value(), + ); + } + + @override + Future findOccurrence({ + String? seriesId, + DateTime? occurrenceDate, + }) { + return super.noSuchMethod( + Invocation.method(#findOccurrence, [], + {#seriesId: seriesId, #occurrenceDate: occurrenceDate}), + returnValue: Future.value(null), + returnValueForMissingStub: Future.value(null), + ); + } + + @override + Future materializeOccurrence(Todo occurrence) { + return super.noSuchMethod( + Invocation.method(#materializeOccurrence, [occurrence]), + returnValue: Future.value(occurrence), + returnValueForMissingStub: Future.value(occurrence), + ); + } } \ No newline at end of file diff --git a/toondo/packages/domain/test/usecases/todo/expand_recurring_todos_for_date_test.dart b/toondo/packages/domain/test/usecases/todo/expand_recurring_todos_for_date_test.dart new file mode 100644 index 00000000..0390b06c --- /dev/null +++ b/toondo/packages/domain/test/usecases/todo/expand_recurring_todos_for_date_test.dart @@ -0,0 +1,165 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:domain/entities/recurrence_rule.dart'; +import 'package:domain/entities/todo.dart'; +import 'package:domain/usecases/todo/expand_recurring_todos_for_date.dart'; +import '../../mocks/mock_todo_repository.dart'; + +Todo _series({ + required String id, + required DateTime start, + required RecurrenceRule rule, +}) { + return Todo( + id: id, + title: 'series-$id', + startDate: start, + endDate: start, + recurrence: rule, + ); +} + +void main() { + late MockTodoRepository repo; + late ExpandRecurringTodosForDateUseCase useCase; + + setUp(() { + repo = MockTodoRepository(); + useCase = ExpandRecurringTodosForDateUseCase(repo); + }); + + group('Daily', () { + test('매일 반복이면 시작일 이후 모든 날에 인스턴스 생성', () async { + final series = _series( + id: 's1', + start: DateTime(2025, 1, 1), + rule: RecurrenceRule(frequency: RecurrenceFrequency.daily), + ); + when(repo.getRecurringSeries()).thenAnswer((_) async => [series]); + + final result = await useCase.call(DateTime(2025, 1, 5)); + expect(result, hasLength(1)); + expect(result.first.seriesId, 's1'); + expect(result.first.occurrenceDate, DateTime(2025, 1, 5)); + }); + + test('격일 반복(interval=2)은 짝수 일차에만 발생', () async { + final series = _series( + id: 's1', + start: DateTime(2025, 1, 1), + rule: RecurrenceRule( + frequency: RecurrenceFrequency.daily, + interval: 2, + ), + ); + when(repo.getRecurringSeries()).thenAnswer((_) async => [series]); + + expect(await useCase.call(DateTime(2025, 1, 1)), hasLength(1)); + expect(await useCase.call(DateTime(2025, 1, 2)), isEmpty); + expect(await useCase.call(DateTime(2025, 1, 3)), hasLength(1)); + }); + + test('시작일 이전 날짜는 빈 결과', () async { + final series = _series( + id: 's1', + start: DateTime(2025, 1, 10), + rule: RecurrenceRule(frequency: RecurrenceFrequency.daily), + ); + when(repo.getRecurringSeries()).thenAnswer((_) async => [series]); + + expect(await useCase.call(DateTime(2025, 1, 1)), isEmpty); + }); + }); + + group('Weekly', () { + test('매주 월/수/금에만 발생', () async { + final series = _series( + id: 's1', + start: DateTime(2025, 1, 6), // Mon + rule: RecurrenceRule( + frequency: RecurrenceFrequency.weekly, + byWeekdays: [1, 3, 5], + ), + ); + when(repo.getRecurringSeries()).thenAnswer((_) async => [series]); + + expect(await useCase.call(DateTime(2025, 1, 6)), hasLength(1)); // Mon + expect(await useCase.call(DateTime(2025, 1, 7)), isEmpty); // Tue + expect(await useCase.call(DateTime(2025, 1, 8)), hasLength(1)); // Wed + expect(await useCase.call(DateTime(2025, 1, 13)), hasLength(1)); // 다음 Mon + }); + }); + + group('Monthly', () { + test('매달 같은 날 발생', () async { + final series = _series( + id: 's1', + start: DateTime(2025, 1, 15), + rule: RecurrenceRule(frequency: RecurrenceFrequency.monthly), + ); + when(repo.getRecurringSeries()).thenAnswer((_) async => [series]); + + expect(await useCase.call(DateTime(2025, 2, 15)), hasLength(1)); + expect(await useCase.call(DateTime(2025, 2, 16)), isEmpty); + expect(await useCase.call(DateTime(2025, 3, 15)), hasLength(1)); + }); + }); + + group('End conditions', () { + test('EndOnDate 이후 날짜는 발생하지 않음', () async { + final series = _series( + id: 's1', + start: DateTime(2025, 1, 1), + rule: RecurrenceRule( + frequency: RecurrenceFrequency.daily, + end: EndOnDate(DateTime(2025, 1, 3)), + ), + ); + when(repo.getRecurringSeries()).thenAnswer((_) async => [series]); + + expect(await useCase.call(DateTime(2025, 1, 3)), hasLength(1)); + expect(await useCase.call(DateTime(2025, 1, 4)), isEmpty); + }); + + test('EndAfterCount: N회 이후 발생하지 않음', () async { + final series = _series( + id: 's1', + start: DateTime(2025, 1, 1), + rule: RecurrenceRule( + frequency: RecurrenceFrequency.daily, + end: const EndAfterCount(3), + ), + ); + when(repo.getRecurringSeries()).thenAnswer((_) async => [series]); + + expect(await useCase.call(DateTime(2025, 1, 3)), hasLength(1)); + expect(await useCase.call(DateTime(2025, 1, 4)), isEmpty); + }); + }); + + test('이미 머터리얼라이즈된 인스턴스가 있으면 그것을 우선 반환', () async { + final series = _series( + id: 's1', + start: DateTime(2025, 1, 1), + rule: RecurrenceRule(frequency: RecurrenceFrequency.daily), + ); + final materialized = Todo( + id: 'mat-1', + title: 'series-s1', + startDate: DateTime(2025, 1, 5), + endDate: DateTime(2025, 1, 5), + status: 1.0, + seriesId: 's1', + occurrenceDate: DateTime(2025, 1, 5), + ); + when(repo.getRecurringSeries()).thenAnswer((_) async => [series]); + when(repo.findOccurrence( + seriesId: 's1', + occurrenceDate: DateTime(2025, 1, 5), + )).thenAnswer((_) async => materialized); + + final result = await useCase.call(DateTime(2025, 1, 5)); + expect(result.single.id, 'mat-1'); + expect(result.single.status, 1.0); + }); +} From 5b2bd35e04ba0f12fb9b7d4c406424d86e08ec22 Mon Sep 17 00:00:00 2001 From: Jun Seo Date: Sat, 6 Jun 2026 13:46:01 +0900 Subject: [PATCH 2/7] feat(data): add Hive models for recurrence + register adapters RecurrenceRuleModel (typeId 8), RecurrenceEndModel (7), RecurrenceEndKind (6), and RecurrenceFrequencyModel (5) provide Hive-backed persistence for the new domain types. TodoModel adds three nullable fields (recurrence/seriesId/occurrenceDate) at HiveField 10-12, so existing boxes remain readable without migration. Adapters are registered in main.dart, and a round-trip test confirms non-recurring todos persist with null recurrence, weekly rules with EndAfterCount survive the round-trip, materialized occurrences keep their seriesId, and EndOnDate/EndNever both deserialize correctly. Co-Authored-By: Claude Opus 4.7 --- toondo/lib/main.dart | 5 + .../lib/models/recurrence_rule_model.dart | 117 +++++++++++ .../lib/models/recurrence_rule_model.g.dart | 187 ++++++++++++++++++ .../packages/data/lib/models/todo_model.dart | 36 +++- .../data/lib/models/todo_model.g.dart | 13 +- .../models/todo_model_recurrence_test.dart | 128 ++++++++++++ 6 files changed, 474 insertions(+), 12 deletions(-) create mode 100644 toondo/packages/data/lib/models/recurrence_rule_model.dart create mode 100644 toondo/packages/data/lib/models/recurrence_rule_model.g.dart create mode 100644 toondo/packages/data/test/models/todo_model_recurrence_test.dart diff --git a/toondo/lib/main.dart b/toondo/lib/main.dart index 3cd80022..935813b2 100644 --- a/toondo/lib/main.dart +++ b/toondo/lib/main.dart @@ -20,6 +20,7 @@ import 'package:data/models/todo_model.dart'; import 'package:data/models/user_model.dart'; import 'package:data/models/goal_model.dart'; import 'package:data/models/goal_status_enum.dart'; +import 'package:data/models/recurrence_rule_model.dart'; import 'package:data/models/custom_icon_model.dart'; import 'package:toondo/injection/di.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -117,6 +118,10 @@ class _AppBootstrapperState extends State { Hive.registerAdapter(UserModelAdapter()); Hive.registerAdapter(GoalStatusEnumAdapter()); Hive.registerAdapter(CustomIconModelAdapter()); + Hive.registerAdapter(RecurrenceFrequencyModelAdapter()); + Hive.registerAdapter(RecurrenceEndKindAdapter()); + Hive.registerAdapter(RecurrenceEndModelAdapter()); + Hive.registerAdapter(RecurrenceRuleModelAdapter()); if (mounted) { setState(() => _loadingStatus = '데이터 불러오는 중...'); diff --git a/toondo/packages/data/lib/models/recurrence_rule_model.dart b/toondo/packages/data/lib/models/recurrence_rule_model.dart new file mode 100644 index 00000000..5c81f744 --- /dev/null +++ b/toondo/packages/data/lib/models/recurrence_rule_model.dart @@ -0,0 +1,117 @@ +import 'package:hive/hive.dart'; +import 'package:domain/entities/recurrence_rule.dart'; + +part 'recurrence_rule_model.g.dart'; + +@HiveType(typeId: 5) +enum RecurrenceFrequencyModel { + @HiveField(0) + daily, + @HiveField(1) + weekly, + @HiveField(2) + monthly, + @HiveField(3) + yearly, +} + +@HiveType(typeId: 6) +enum RecurrenceEndKind { + @HiveField(0) + never, + @HiveField(1) + onDate, + @HiveField(2) + afterCount, +} + +@HiveType(typeId: 7) +class RecurrenceEndModel { + @HiveField(0) + final RecurrenceEndKind kind; + + @HiveField(1) + final DateTime? date; + + @HiveField(2) + final int? count; + + RecurrenceEndModel({required this.kind, this.date, this.count}); + + factory RecurrenceEndModel.fromEntity(RecurrenceEnd end) { + return switch (end) { + EndNever() => RecurrenceEndModel(kind: RecurrenceEndKind.never), + EndOnDate(date: final d) => + RecurrenceEndModel(kind: RecurrenceEndKind.onDate, date: d), + EndAfterCount(count: final c) => + RecurrenceEndModel(kind: RecurrenceEndKind.afterCount, count: c), + }; + } + + RecurrenceEnd toEntity() { + switch (kind) { + case RecurrenceEndKind.never: + return const EndNever(); + case RecurrenceEndKind.onDate: + return EndOnDate(date!); + case RecurrenceEndKind.afterCount: + return EndAfterCount(count!); + } + } +} + +@HiveType(typeId: 8) +class RecurrenceRuleModel { + @HiveField(0) + final RecurrenceFrequencyModel frequency; + + @HiveField(1) + final int interval; + + @HiveField(2) + final List byWeekdays; + + @HiveField(3) + final int? byMonthDay; + + @HiveField(4) + final RecurrenceEndModel end; + + RecurrenceRuleModel({ + required this.frequency, + this.interval = 1, + this.byWeekdays = const [], + this.byMonthDay, + required this.end, + }); + + factory RecurrenceRuleModel.fromEntity(RecurrenceRule rule) { + return RecurrenceRuleModel( + frequency: switch (rule.frequency) { + RecurrenceFrequency.daily => RecurrenceFrequencyModel.daily, + RecurrenceFrequency.weekly => RecurrenceFrequencyModel.weekly, + RecurrenceFrequency.monthly => RecurrenceFrequencyModel.monthly, + RecurrenceFrequency.yearly => RecurrenceFrequencyModel.yearly, + }, + interval: rule.interval, + byWeekdays: List.from(rule.byWeekdays), + byMonthDay: rule.byMonthDay, + end: RecurrenceEndModel.fromEntity(rule.end), + ); + } + + RecurrenceRule toEntity() { + return RecurrenceRule( + frequency: switch (frequency) { + RecurrenceFrequencyModel.daily => RecurrenceFrequency.daily, + RecurrenceFrequencyModel.weekly => RecurrenceFrequency.weekly, + RecurrenceFrequencyModel.monthly => RecurrenceFrequency.monthly, + RecurrenceFrequencyModel.yearly => RecurrenceFrequency.yearly, + }, + interval: interval, + byWeekdays: List.from(byWeekdays), + byMonthDay: byMonthDay, + end: end.toEntity(), + ); + } +} diff --git a/toondo/packages/data/lib/models/recurrence_rule_model.g.dart b/toondo/packages/data/lib/models/recurrence_rule_model.g.dart new file mode 100644 index 00000000..dcbaf5c2 --- /dev/null +++ b/toondo/packages/data/lib/models/recurrence_rule_model.g.dart @@ -0,0 +1,187 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recurrence_rule_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class RecurrenceEndModelAdapter extends TypeAdapter { + @override + final int typeId = 7; + + @override + RecurrenceEndModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return RecurrenceEndModel( + kind: fields[0] as RecurrenceEndKind, + date: fields[1] as DateTime?, + count: fields[2] as int?, + ); + } + + @override + void write(BinaryWriter writer, RecurrenceEndModel obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.kind) + ..writeByte(1) + ..write(obj.date) + ..writeByte(2) + ..write(obj.count); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RecurrenceEndModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class RecurrenceRuleModelAdapter extends TypeAdapter { + @override + final int typeId = 8; + + @override + RecurrenceRuleModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return RecurrenceRuleModel( + frequency: fields[0] as RecurrenceFrequencyModel, + interval: fields[1] as int, + byWeekdays: (fields[2] as List).cast(), + byMonthDay: fields[3] as int?, + end: fields[4] as RecurrenceEndModel, + ); + } + + @override + void write(BinaryWriter writer, RecurrenceRuleModel obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.frequency) + ..writeByte(1) + ..write(obj.interval) + ..writeByte(2) + ..write(obj.byWeekdays) + ..writeByte(3) + ..write(obj.byMonthDay) + ..writeByte(4) + ..write(obj.end); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RecurrenceRuleModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class RecurrenceFrequencyModelAdapter + extends TypeAdapter { + @override + final int typeId = 5; + + @override + RecurrenceFrequencyModel read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return RecurrenceFrequencyModel.daily; + case 1: + return RecurrenceFrequencyModel.weekly; + case 2: + return RecurrenceFrequencyModel.monthly; + case 3: + return RecurrenceFrequencyModel.yearly; + default: + return RecurrenceFrequencyModel.daily; + } + } + + @override + void write(BinaryWriter writer, RecurrenceFrequencyModel obj) { + switch (obj) { + case RecurrenceFrequencyModel.daily: + writer.writeByte(0); + break; + case RecurrenceFrequencyModel.weekly: + writer.writeByte(1); + break; + case RecurrenceFrequencyModel.monthly: + writer.writeByte(2); + break; + case RecurrenceFrequencyModel.yearly: + writer.writeByte(3); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RecurrenceFrequencyModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class RecurrenceEndKindAdapter extends TypeAdapter { + @override + final int typeId = 6; + + @override + RecurrenceEndKind read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return RecurrenceEndKind.never; + case 1: + return RecurrenceEndKind.onDate; + case 2: + return RecurrenceEndKind.afterCount; + default: + return RecurrenceEndKind.never; + } + } + + @override + void write(BinaryWriter writer, RecurrenceEndKind obj) { + switch (obj) { + case RecurrenceEndKind.never: + writer.writeByte(0); + break; + case RecurrenceEndKind.onDate: + writer.writeByte(1); + break; + case RecurrenceEndKind.afterCount: + writer.writeByte(2); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RecurrenceEndKindAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/toondo/packages/data/lib/models/todo_model.dart b/toondo/packages/data/lib/models/todo_model.dart index 038583f7..de6dbc6a 100644 --- a/toondo/packages/data/lib/models/todo_model.dart +++ b/toondo/packages/data/lib/models/todo_model.dart @@ -1,6 +1,7 @@ import 'package:hive/hive.dart'; import 'package:domain/entities/todo.dart'; -part 'todo_model.g.dart'; // Hive 어댑터 생성 +import 'package:data/models/recurrence_rule_model.dart'; +part 'todo_model.g.dart'; @HiveType(typeId: 0) class TodoModel extends HiveObject { @@ -26,13 +27,22 @@ class TodoModel extends HiveObject { DateTime endDate; @HiveField(7) - int eisenhower; // 0: 중요하지않고 급하지않음, 1: 급함, 2: 중요함, 3: 중요하고 급함 + int eisenhower; @HiveField(8) - bool isSynced; // 로컬 데이터 동기화 여부 + bool isSynced; @HiveField(9, defaultValue: false) - bool showOnHome; // 메인화면 노출 여부 + bool showOnHome; + + @HiveField(10) + RecurrenceRuleModel? recurrence; + + @HiveField(11) + String? seriesId; + + @HiveField(12) + DateTime? occurrenceDate; TodoModel({ required this.id, @@ -45,16 +55,16 @@ class TodoModel extends HiveObject { this.eisenhower = 0, this.isSynced = false, this.showOnHome = false, + this.recurrence, + this.seriesId, + this.occurrenceDate, }); - // New getter and setter for isSynced bool get synced => isSynced; set synced(bool value) { isSynced = value; - // Optionally persist change: save(); } - // ✅ Entity → Model 변환 factory TodoModel.fromEntity(Todo entity) { return TodoModel( id: entity.id, @@ -67,10 +77,14 @@ class TodoModel extends HiveObject { eisenhower: entity.eisenhower, showOnHome: entity.showOnHome, isSynced: false, + recurrence: entity.recurrence == null + ? null + : RecurrenceRuleModel.fromEntity(entity.recurrence!), + seriesId: entity.seriesId, + occurrenceDate: entity.occurrenceDate, ); } - // ✅ Model → Entity 변환 Todo toEntity() { return Todo( id: id, @@ -82,10 +96,12 @@ class TodoModel extends HiveObject { endDate: endDate, eisenhower: eisenhower, showOnHome: showOnHome, + recurrence: recurrence?.toEntity(), + seriesId: seriesId, + occurrenceDate: occurrenceDate, ); } - // ✅ JSON 변환 (API 통신용) Map toJson() { return { 'id': id, @@ -110,7 +126,7 @@ class TodoModel extends HiveObject { startDate: DateTime.parse(json['startDate']), endDate: DateTime.parse(json['endDate']), eisenhower: (json['eisenhower'] as num).toInt(), - showOnHome: json['showOnHome'] ?? false, // 기본값 false로 설정 + showOnHome: json['showOnHome'] ?? false, isSynced: true, ); } diff --git a/toondo/packages/data/lib/models/todo_model.g.dart b/toondo/packages/data/lib/models/todo_model.g.dart index c6a506e3..9233e807 100644 --- a/toondo/packages/data/lib/models/todo_model.g.dart +++ b/toondo/packages/data/lib/models/todo_model.g.dart @@ -27,13 +27,16 @@ class TodoModelAdapter extends TypeAdapter { eisenhower: fields[7] as int, isSynced: fields[8] as bool, showOnHome: fields[9] == null ? false : fields[9] as bool, + recurrence: fields[10] as RecurrenceRuleModel?, + seriesId: fields[11] as String?, + occurrenceDate: fields[12] as DateTime?, ); } @override void write(BinaryWriter writer, TodoModel obj) { writer - ..writeByte(10) + ..writeByte(13) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -53,7 +56,13 @@ class TodoModelAdapter extends TypeAdapter { ..writeByte(8) ..write(obj.isSynced) ..writeByte(9) - ..write(obj.showOnHome); + ..write(obj.showOnHome) + ..writeByte(10) + ..write(obj.recurrence) + ..writeByte(11) + ..write(obj.seriesId) + ..writeByte(12) + ..write(obj.occurrenceDate); } @override diff --git a/toondo/packages/data/test/models/todo_model_recurrence_test.dart b/toondo/packages/data/test/models/todo_model_recurrence_test.dart new file mode 100644 index 00000000..20b27a78 --- /dev/null +++ b/toondo/packages/data/test/models/todo_model_recurrence_test.dart @@ -0,0 +1,128 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:data/models/todo_model.dart'; +import 'package:data/models/recurrence_rule_model.dart'; +import 'package:domain/entities/recurrence_rule.dart'; +import 'package:domain/entities/todo.dart'; + +void main() { + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('todo_model_test_'); + Hive.init(tempDir.path); + Hive.registerAdapter(TodoModelAdapter()); + Hive.registerAdapter(RecurrenceFrequencyModelAdapter()); + Hive.registerAdapter(RecurrenceEndKindAdapter()); + Hive.registerAdapter(RecurrenceEndModelAdapter()); + Hive.registerAdapter(RecurrenceRuleModelAdapter()); + }); + + tearDownAll(() async { + await Hive.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('TodoModel Hive round-trip', () { + test('반복 없는 Todo는 nullable 필드가 모두 null로 보존', () async { + final box = await Hive.openBox('non_recurring_${DateTime.now().microsecondsSinceEpoch}'); + final model = TodoModel( + id: 't1', + title: '일반 할 일', + startDate: DateTime(2025, 1, 1), + endDate: DateTime(2025, 1, 1), + ); + await box.put(model.id, model); + + final restored = box.get('t1')!; + expect(restored.recurrence, isNull); + expect(restored.seriesId, isNull); + expect(restored.occurrenceDate, isNull); + expect(restored.toEntity().isRecurring, isFalse); + + await box.close(); + }); + + test('반복 규칙이 포함된 Todo는 라운드트립 후 동일', () async { + final box = await Hive.openBox('recurring_${DateTime.now().microsecondsSinceEpoch}'); + final rule = RecurrenceRule( + frequency: RecurrenceFrequency.weekly, + interval: 2, + byWeekdays: const [1, 3, 5], + end: const EndAfterCount(10), + ); + final entity = Todo( + id: 's1', + title: '주 3회 운동', + startDate: DateTime(2025, 1, 6), + endDate: DateTime(2025, 1, 6), + recurrence: rule, + ); + await box.put(entity.id, TodoModel.fromEntity(entity)); + + final restored = box.get('s1')!.toEntity(); + expect(restored.recurrence, equals(rule)); + expect(restored.isRecurring, isTrue); + expect(restored.isRecurringSeries, isTrue); + + await box.close(); + }); + + test('머터리얼라이즈된 occurrence는 seriesId/occurrenceDate 보존', () async { + final box = await Hive.openBox('materialized_${DateTime.now().microsecondsSinceEpoch}'); + final occurrence = Todo( + id: 'm1', + title: '운동', + startDate: DateTime(2025, 1, 8), + endDate: DateTime(2025, 1, 8), + status: 1.0, + seriesId: 's1', + occurrenceDate: DateTime(2025, 1, 8), + ); + await box.put(occurrence.id, TodoModel.fromEntity(occurrence)); + + final restored = box.get('m1')!.toEntity(); + expect(restored.seriesId, 's1'); + expect(restored.occurrenceDate, DateTime(2025, 1, 8)); + expect(restored.status, 1.0); + expect(restored.isRecurringOccurrence, isTrue); + + await box.close(); + }); + + test('EndOnDate / EndNever 종료조건 라운드트립', () async { + final box = await Hive.openBox('end_conditions_${DateTime.now().microsecondsSinceEpoch}'); + + final entity1 = Todo( + id: 'e1', + title: 'until', + startDate: DateTime(2025, 1, 1), + endDate: DateTime(2025, 1, 1), + recurrence: RecurrenceRule( + frequency: RecurrenceFrequency.daily, + end: EndOnDate(DateTime(2025, 3, 1)), + ), + ); + final entity2 = Todo( + id: 'e2', + title: 'forever', + startDate: DateTime(2025, 1, 1), + endDate: DateTime(2025, 1, 1), + recurrence: RecurrenceRule(frequency: RecurrenceFrequency.daily), + ); + + await box.put(entity1.id, TodoModel.fromEntity(entity1)); + await box.put(entity2.id, TodoModel.fromEntity(entity2)); + + expect(box.get('e1')!.toEntity().recurrence!.end, + EndOnDate(DateTime(2025, 3, 1))); + expect(box.get('e2')!.toEntity().recurrence!.end, const EndNever()); + + await box.close(); + }); + }); +} From f425b1d09a33ed8e6fb3734c84ecea53b36c282d Mon Sep 17 00:00:00 2001 From: Jun Seo Date: Sat, 6 Jun 2026 13:47:53 +0900 Subject: [PATCH 3/7] feat(data): implement recurring todo repository methods TodoLocalDatasource gains getRecurringSeries (filters non-occurrence todos with a recurrence rule), findOccurrence (matches seriesId + day-normalized occurrenceDate), and deleteSeriesAndUnfinishedOccurrences (drops the series and any unfinished materialized occurrences, preserving completed history). TodoRepositoryImpl delegates the four new contract methods to the datasource. materializeOccurrence persists the entity as-is so the virtual id assigned by ExpandRecurringTodosForDate becomes the stable Hive key for that day's instance. Co-Authored-By: Claude Opus 4.7 --- .../local/todo_local_datasource.dart | 39 ++++++ .../repositories/todo_repository_impl.dart | 14 ++- ...todo_local_datasource_recurrence_test.dart | 116 ++++++++++++++++++ 3 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 toondo/packages/data/test/datasources/local/todo_local_datasource_recurrence_test.dart diff --git a/toondo/packages/data/lib/datasources/local/todo_local_datasource.dart b/toondo/packages/data/lib/datasources/local/todo_local_datasource.dart index 8f3b5b44..2ee6b607 100644 --- a/toondo/packages/data/lib/datasources/local/todo_local_datasource.dart +++ b/toondo/packages/data/lib/datasources/local/todo_local_datasource.dart @@ -65,4 +65,43 @@ class TodoLocalDatasource { await todoBox.put(updatedModel.id, updatedModel); } } + + List getRecurringSeries() { + return todoBox.values + .where((m) => m.recurrence != null && m.seriesId == null) + .map((m) => m.toEntity()) + .toList(); + } + + Todo? findOccurrence({ + required String seriesId, + required DateTime occurrenceDate, + }) { + final dayOnly = DateTime( + occurrenceDate.year, + occurrenceDate.month, + occurrenceDate.day, + ); + for (final m in todoBox.values) { + if (m.seriesId != seriesId) continue; + final occ = m.occurrenceDate; + if (occ == null) continue; + if (DateTime(occ.year, occ.month, occ.day) == dayOnly) { + return m.toEntity(); + } + } + return null; + } + + Future deleteSeriesAndUnfinishedOccurrences(String seriesId) async { + await todoBox.delete(seriesId); + final unfinishedKeys = todoBox.values + .where((m) => + m.seriesId == seriesId && (m.status < 1.0)) + .map((m) => m.id) + .toList(); + for (final id in unfinishedKeys) { + await todoBox.delete(id); + } + } } diff --git a/toondo/packages/data/lib/repositories/todo_repository_impl.dart b/toondo/packages/data/lib/repositories/todo_repository_impl.dart index 6996fdd9..064a07c7 100644 --- a/toondo/packages/data/lib/repositories/todo_repository_impl.dart +++ b/toondo/packages/data/lib/repositories/todo_repository_impl.dart @@ -226,12 +226,12 @@ class TodoRepositoryImpl implements TodoRepository { @override Future> getRecurringSeries() async { - throw UnimplementedError('Implemented in F2 step 3'); + return localDatasource.getRecurringSeries(); } @override Future deleteSeries(String seriesId) async { - throw UnimplementedError('Implemented in F2 step 3'); + await localDatasource.deleteSeriesAndUnfinishedOccurrences(seriesId); } @override @@ -239,11 +239,17 @@ class TodoRepositoryImpl implements TodoRepository { required String seriesId, required DateTime occurrenceDate, }) async { - throw UnimplementedError('Implemented in F2 step 3'); + return localDatasource.findOccurrence( + seriesId: seriesId, + occurrenceDate: occurrenceDate, + ); } @override Future materializeOccurrence(Todo occurrence) async { - throw UnimplementedError('Implemented in F2 step 3'); + assert(occurrence.seriesId != null && occurrence.occurrenceDate != null, + 'materializeOccurrence requires seriesId and occurrenceDate'); + await localDatasource.saveTodo(occurrence); + return occurrence; } } diff --git a/toondo/packages/data/test/datasources/local/todo_local_datasource_recurrence_test.dart b/toondo/packages/data/test/datasources/local/todo_local_datasource_recurrence_test.dart new file mode 100644 index 00000000..6fd50c56 --- /dev/null +++ b/toondo/packages/data/test/datasources/local/todo_local_datasource_recurrence_test.dart @@ -0,0 +1,116 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:data/datasources/local/todo_local_datasource.dart'; +import 'package:data/models/recurrence_rule_model.dart'; +import 'package:data/models/todo_model.dart'; +import 'package:domain/entities/recurrence_rule.dart'; +import 'package:domain/entities/todo.dart'; + +void main() { + late Directory tempDir; + late Box todoBox; + late Box deletedBox; + late TodoLocalDatasource ds; + var counter = 0; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('ds_test_'); + Hive.init(tempDir.path); + Hive.registerAdapter(TodoModelAdapter()); + Hive.registerAdapter(RecurrenceFrequencyModelAdapter()); + Hive.registerAdapter(RecurrenceEndKindAdapter()); + Hive.registerAdapter(RecurrenceEndModelAdapter()); + Hive.registerAdapter(RecurrenceRuleModelAdapter()); + }); + + tearDownAll(() async { + await Hive.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + setUp(() async { + counter++; + todoBox = await Hive.openBox('todos_$counter'); + deletedBox = await Hive.openBox('deleted_$counter'); + ds = TodoLocalDatasource(todoBox, deletedBox); + }); + + tearDown(() async { + await todoBox.close(); + await deletedBox.close(); + }); + + Todo seriesTodo({String id = 's1'}) => Todo( + id: id, + title: 'series', + startDate: DateTime(2025, 1, 1), + endDate: DateTime(2025, 1, 1), + recurrence: RecurrenceRule(frequency: RecurrenceFrequency.daily), + ); + + Todo occurrenceOf(String seriesId, DateTime date, + {double status = 0.0, String? id}) => + Todo( + id: id ?? '$seriesId@${date.toIso8601String()}', + title: 'occurrence', + startDate: date, + endDate: date, + status: status, + seriesId: seriesId, + occurrenceDate: date, + ); + + test('getRecurringSeries는 시리즈만 반환 (단일 todo / occurrence 제외)', () async { + await ds.saveTodo(Todo( + id: 'plain', + title: 'normal', + startDate: DateTime(2025, 1, 1), + endDate: DateTime(2025, 1, 1), + )); + await ds.saveTodo(seriesTodo(id: 's1')); + await ds.saveTodo(occurrenceOf('s1', DateTime(2025, 1, 2))); + + final series = ds.getRecurringSeries(); + expect(series, hasLength(1)); + expect(series.first.id, 's1'); + }); + + test('findOccurrence는 (seriesId, occurrenceDate)로 정확 매칭', () async { + await ds.saveTodo(occurrenceOf('s1', DateTime(2025, 1, 5), status: 1.0)); + await ds.saveTodo(occurrenceOf('s1', DateTime(2025, 1, 6))); + + final hit = ds.findOccurrence( + seriesId: 's1', + occurrenceDate: DateTime(2025, 1, 5), + ); + expect(hit, isNotNull); + expect(hit!.status, 1.0); + + expect( + ds.findOccurrence( + seriesId: 's1', + occurrenceDate: DateTime(2025, 1, 7), + ), + isNull, + ); + }); + + test('deleteSeries는 시리즈와 미완료 occurrence는 삭제, 완료는 보존', () async { + await ds.saveTodo(seriesTodo(id: 's1')); + await ds.saveTodo(occurrenceOf('s1', DateTime(2025, 1, 2), status: 1.0, id: 'done')); + await ds.saveTodo(occurrenceOf('s1', DateTime(2025, 1, 3), id: 'pending')); + await ds.saveTodo(seriesTodo(id: 's2')); + + await ds.deleteSeriesAndUnfinishedOccurrences('s1'); + + final remaining = ds.getAllTodos().map((t) => t.id).toSet(); + expect(remaining, contains('done')); + expect(remaining, contains('s2')); + expect(remaining, isNot(contains('s1'))); + expect(remaining, isNot(contains('pending'))); + }); +} From 33fa1d7cf8677f6d4de1e3cc072857bfe1248dfa Mon Sep 17 00:00:00 2001 From: Jun Seo Date: Sat, 6 Jun 2026 13:57:33 +0900 Subject: [PATCH 4/7] feat(presentation): add recurrence settings to todo input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TodoInputViewModel now carries a nullable RecurrenceRule, populates it from the editing todo, and routes save through the recurring usecases when a rule is set. describeRecurrence() formats the rule for display ("매주 월, 수, 금", "매 2달 15일", etc.). A new RecurrenceBottomSheet lets users choose frequency (none/daily/weekly/monthly/yearly), interval, weekdays (multi-select chips when weekly), month day (when monthly), and end condition (never / on date / after N times). Defaults seed the start weekday for weekly and the start day-of-month for monthly so common cases are one-tap. The input body surfaces a "반복" row beneath the existing options; it is shown only for daily todos since a date-range todo already defines a single span. Co-Authored-By: Claude Opus 4.7 --- .../presentation/lib/injection/di.config.dart | 24 +- .../viewmodels/todo/todo_input_viewmodel.dart | 54 ++- .../lib/views/todo/input/todo_input_body.dart | 54 +++ .../views/todo/input/todo_input_screen.dart | 6 + .../todo/widget/recurrence_bottom_sheet.dart | 361 ++++++++++++++++++ 5 files changed, 486 insertions(+), 13 deletions(-) create mode 100644 toondo/packages/presentation/lib/views/todo/widget/recurrence_bottom_sheet.dart diff --git a/toondo/packages/presentation/lib/injection/di.config.dart b/toondo/packages/presentation/lib/injection/di.config.dart index 06bd8303..f5fa52d3 100644 --- a/toondo/packages/presentation/lib/injection/di.config.dart +++ b/toondo/packages/presentation/lib/injection/di.config.dart @@ -42,10 +42,12 @@ import 'package:domain/usecases/notification/set_reminder_time.dart' as _i236; import 'package:domain/usecases/theme/get_theme_mode.dart' as _i129; import 'package:domain/usecases/theme/set_theme_mode.dart' as _i366; import 'package:domain/usecases/todo/commit_todos.dart' as _i412; +import 'package:domain/usecases/todo/create_recurring_todo.dart' as _i787; import 'package:domain/usecases/todo/create_todo.dart' as _i834; import 'package:domain/usecases/todo/delete_todo.dart' as _i552; import 'package:domain/usecases/todo/fetch_todos.dart' as _i314; import 'package:domain/usecases/todo/get_all_todos.dart' as _i362; +import 'package:domain/usecases/todo/update_recurring_todo.dart' as _i237; import 'package:domain/usecases/todo/update_todo.dart' as _i375; import 'package:domain/usecases/todo/update_todo_dates.dart' as _i182; import 'package:domain/usecases/todo/update_todo_status.dart' as _i183; @@ -174,16 +176,6 @@ extension GetItInjectableX on _i174.GetIt { logoutUseCase: gh<_i969.LogoutUseCase>(), ), ); - gh.lazySingleton<_i370.AppNotificationViewModel>( - () => _i370.AppNotificationViewModel( - gh<_i22.GetNotificationSettingsUseCase>(), - gh<_i930.SetNotificationSettingsUseCase>(), - gh<_i983.ReminderNotificationService>(), - ), - ); - gh.factory<_i88.SlimeCharacterViewModel>( - () => _i88.SlimeCharacterViewModel(gh<_i610.SlimeOnGestureUseCase>()), - ); gh.lazySingleton<_i72.TodoInputViewModel>( () => _i72.TodoInputViewModel( todo: gh<_i429.Todo>(), @@ -191,10 +183,22 @@ extension GetItInjectableX on _i174.GetIt { isOnboarding: gh(), createTodoUseCase: gh<_i834.CreateTodoUseCase>(), updateTodoUseCase: gh<_i375.UpdateTodoUseCase>(), + createRecurringTodoUseCase: gh<_i787.CreateRecurringTodoUseCase>(), + updateRecurringTodoUseCase: gh<_i237.UpdateRecurringTodoUseCase>(), getGoalsLocalUseCase: gh<_i477.GetGoalsLocalUseCase>(), initialGoalId: gh(), ), ); + gh.lazySingleton<_i370.AppNotificationViewModel>( + () => _i370.AppNotificationViewModel( + gh<_i22.GetNotificationSettingsUseCase>(), + gh<_i930.SetNotificationSettingsUseCase>(), + gh<_i983.ReminderNotificationService>(), + ), + ); + gh.factory<_i88.SlimeCharacterViewModel>( + () => _i88.SlimeCharacterViewModel(gh<_i610.SlimeOnGestureUseCase>()), + ); gh.lazySingleton<_i1040.AppThemeViewModel>( () => _i1040.AppThemeViewModel( gh<_i129.GetThemeModeUseCase>(), diff --git a/toondo/packages/presentation/lib/viewmodels/todo/todo_input_viewmodel.dart b/toondo/packages/presentation/lib/viewmodels/todo/todo_input_viewmodel.dart index f610b70c..6468c191 100644 --- a/toondo/packages/presentation/lib/viewmodels/todo/todo_input_viewmodel.dart +++ b/toondo/packages/presentation/lib/viewmodels/todo/todo_input_viewmodel.dart @@ -1,13 +1,16 @@ import 'package:domain/entities/goal.dart'; +import 'package:domain/entities/recurrence_rule.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:domain/entities/todo.dart'; +import 'package:domain/usecases/todo/create_recurring_todo.dart'; import 'package:domain/usecases/todo/create_todo.dart'; +import 'package:domain/usecases/todo/update_recurring_todo.dart'; import 'package:domain/usecases/todo/update_todo.dart'; import 'package:presentation/designsystem/components/calendars/calendar_bottom_sheet.dart'; import 'package:injectable/injectable.dart'; import 'package:domain/usecases/goal/get_goals_local.dart'; -import 'package:presentation/models/eisenhower_model.dart'; // 여기 EisenhowerType enum이 정의됨 +import 'package:presentation/models/eisenhower_model.dart'; import 'package:get_it/get_it.dart'; import 'package:presentation/viewmodels/home/home_viewmodel.dart'; @@ -40,8 +43,11 @@ class TodoInputViewModel extends ChangeNotifier { Todo? todo; bool isDDayTodo; + RecurrenceRule? recurrence; final CreateTodoUseCase _createTodoUseCase; final UpdateTodoUseCase _updateTodoUseCase; + final CreateRecurringTodoUseCase _createRecurringTodoUseCase; + final UpdateRecurringTodoUseCase _updateRecurringTodoUseCase; final GetGoalsLocalUseCase _getGoalsLocalUseCase; final String? initialGoalId; // 새로 생성된 목표 전달용 @@ -51,10 +57,14 @@ class TodoInputViewModel extends ChangeNotifier { required this.isOnboarding, required CreateTodoUseCase createTodoUseCase, required UpdateTodoUseCase updateTodoUseCase, + required CreateRecurringTodoUseCase createRecurringTodoUseCase, + required UpdateRecurringTodoUseCase updateRecurringTodoUseCase, required GetGoalsLocalUseCase getGoalsLocalUseCase, this.initialGoalId, }) : _createTodoUseCase = createTodoUseCase, _updateTodoUseCase = updateTodoUseCase, + _createRecurringTodoUseCase = createRecurringTodoUseCase, + _updateRecurringTodoUseCase = updateRecurringTodoUseCase, _getGoalsLocalUseCase = getGoalsLocalUseCase { if (todo != null) { titleController.text = todo!.title; @@ -63,6 +73,7 @@ class TodoInputViewModel extends ChangeNotifier { startDate = todo!.startDate; endDate = todo!.endDate; showOnHome = todo!.showOnHome; + recurrence = todo!.recurrence; // eisenhower 필드에서 EisenhowerType으로 매핑 _selectedEisenhowerType = _mapEisenhowerToType(todo!.eisenhower); isDailyTodo = todo!.startDate == todo!.endDate; @@ -125,6 +136,36 @@ class TodoInputViewModel extends ChangeNotifier { notifyListeners(); } + void setRecurrence(RecurrenceRule? rule) { + recurrence = rule; + notifyListeners(); + } + + String describeRecurrence() { + final r = recurrence; + if (r == null) return '안 함'; + final intervalLabel = r.interval > 1 ? '${r.interval}' : ''; + switch (r.frequency) { + case RecurrenceFrequency.daily: + return r.interval > 1 ? '매 ${r.interval}일' : '매일'; + case RecurrenceFrequency.weekly: + const labels = ['월', '화', '수', '목', '금', '토', '일']; + final days = (r.byWeekdays.isEmpty + ? [] + : [...r.byWeekdays]..sort()) + .map((d) => labels[d - 1]) + .join(', '); + final prefix = r.interval > 1 ? '매 $intervalLabel주' : '매주'; + return days.isEmpty ? prefix : '$prefix $days'; + case RecurrenceFrequency.monthly: + final day = r.byMonthDay; + final prefix = r.interval > 1 ? '매 $intervalLabel달' : '매달'; + return day == null ? prefix : '$prefix ${day}일'; + case RecurrenceFrequency.yearly: + return r.interval > 1 ? '매 $intervalLabel년' : '매년'; + } + } + void toggleShowOnHome(bool value) { showOnHome = value; // TODO: 메인화면 노출 기능 개선사항 @@ -235,6 +276,7 @@ class TodoInputViewModel extends ChangeNotifier { goalId: selectedGoalId, eisenhower: _mapTypeToEisenhower(_selectedEisenhowerType), showOnHome: showOnHome, + recurrence: recurrence, ); print('🔍 생성된 투두 정보: ${newTodo.title}, showOnHome: ${newTodo.showOnHome}'); @@ -284,7 +326,9 @@ class TodoInputViewModel extends ChangeNotifier { try { final newTodo = _buildTodo(); - final created = await _createTodoUseCase(newTodo); + final created = newTodo.recurrence != null + ? await _createRecurringTodoUseCase(newTodo) + : await _createTodoUseCase(newTodo); if (created) { // TODO: 투두 생성 버그 수정 - 홈 뷰모델 동기화 누락 // TODO: 투두 생성 후 홈 화면의 todayTop3Todos가 업데이트되지 않는 문제 @@ -326,7 +370,11 @@ class TodoInputViewModel extends ChangeNotifier { try { final updatedTodo = _buildTodo(); - await _updateTodoUseCase(updatedTodo); + if (updatedTodo.recurrence != null) { + await _updateRecurringTodoUseCase(updatedTodo); + } else { + await _updateTodoUseCase(updatedTodo); + } onSuccess(); } catch (e) { onError('업데이트 중 오류 발생: $e'); diff --git a/toondo/packages/presentation/lib/views/todo/input/todo_input_body.dart b/toondo/packages/presentation/lib/views/todo/input/todo_input_body.dart index ff6aac9f..ac1043f0 100644 --- a/toondo/packages/presentation/lib/views/todo/input/todo_input_body.dart +++ b/toondo/packages/presentation/lib/views/todo/input/todo_input_body.dart @@ -1,3 +1,4 @@ +import 'package:domain/entities/recurrence_rule.dart'; import 'package:flutter/material.dart'; import 'package:presentation/designsystem/colors/app_colors.dart'; import 'package:presentation/designsystem/components/app_ink_well.dart'; @@ -11,6 +12,7 @@ import 'package:presentation/designsystem/dimensions/app_dimensions.dart'; import 'package:presentation/viewmodels/todo/todo_input_viewmodel.dart'; import 'package:presentation/designsystem/components/inputs/app_tip_text.dart'; import 'package:presentation/views/todo/widget/goal_selection_section.dart'; +import 'package:presentation/views/todo/widget/recurrence_bottom_sheet.dart'; import 'package:provider/provider.dart'; class TodoInputBody extends StatelessWidget { @@ -53,6 +55,10 @@ class TodoInputBody extends StatelessWidget { _buildDateSection(viewModel, context), SizedBox(height: AppSpacing.v24), _buildOptions(viewModel), + if (viewModel.isDailyTodo) ...[ + SizedBox(height: AppSpacing.v16), + _buildRecurrenceRow(viewModel, context), + ], SizedBox(height: AppSpacing.v24), Text( '아이젠하워', @@ -107,6 +113,54 @@ class TodoInputBody extends StatelessWidget { ); } + Widget _buildRecurrenceRow( + TodoInputViewModel viewModel, BuildContext context) { + return AppInkWell( + onTap: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => RecurrenceBottomSheet( + initial: viewModel.recurrence, + seriesStartDate: DateTime.now(), + ), + ); + if (!context.mounted) return; + if (result != null || viewModel.recurrence != null) { + viewModel.setRecurrence(result); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + const Icon(Icons.repeat, size: 16, color: AppColors.status100_75), + SizedBox(width: AppSpacing.h8), + Text( + '반복', + style: AppTypography.caption1Regular.copyWith( + color: AppColors.status100, + ), + ), + const Spacer(), + Text( + viewModel.describeRecurrence(), + style: AppTypography.caption1Medium.copyWith( + color: viewModel.recurrence == null + ? AppColors.status100_50 + : AppColors.green500, + ), + ), + SizedBox(width: AppSpacing.h8), + const Icon(Icons.chevron_right, + size: 16, color: AppColors.status100_50), + ], + ), + ), + ); + } + Widget _buildCustomCheckbox({ required String label, required bool value, diff --git a/toondo/packages/presentation/lib/views/todo/input/todo_input_screen.dart b/toondo/packages/presentation/lib/views/todo/input/todo_input_screen.dart index e428279c..5eb2a7bb 100644 --- a/toondo/packages/presentation/lib/views/todo/input/todo_input_screen.dart +++ b/toondo/packages/presentation/lib/views/todo/input/todo_input_screen.dart @@ -6,7 +6,9 @@ import 'package:presentation/views/home/home_screen.dart'; import 'package:presentation/views/todo/input/todo_input_body.dart'; import 'package:provider/provider.dart'; import 'package:get_it/get_it.dart'; +import 'package:domain/usecases/todo/create_recurring_todo.dart'; import 'package:domain/usecases/todo/create_todo.dart'; +import 'package:domain/usecases/todo/update_recurring_todo.dart'; import 'package:domain/usecases/todo/update_todo.dart'; import 'package:domain/usecases/goal/get_goals_local.dart'; import 'package:domain/entities/todo.dart'; @@ -34,6 +36,10 @@ class TodoInputScreen extends StatelessWidget { isOnboarding: isOnboarding, createTodoUseCase: GetIt.instance(), updateTodoUseCase: GetIt.instance(), + createRecurringTodoUseCase: + GetIt.instance(), + updateRecurringTodoUseCase: + GetIt.instance(), getGoalsLocalUseCase: GetIt.instance(), ), child: BaseScaffold( diff --git a/toondo/packages/presentation/lib/views/todo/widget/recurrence_bottom_sheet.dart b/toondo/packages/presentation/lib/views/todo/widget/recurrence_bottom_sheet.dart new file mode 100644 index 00000000..0480d4cb --- /dev/null +++ b/toondo/packages/presentation/lib/views/todo/widget/recurrence_bottom_sheet.dart @@ -0,0 +1,361 @@ +import 'package:domain/entities/recurrence_rule.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:intl/intl.dart'; +import 'package:presentation/designsystem/colors/app_colors.dart'; +import 'package:presentation/designsystem/components/bottom_sheets/app_bottom_sheet.dart'; +import 'package:presentation/designsystem/components/buttons/double_action_buttons.dart'; +import 'package:presentation/designsystem/dimensions/app_dimensions.dart'; +import 'package:presentation/designsystem/spacing/app_spacing.dart'; +import 'package:presentation/designsystem/typography/app_typography.dart'; + +enum _Mode { none, daily, weekly, monthly, yearly } + +enum _EndMode { never, onDate, afterCount } + +class RecurrenceBottomSheet extends StatefulWidget { + final RecurrenceRule? initial; + final DateTime? seriesStartDate; + + const RecurrenceBottomSheet({ + super.key, + this.initial, + this.seriesStartDate, + }); + + @override + State createState() => _RecurrenceBottomSheetState(); +} + +class _RecurrenceBottomSheetState extends State { + late _Mode _mode; + int _interval = 1; + final Set _weekdays = {}; + int _monthDay = 1; + _EndMode _endMode = _EndMode.never; + DateTime _endDate = DateTime.now().add(const Duration(days: 30)); + int _count = 10; + + @override + void initState() { + super.initState(); + final r = widget.initial; + if (r == null) { + _mode = _Mode.none; + final defaultDay = widget.seriesStartDate?.weekday; + if (defaultDay != null) _weekdays.add(defaultDay); + _monthDay = widget.seriesStartDate?.day ?? 1; + } else { + _mode = switch (r.frequency) { + RecurrenceFrequency.daily => _Mode.daily, + RecurrenceFrequency.weekly => _Mode.weekly, + RecurrenceFrequency.monthly => _Mode.monthly, + RecurrenceFrequency.yearly => _Mode.yearly, + }; + _interval = r.interval; + _weekdays.addAll(r.byWeekdays); + _monthDay = r.byMonthDay ?? widget.seriesStartDate?.day ?? 1; + switch (r.end) { + case EndNever(): + _endMode = _EndMode.never; + case EndOnDate(date: final d): + _endMode = _EndMode.onDate; + _endDate = d; + case EndAfterCount(count: final c): + _endMode = _EndMode.afterCount; + _count = c; + } + } + } + + RecurrenceRule? _build() { + if (_mode == _Mode.none) return null; + final frequency = switch (_mode) { + _Mode.daily => RecurrenceFrequency.daily, + _Mode.weekly => RecurrenceFrequency.weekly, + _Mode.monthly => RecurrenceFrequency.monthly, + _Mode.yearly => RecurrenceFrequency.yearly, + _Mode.none => RecurrenceFrequency.daily, + }; + final end = switch (_endMode) { + _EndMode.never => const EndNever(), + _EndMode.onDate => EndOnDate(_endDate), + _EndMode.afterCount => EndAfterCount(_count), + }; + return RecurrenceRule( + frequency: frequency, + interval: _interval.clamp(1, 365), + byWeekdays: _mode == _Mode.weekly + ? (_weekdays.toList()..sort()) + : const [], + byMonthDay: _mode == _Mode.monthly ? _monthDay : null, + end: end, + ); + } + + @override + Widget build(BuildContext context) { + return AppBottomSheet( + initialSize: 0.7, + maxSize: 0.95, + body: Padding( + padding: EdgeInsets.symmetric(horizontal: AppSpacing.h20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('반복 설정', style: AppTypography.h2Bold), + SizedBox(height: AppSpacing.v20), + _frequencySection(), + if (_mode != _Mode.none) ...[ + SizedBox(height: AppSpacing.v24), + _intervalSection(), + ], + if (_mode == _Mode.weekly) ...[ + SizedBox(height: AppSpacing.v24), + _weekdaySection(), + ], + if (_mode == _Mode.monthly) ...[ + SizedBox(height: AppSpacing.v24), + _monthDaySection(), + ], + if (_mode != _Mode.none) ...[ + SizedBox(height: AppSpacing.v24), + _endSection(), + ], + SizedBox(height: AppSpacing.v32), + DoubleActionButtons( + backText: '취소', + nextText: '적용', + onBack: () => Navigator.of(context).pop(null), + onNext: _isValid() + ? () => + Navigator.of(context).pop(_build()) + : null, + isNextEnabled: _isValid(), + ), + SizedBox(height: AppSpacing.v16), + ], + ), + ), + ); + } + + bool _isValid() { + if (_mode == _Mode.weekly && _weekdays.isEmpty) return false; + if (_endMode == _EndMode.afterCount && _count < 1) return false; + return true; + } + + Widget _frequencySection() { + return _section( + title: '주기', + child: Wrap( + spacing: AppSpacing.h8, + runSpacing: AppSpacing.v8, + children: [ + _choiceChip('안 함', _mode == _Mode.none, () { + setState(() => _mode = _Mode.none); + }), + _choiceChip('매일', _mode == _Mode.daily, () { + setState(() => _mode = _Mode.daily); + }), + _choiceChip('매주', _mode == _Mode.weekly, () { + setState(() => _mode = _Mode.weekly); + }), + _choiceChip('매달', _mode == _Mode.monthly, () { + setState(() => _mode = _Mode.monthly); + }), + _choiceChip('매년', _mode == _Mode.yearly, () { + setState(() => _mode = _Mode.yearly); + }), + ], + ), + ); + } + + Widget _intervalSection() { + final unit = switch (_mode) { + _Mode.daily => '일', + _Mode.weekly => '주', + _Mode.monthly => '달', + _Mode.yearly => '년', + _Mode.none => '', + }; + return _section( + title: '간격', + child: Row( + children: [ + Text('매', style: AppTypography.body2Regular), + SizedBox(width: AppSpacing.h8), + _stepper(_interval, (v) => setState(() => _interval = v)), + SizedBox(width: AppSpacing.h8), + Text(unit, style: AppTypography.body2Regular), + ], + ), + ); + } + + Widget _weekdaySection() { + const labels = ['월', '화', '수', '목', '금', '토', '일']; + return _section( + title: '요일', + child: Wrap( + spacing: AppSpacing.h8, + children: List.generate(7, (i) { + final weekday = i + 1; + final selected = _weekdays.contains(weekday); + return _choiceChip(labels[i], selected, () { + setState(() { + if (selected) { + _weekdays.remove(weekday); + } else { + _weekdays.add(weekday); + } + }); + }); + }), + ), + ); + } + + Widget _monthDaySection() { + return _section( + title: '일자', + child: Row( + children: [ + _stepper(_monthDay, (v) { + if (v < 1 || v > 31) return; + setState(() => _monthDay = v); + }), + SizedBox(width: AppSpacing.h8), + Text('일', style: AppTypography.body2Regular), + ], + ), + ); + } + + Widget _endSection() { + return _section( + title: '종료', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _radioRow('계속 반복', _endMode == _EndMode.never, () { + setState(() => _endMode = _EndMode.never); + }), + SizedBox(height: AppSpacing.v8), + _radioRow( + '특정 날짜에 종료 (${DateFormat('yyyy. M. d.').format(_endDate)})', + _endMode == _EndMode.onDate, + () async { + setState(() => _endMode = _EndMode.onDate); + final picked = await showDatePicker( + context: context, + initialDate: _endDate, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365 * 10)), + ); + if (picked != null) setState(() => _endDate = picked); + }, + ), + SizedBox(height: AppSpacing.v8), + Row( + children: [ + _radioRow('횟수 제한', _endMode == _EndMode.afterCount, () { + setState(() => _endMode = _EndMode.afterCount); + }), + SizedBox(width: AppSpacing.h16), + if (_endMode == _EndMode.afterCount) + _stepper(_count, (v) { + if (v < 1) return; + setState(() => _count = v); + }), + if (_endMode == _EndMode.afterCount) ...[ + SizedBox(width: AppSpacing.h8), + Text('회', style: AppTypography.body2Regular), + ], + ], + ), + ], + ), + ); + } + + Widget _section({required String title, required Widget child}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: AppTypography.caption1Regular + .copyWith(color: AppColors.status100_75)), + SizedBox(height: AppSpacing.v8), + child, + ], + ); + } + + Widget _choiceChip(String label, bool selected, VoidCallback onTap) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppDimensions.radiusPill), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 8.h), + decoration: BoxDecoration( + color: selected ? AppColors.green500 : AppColors.green100, + borderRadius: BorderRadius.circular(AppDimensions.radiusPill), + ), + child: Text( + label, + style: AppTypography.caption1Medium.copyWith( + color: selected ? Colors.white : AppColors.status100, + ), + ), + ), + ); + } + + Widget _stepper(int value, ValueChanged onChange) { + return Container( + decoration: BoxDecoration( + border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + visualDensity: VisualDensity.compact, + onPressed: () => onChange(value - 1), + icon: const Icon(Icons.remove, size: 16), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 8.w), + child: Text('$value', style: AppTypography.body2Medium), + ), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: () => onChange(value + 1), + icon: const Icon(Icons.add, size: 16), + ), + ], + ), + ); + } + + Widget _radioRow(String label, bool selected, VoidCallback onTap) { + return InkWell( + onTap: onTap, + child: Row( + children: [ + Icon( + selected ? Icons.radio_button_checked : Icons.radio_button_off, + color: selected ? AppColors.green500 : AppColors.status100_50, + size: 18, + ), + SizedBox(width: AppSpacing.h8), + Flexible(child: Text(label, style: AppTypography.body2Regular)), + ], + ), + ); + } +} From 8b129382223ef40661f47cdc80282cda978c9424 Mon Sep 17 00:00:00 2001 From: Jun Seo Date: Sat, 6 Jun 2026 14:04:14 +0900 Subject: [PATCH 5/7] feat(presentation): expand recurring todos lazily on home + manage HomeViewModel.loadTodos now drops series templates from the raw list, asks ExpandRecurringTodosForDate for today's occurrences, and merges them by id so an already-materialized instance wins over the virtual copy. The home row shows a small repeat icon for any todo that carries a recurrence rule (series or occurrence). TodoManageViewModel applies the same drop+expand+merge for the currently selected date; updateSelectedDate is now async so the expansion refresh completes before filter/categorize. The status update path in TodoRepositoryImpl preserves recurrence/seriesId/ occurrenceDate so toggling a virtual occurrence materializes it as a proper occurrence record rather than wiping the recurrence metadata. Co-Authored-By: Claude Opus 4.7 --- .../repositories/todo_repository_impl.dart | 5 ++- .../presentation/lib/injection/di.config.dart | 38 ++++++++++--------- .../lib/viewmodels/home/home_viewmodel.dart | 22 ++++++++++- .../todo/todo_manage_viewmodel.dart | 35 ++++++++++++----- .../lib/views/home/widget/home_list_item.dart | 12 ++++-- .../views/todo/manage/todo_manage_screen.dart | 3 ++ .../todo/todo_manage_viewmodel_test.dart | 12 +++++- .../todo_manage_viewmodel_test.mocks.dart | 31 +++++++++++++++ 8 files changed, 123 insertions(+), 35 deletions(-) diff --git a/toondo/packages/data/lib/repositories/todo_repository_impl.dart b/toondo/packages/data/lib/repositories/todo_repository_impl.dart index 064a07c7..e7913e7f 100644 --- a/toondo/packages/data/lib/repositories/todo_repository_impl.dart +++ b/toondo/packages/data/lib/repositories/todo_repository_impl.dart @@ -204,7 +204,7 @@ class TodoRepositoryImpl implements TodoRepository { final normalizedStatus = status >= 1.0 ? 1.0 : 0.0; - // 상태 업데이트 후 Todo 객체 생성 + // 상태 업데이트 후 Todo 객체 생성 (반복 메타데이터 보존) final updated = Todo( id: todo.id, title: todo.title, @@ -215,6 +215,9 @@ class TodoRepositoryImpl implements TodoRepository { comment: todo.comment, eisenhower: todo.eisenhower, showOnHome: todo.showOnHome, + recurrence: todo.recurrence, + seriesId: todo.seriesId, + occurrenceDate: todo.occurrenceDate, ); await localDatasource.updateTodo(updated); } diff --git a/toondo/packages/presentation/lib/injection/di.config.dart b/toondo/packages/presentation/lib/injection/di.config.dart index f5fa52d3..66d5a715 100644 --- a/toondo/packages/presentation/lib/injection/di.config.dart +++ b/toondo/packages/presentation/lib/injection/di.config.dart @@ -45,6 +45,8 @@ import 'package:domain/usecases/todo/commit_todos.dart' as _i412; import 'package:domain/usecases/todo/create_recurring_todo.dart' as _i787; import 'package:domain/usecases/todo/create_todo.dart' as _i834; import 'package:domain/usecases/todo/delete_todo.dart' as _i552; +import 'package:domain/usecases/todo/expand_recurring_todos_for_date.dart' + as _i466; import 'package:domain/usecases/todo/fetch_todos.dart' as _i314; import 'package:domain/usecases/todo/get_all_todos.dart' as _i362; import 'package:domain/usecases/todo/update_recurring_todo.dart' as _i237; @@ -120,6 +122,17 @@ extension GetItInjectableX on _i174.GetIt { logoutUseCase: gh<_i969.LogoutUseCase>(), ), ); + gh.lazySingleton<_i506.TodoManageViewModel>( + () => _i506.TodoManageViewModel( + deleteTodoUseCase: gh<_i552.DeleteTodoUseCase>(), + getTodosUseCase: gh<_i362.GetAllTodosUseCase>(), + updateTodoStatusUseCase: gh<_i183.UpdateTodoStatusUseCase>(), + updateTodoDatesUseCase: gh<_i182.UpdateTodoDatesUseCase>(), + getGoalsLocalUseCase: gh<_i477.GetGoalsLocalUseCase>(), + expandRecurring: gh<_i466.ExpandRecurringTodosForDateUseCase>(), + initialDate: gh(), + ), + ); gh.factory<_i940.GoalManagementViewModel>( () => _i940.GoalManagementViewModel( getGoalsLocalUseCase: gh<_i477.GetGoalsLocalUseCase>(), @@ -138,16 +151,6 @@ extension GetItInjectableX on _i174.GetIt { gh.lazySingleton<_i764.LoginViewModel>( () => _i764.LoginViewModel(loginUseCase: gh<_i1068.LoginUseCase>()), ); - gh.lazySingleton<_i506.TodoManageViewModel>( - () => _i506.TodoManageViewModel( - deleteTodoUseCase: gh<_i552.DeleteTodoUseCase>(), - getTodosUseCase: gh<_i362.GetAllTodosUseCase>(), - updateTodoStatusUseCase: gh<_i183.UpdateTodoStatusUseCase>(), - updateTodoDatesUseCase: gh<_i182.UpdateTodoDatesUseCase>(), - getGoalsLocalUseCase: gh<_i477.GetGoalsLocalUseCase>(), - initialDate: gh(), - ), - ); gh.factory<_i705.SignupViewModel>( () => _i705.SignupViewModel( registerUserUseCase: gh<_i899.RegisterUseCase>(), @@ -199,6 +202,14 @@ extension GetItInjectableX on _i174.GetIt { gh.factory<_i88.SlimeCharacterViewModel>( () => _i88.SlimeCharacterViewModel(gh<_i610.SlimeOnGestureUseCase>()), ); + gh.lazySingleton<_i370.HomeViewModel>( + () => _i370.HomeViewModel( + gh<_i243.GetInProgressGoalsUseCase>(), + gh<_i849.GetUserNicknameUseCase>(), + gh<_i362.GetAllTodosUseCase>(), + gh<_i466.ExpandRecurringTodosForDateUseCase>(), + ), + ); gh.lazySingleton<_i1040.AppThemeViewModel>( () => _i1040.AppThemeViewModel( gh<_i129.GetThemeModeUseCase>(), @@ -213,13 +224,6 @@ extension GetItInjectableX on _i174.GetIt { myPageViewModel: gh<_i272.MyPageViewModel>(), ), ); - gh.lazySingleton<_i370.HomeViewModel>( - () => _i370.HomeViewModel( - gh<_i243.GetInProgressGoalsUseCase>(), - gh<_i849.GetUserNicknameUseCase>(), - gh<_i362.GetAllTodosUseCase>(), - ), - ); gh.factory<_i657.OnboardingViewModel>( () => _i657.OnboardingViewModel( updateNickNameUseCase: gh<_i910.UpdateNickNameUseCase>(), diff --git a/toondo/packages/presentation/lib/viewmodels/home/home_viewmodel.dart b/toondo/packages/presentation/lib/viewmodels/home/home_viewmodel.dart index 24ef74ae..7aa2b2ab 100644 --- a/toondo/packages/presentation/lib/viewmodels/home/home_viewmodel.dart +++ b/toondo/packages/presentation/lib/viewmodels/home/home_viewmodel.dart @@ -1,4 +1,5 @@ import 'package:domain/entities/todo.dart'; +import 'package:domain/usecases/todo/expand_recurring_todos_for_date.dart'; import 'package:domain/usecases/todo/get_all_todos.dart'; import 'package:flutter/material.dart'; import 'package:domain/entities/goal.dart'; @@ -13,8 +14,14 @@ class HomeViewModel extends ChangeNotifier { final GetInProgressGoalsUseCase _getGoals; final GetUserNicknameUseCase _getNick; final GetAllTodosUseCase _getTodosUseCase; + final ExpandRecurringTodosForDateUseCase _expandRecurring; - HomeViewModel(this._getGoals, this._getNick, this._getTodosUseCase) { + HomeViewModel( + this._getGoals, + this._getNick, + this._getTodosUseCase, + this._expandRecurring, + ) { _init(); } @@ -40,7 +47,18 @@ class HomeViewModel extends ChangeNotifier { Future loadTodos() async { try { - _todos = await _getTodosUseCase(); + final raw = await _getTodosUseCase(); + // 시리즈 템플릿은 홈 단순 목록에서 제외 — expand 결과로 대체 + final nonSeries = + raw.where((t) => !t.isRecurringSeries).toList(); + final todayExpansion = await _expandRecurring(DateTime.now()); + // expand가 반환한 occurrence는 이미 비-시리즈 목록에 머터리얼라이즈된 + // 인스턴스가 있으면 그것을 포함하므로 id 기준 중복 제거 + final byId = {for (final t in nonSeries) t.id: t}; + for (final occ in todayExpansion) { + byId.putIfAbsent(occ.id, () => occ); + } + _todos = byId.values.toList(); print('📊 홈에서 로드된 Todo 개수: ${_todos.length}'); final showOnHomeTodos = _todos.where((todo) => todo.showOnHome).toList(); print('📊 showOnHome=true인 Todo 개수: ${showOnHomeTodos.length}'); diff --git a/toondo/packages/presentation/lib/viewmodels/todo/todo_manage_viewmodel.dart b/toondo/packages/presentation/lib/viewmodels/todo/todo_manage_viewmodel.dart index 876965d8..6e323abb 100644 --- a/toondo/packages/presentation/lib/viewmodels/todo/todo_manage_viewmodel.dart +++ b/toondo/packages/presentation/lib/viewmodels/todo/todo_manage_viewmodel.dart @@ -6,6 +6,7 @@ import 'package:domain/usecases/todo/get_all_todos.dart'; import 'package:domain/usecases/todo/update_todo_dates.dart'; import 'package:domain/usecases/todo/update_todo_status.dart'; import 'package:domain/usecases/todo/delete_todo.dart'; +import 'package:domain/usecases/todo/expand_recurring_todos_for_date.dart'; import 'package:flutter/material.dart'; import 'package:injectable/injectable.dart'; import 'package:get_it/get_it.dart'; @@ -18,11 +19,13 @@ class TodoManageViewModel extends ChangeNotifier { final UpdateTodoStatusUseCase _updateTodoStatusUseCase; final UpdateTodoDatesUseCase _updateTodoDatesUseCase; final GetGoalsLocalUseCase _getGoalsLocalUseCase; + final ExpandRecurringTodosForDateUseCase _expandRecurring; DateTime selectedDate; TodoFilterOption selectedFilter = TodoFilterOption.all; String? selectedGoalId; List allTodos = []; + List _expandedForSelectedDate = []; List dDayTodos = []; List dailyTodos = []; List goals = []; @@ -33,19 +36,24 @@ class TodoManageViewModel extends ChangeNotifier { required UpdateTodoStatusUseCase updateTodoStatusUseCase, required UpdateTodoDatesUseCase updateTodoDatesUseCase, required GetGoalsLocalUseCase getGoalsLocalUseCase, + required ExpandRecurringTodosForDateUseCase expandRecurring, DateTime? initialDate, }) : _deleteTodoUseCase = deleteTodoUseCase, _getTodosUseCase = getTodosUseCase, _updateTodoStatusUseCase = updateTodoStatusUseCase, _updateTodoDatesUseCase = updateTodoDatesUseCase, _getGoalsLocalUseCase = getGoalsLocalUseCase, + _expandRecurring = expandRecurring, selectedDate = initialDate ?? DateTime.now(); Future loadTodos() async { try { // NOTE 원격 서버에서 가져오는 대신 로컬 데이터베이스에서만 Todo 불러오기 (수정필) - allTodos = await _getTodosUseCase(); + final raw = await _getTodosUseCase(); + // 시리즈 템플릿은 날짜별 화면에서 제외, 실제 발생은 _expandedForSelectedDate가 담당 + allTodos = raw.where((t) => !t.isRecurringSeries).toList(); goals = await _getGoalsLocalUseCase(); + await _refreshExpansion(); _filterAndCategorizeTodos(); notifyListeners(); } catch (e) { @@ -53,12 +61,17 @@ class TodoManageViewModel extends ChangeNotifier { } } + Future _refreshExpansion() async { + _expandedForSelectedDate = await _expandRecurring(selectedDate); + } + Future> getTodos() async { return await _getTodosUseCase(); } - void updateSelectedDate(DateTime date) { + Future updateSelectedDate(DateTime date) async { selectedDate = date; + await _refreshExpansion(); _filterAndCategorizeTodos(); notifyListeners(); } @@ -82,13 +95,17 @@ class TodoManageViewModel extends ChangeNotifier { selectedDate.day, ); - List todosForSelectedDate = - allTodos.where((todo) { - return (todo.startDate.isBefore(selectedDateOnly) || - _isSameDay(todo.startDate, selectedDateOnly)) && - (todo.endDate.isAfter(selectedDateOnly) || - _isSameDay(todo.endDate, selectedDateOnly)); - }).toList(); + final baseFiltered = allTodos.where((todo) { + return (todo.startDate.isBefore(selectedDateOnly) || + _isSameDay(todo.startDate, selectedDateOnly)) && + (todo.endDate.isAfter(selectedDateOnly) || + _isSameDay(todo.endDate, selectedDateOnly)); + }); + final byId = {for (final t in baseFiltered) t.id: t}; + for (final occ in _expandedForSelectedDate) { + byId.putIfAbsent(occ.id, () => occ); + } + List todosForSelectedDate = byId.values.toList(); if (selectedFilter == TodoFilterOption.goal && selectedGoalId != null) { todosForSelectedDate = diff --git a/toondo/packages/presentation/lib/views/home/widget/home_list_item.dart b/toondo/packages/presentation/lib/views/home/widget/home_list_item.dart index ca1d4f46..1683eb54 100644 --- a/toondo/packages/presentation/lib/views/home/widget/home_list_item.dart +++ b/toondo/packages/presentation/lib/views/home/widget/home_list_item.dart @@ -99,10 +99,14 @@ class HomeListItem extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), - // TODO: 투두/목표 구분 표시 개선 완료 - // TODO: 우선순위 배지는 투두인 경우에만 표시 (목표는 우선순위 개념이 없음) - // TODO: 투두: eisenhower 매트릭스 기반 우선순위 배지 표시 - // TODO: 목표: 우선순위 배지 표시하지 않음 + if (todo?.isRecurring == true) ...[ + SizedBox(width: AppSpacing.h4), + const Icon( + Icons.repeat, + size: 14, + color: AppColors.status100_50, + ), + ], if (todo != null) ...[ SizedBox(width: AppSpacing.h8), _buildPriorityBadge(priorityColor, priorityLabel), diff --git a/toondo/packages/presentation/lib/views/todo/manage/todo_manage_screen.dart b/toondo/packages/presentation/lib/views/todo/manage/todo_manage_screen.dart index 5c8e0c68..f039ee30 100644 --- a/toondo/packages/presentation/lib/views/todo/manage/todo_manage_screen.dart +++ b/toondo/packages/presentation/lib/views/todo/manage/todo_manage_screen.dart @@ -6,6 +6,7 @@ import 'package:presentation/views/base_scaffold.dart'; import 'package:presentation/views/todo/manage/todo_manage_body.dart'; import 'package:provider/provider.dart'; import 'package:domain/usecases/todo/delete_todo.dart'; +import 'package:domain/usecases/todo/expand_recurring_todos_for_date.dart'; import 'package:domain/usecases/todo/get_all_todos.dart'; import 'package:domain/usecases/todo/update_todo_status.dart'; import 'package:domain/usecases/todo/update_todo_dates.dart'; @@ -27,6 +28,8 @@ class TodoManageScreen extends StatelessWidget { updateTodoStatusUseCase: GetIt.instance(), updateTodoDatesUseCase: GetIt.instance(), getGoalsLocalUseCase: GetIt.instance(), + expandRecurring: + GetIt.instance(), initialDate: selectedDate, )..loadTodos(), child: BaseScaffold( diff --git a/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.dart b/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.dart index 6e724702..5f9df9ad 100644 --- a/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.dart +++ b/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.dart @@ -12,6 +12,7 @@ import 'package:domain/usecases/todo/get_all_todos.dart'; import 'package:domain/usecases/todo/update_todo_dates.dart'; import 'package:domain/usecases/todo/update_todo_status.dart'; import 'package:domain/usecases/todo/delete_todo.dart'; +import 'package:domain/usecases/todo/expand_recurring_todos_for_date.dart'; import 'package:presentation/viewmodels/todo/todo_manage_viewmodel.dart'; import '../../helpers/test_data.dart'; @@ -22,6 +23,7 @@ import '../../helpers/test_data.dart'; UpdateTodoStatusUseCase, UpdateTodoDatesUseCase, GetGoalsLocalUseCase, + ExpandRecurringTodosForDateUseCase, ]) void main() { late TodoManageViewModel viewModel; @@ -30,6 +32,7 @@ void main() { late MockUpdateTodoStatusUseCase mockUpdateTodoStatusUseCase; late MockUpdateTodoDatesUseCase mockUpdateTodoDatesUseCase; late MockGetGoalsLocalUseCase mockGetGoalsLocalUseCase; + late MockExpandRecurringTodosForDateUseCase mockExpandRecurringTodosForDateUseCase; final testDate = DateTime(2025, 5, 5); late List testTodos; @@ -41,12 +44,16 @@ void main() { mockUpdateTodoStatusUseCase = MockUpdateTodoStatusUseCase(); mockUpdateTodoDatesUseCase = MockUpdateTodoDatesUseCase(); mockGetGoalsLocalUseCase = MockGetGoalsLocalUseCase(); + mockExpandRecurringTodosForDateUseCase = + MockExpandRecurringTodosForDateUseCase(); testTodos = TestData.createTestTodos(); testGoals = TestData.createTestGoals(); when(mockGetAllTodosUseCase.call()).thenAnswer((_) async => testTodos); when(mockGetGoalsLocalUseCase.call()).thenAnswer((_) async => testGoals); + when(mockExpandRecurringTodosForDateUseCase.call(any)) + .thenAnswer((_) async => []); viewModel = TodoManageViewModel( deleteTodoUseCase: mockDeleteTodoUseCase, @@ -54,6 +61,7 @@ void main() { updateTodoStatusUseCase: mockUpdateTodoStatusUseCase, updateTodoDatesUseCase: mockUpdateTodoDatesUseCase, getGoalsLocalUseCase: mockGetGoalsLocalUseCase, + expandRecurring: mockExpandRecurringTodosForDateUseCase, initialDate: testDate, ); }); @@ -100,12 +108,12 @@ void main() { await viewModel.loadTodos(); }); - test('날짜 선택 변경 시 해당 날짜의 투두만 필터링되어야 한다', () { + test('날짜 선택 변경 시 해당 날짜의 투두만 필터링되어야 한다', () async { // Given final newDate = DateTime(2025, 5, 10); // When - viewModel.updateSelectedDate(newDate); + await viewModel.updateSelectedDate(newDate); // Then expect(viewModel.selectedDate, newDate); diff --git a/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.mocks.dart b/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.mocks.dart index 0235ce91..4eae7a3f 100644 --- a/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.mocks.dart +++ b/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.mocks.dart @@ -11,6 +11,8 @@ import 'package:domain/repositories/goal_repository.dart' as _i3; import 'package:domain/repositories/todo_repository.dart' as _i2; import 'package:domain/usecases/goal/get_goals_local.dart' as _i10; import 'package:domain/usecases/todo/delete_todo.dart' as _i4; +import 'package:domain/usecases/todo/expand_recurring_todos_for_date.dart' + as _i12; import 'package:domain/usecases/todo/get_all_todos.dart' as _i7; import 'package:domain/usecases/todo/update_todo_dates.dart' as _i9; import 'package:domain/usecases/todo/update_todo_status.dart' as _i8; @@ -192,3 +194,32 @@ class MockGetGoalsLocalUseCase extends _i1.Mock ) as _i5.Future>); } + +/// A class which mocks [ExpandRecurringTodosForDateUseCase]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockExpandRecurringTodosForDateUseCase extends _i1.Mock + implements _i12.ExpandRecurringTodosForDateUseCase { + MockExpandRecurringTodosForDateUseCase() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.TodoRepository get repository => + (super.noSuchMethod( + Invocation.getter(#repository), + returnValue: _FakeTodoRepository_0( + this, + Invocation.getter(#repository), + ), + ) + as _i2.TodoRepository); + + @override + _i5.Future> call(DateTime? targetDate) => + (super.noSuchMethod( + Invocation.method(#call, [targetDate]), + returnValue: _i5.Future>.value(<_i6.Todo>[]), + ) + as _i5.Future>); +} From e4f66bab3ebad4e882771a35e669d678d5cafa5d Mon Sep 17 00:00:00 2001 From: Jun Seo Date: Sat, 6 Jun 2026 14:57:27 +0900 Subject: [PATCH 6/7] feat(presentation): confirm-and-delete entire series for recurring todos TodoManageViewModel gains deleteRecurringSeries (delegates to DeleteRecurringTodoUseCase) and deleteTodoById now falls back to the day's expanded occurrences so virtual instances can also be targeted. todo_list_section routes onDelete through a shared handler: a non-recurring todo deletes immediately as before, while a recurring one (series or any occurrence) raises a confirmation dialog explaining that future occurrences will be removed and completed history is preserved. Co-Authored-By: Claude Opus 4.7 --- .../presentation/lib/injection/di.config.dart | 24 ++++++----- .../todo/todo_manage_viewmodel.dart | 24 ++++++++++- .../views/todo/manage/todo_manage_screen.dart | 3 ++ .../views/todo/widget/todo_list_section.dart | 41 +++++++++++++++++-- .../todo/todo_manage_viewmodel_test.dart | 5 +++ .../todo_manage_viewmodel_test.mocks.dart | 31 ++++++++++++++ 6 files changed, 112 insertions(+), 16 deletions(-) diff --git a/toondo/packages/presentation/lib/injection/di.config.dart b/toondo/packages/presentation/lib/injection/di.config.dart index 66d5a715..8d74b789 100644 --- a/toondo/packages/presentation/lib/injection/di.config.dart +++ b/toondo/packages/presentation/lib/injection/di.config.dart @@ -44,6 +44,7 @@ import 'package:domain/usecases/theme/set_theme_mode.dart' as _i366; import 'package:domain/usecases/todo/commit_todos.dart' as _i412; import 'package:domain/usecases/todo/create_recurring_todo.dart' as _i787; import 'package:domain/usecases/todo/create_todo.dart' as _i834; +import 'package:domain/usecases/todo/delete_recurring_todo.dart' as _i657; import 'package:domain/usecases/todo/delete_todo.dart' as _i552; import 'package:domain/usecases/todo/expand_recurring_todos_for_date.dart' as _i466; @@ -122,17 +123,6 @@ extension GetItInjectableX on _i174.GetIt { logoutUseCase: gh<_i969.LogoutUseCase>(), ), ); - gh.lazySingleton<_i506.TodoManageViewModel>( - () => _i506.TodoManageViewModel( - deleteTodoUseCase: gh<_i552.DeleteTodoUseCase>(), - getTodosUseCase: gh<_i362.GetAllTodosUseCase>(), - updateTodoStatusUseCase: gh<_i183.UpdateTodoStatusUseCase>(), - updateTodoDatesUseCase: gh<_i182.UpdateTodoDatesUseCase>(), - getGoalsLocalUseCase: gh<_i477.GetGoalsLocalUseCase>(), - expandRecurring: gh<_i466.ExpandRecurringTodosForDateUseCase>(), - initialDate: gh(), - ), - ); gh.factory<_i940.GoalManagementViewModel>( () => _i940.GoalManagementViewModel( getGoalsLocalUseCase: gh<_i477.GetGoalsLocalUseCase>(), @@ -210,6 +200,18 @@ extension GetItInjectableX on _i174.GetIt { gh<_i466.ExpandRecurringTodosForDateUseCase>(), ), ); + gh.lazySingleton<_i506.TodoManageViewModel>( + () => _i506.TodoManageViewModel( + deleteTodoUseCase: gh<_i552.DeleteTodoUseCase>(), + getTodosUseCase: gh<_i362.GetAllTodosUseCase>(), + updateTodoStatusUseCase: gh<_i183.UpdateTodoStatusUseCase>(), + updateTodoDatesUseCase: gh<_i182.UpdateTodoDatesUseCase>(), + getGoalsLocalUseCase: gh<_i477.GetGoalsLocalUseCase>(), + expandRecurring: gh<_i466.ExpandRecurringTodosForDateUseCase>(), + deleteRecurring: gh<_i657.DeleteRecurringTodoUseCase>(), + initialDate: gh(), + ), + ); gh.lazySingleton<_i1040.AppThemeViewModel>( () => _i1040.AppThemeViewModel( gh<_i129.GetThemeModeUseCase>(), diff --git a/toondo/packages/presentation/lib/viewmodels/todo/todo_manage_viewmodel.dart b/toondo/packages/presentation/lib/viewmodels/todo/todo_manage_viewmodel.dart index 6e323abb..9a8d9d9f 100644 --- a/toondo/packages/presentation/lib/viewmodels/todo/todo_manage_viewmodel.dart +++ b/toondo/packages/presentation/lib/viewmodels/todo/todo_manage_viewmodel.dart @@ -5,6 +5,7 @@ import 'package:domain/usecases/goal/get_goals_local.dart'; import 'package:domain/usecases/todo/get_all_todos.dart'; import 'package:domain/usecases/todo/update_todo_dates.dart'; import 'package:domain/usecases/todo/update_todo_status.dart'; +import 'package:domain/usecases/todo/delete_recurring_todo.dart'; import 'package:domain/usecases/todo/delete_todo.dart'; import 'package:domain/usecases/todo/expand_recurring_todos_for_date.dart'; import 'package:flutter/material.dart'; @@ -20,6 +21,7 @@ class TodoManageViewModel extends ChangeNotifier { final UpdateTodoDatesUseCase _updateTodoDatesUseCase; final GetGoalsLocalUseCase _getGoalsLocalUseCase; final ExpandRecurringTodosForDateUseCase _expandRecurring; + final DeleteRecurringTodoUseCase _deleteRecurring; DateTime selectedDate; TodoFilterOption selectedFilter = TodoFilterOption.all; @@ -37,6 +39,7 @@ class TodoManageViewModel extends ChangeNotifier { required UpdateTodoDatesUseCase updateTodoDatesUseCase, required GetGoalsLocalUseCase getGoalsLocalUseCase, required ExpandRecurringTodosForDateUseCase expandRecurring, + required DeleteRecurringTodoUseCase deleteRecurring, DateTime? initialDate, }) : _deleteTodoUseCase = deleteTodoUseCase, _getTodosUseCase = getTodosUseCase, @@ -44,6 +47,7 @@ class TodoManageViewModel extends ChangeNotifier { _updateTodoDatesUseCase = updateTodoDatesUseCase, _getGoalsLocalUseCase = getGoalsLocalUseCase, _expandRecurring = expandRecurring, + _deleteRecurring = deleteRecurring, selectedDate = initialDate ?? DateTime.now(); Future loadTodos() async { @@ -173,9 +177,27 @@ class TodoManageViewModel extends ChangeNotifier { } } + Future deleteRecurringSeries(String seriesId) async { + try { + await _deleteRecurring(seriesId); + await loadTodos(); + try { + await GetIt.instance().loadTodos(); + } catch (e) { + print('⚠️ 홈 뷰모델 동기화 실패: $e'); + } + notifyListeners(); + } catch (e) { + print('Error deleting recurring series: $e'); + } + } + Future deleteTodoById(String id) async { try { - final target = allTodos.firstWhere((t) => t.id == id); + final target = allTodos.firstWhere( + (t) => t.id == id, + orElse: () => _expandedForSelectedDate.firstWhere((t) => t.id == id), + ); await _deleteTodoUseCase(target); await loadTodos(); diff --git a/toondo/packages/presentation/lib/views/todo/manage/todo_manage_screen.dart b/toondo/packages/presentation/lib/views/todo/manage/todo_manage_screen.dart index f039ee30..9023e200 100644 --- a/toondo/packages/presentation/lib/views/todo/manage/todo_manage_screen.dart +++ b/toondo/packages/presentation/lib/views/todo/manage/todo_manage_screen.dart @@ -5,6 +5,7 @@ import 'package:presentation/designsystem/dimensions/app_dimensions.dart'; import 'package:presentation/views/base_scaffold.dart'; import 'package:presentation/views/todo/manage/todo_manage_body.dart'; import 'package:provider/provider.dart'; +import 'package:domain/usecases/todo/delete_recurring_todo.dart'; import 'package:domain/usecases/todo/delete_todo.dart'; import 'package:domain/usecases/todo/expand_recurring_todos_for_date.dart'; import 'package:domain/usecases/todo/get_all_todos.dart'; @@ -30,6 +31,8 @@ class TodoManageScreen extends StatelessWidget { getGoalsLocalUseCase: GetIt.instance(), expandRecurring: GetIt.instance(), + deleteRecurring: + GetIt.instance(), initialDate: selectedDate, )..loadTodos(), child: BaseScaffold( diff --git a/toondo/packages/presentation/lib/views/todo/widget/todo_list_section.dart b/toondo/packages/presentation/lib/views/todo/widget/todo_list_section.dart index de5a34d3..bda09f13 100644 --- a/toondo/packages/presentation/lib/views/todo/widget/todo_list_section.dart +++ b/toondo/packages/presentation/lib/views/todo/widget/todo_list_section.dart @@ -1,4 +1,5 @@ import 'package:domain/entities/goal.dart'; +import 'package:domain/entities/todo.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:presentation/designsystem/colors/app_colors.dart'; @@ -123,9 +124,8 @@ class TodoListSection extends StatelessWidget { isDdayTodo: isDDay, isChecked: isCompleted, levelColor: levelColor, - onDelete: () { - viewModel.deleteTodoById(todo.id); - }, + onDelete: () => + _handleDelete(context, todo, viewModel), onTap: () async { showModalBottomSheet( context: context, @@ -152,8 +152,8 @@ class TodoListSection extends StatelessWidget { viewModel.loadTodos(); }, onDelete: () { - viewModel.deleteTodoById(todo.id); Navigator.pop(context); // 바텀시트 닫기 + _handleDelete(context, todo, viewModel); }, onDelay: isDDay @@ -185,4 +185,37 @@ class TodoListSection extends StatelessWidget { ), ); } + + Future _handleDelete( + BuildContext context, + Todo todo, + TodoManageViewModel viewModel, + ) async { + if (!todo.isRecurring) { + viewModel.deleteTodoById(todo.id); + return; + } + final seriesId = todo.seriesId ?? todo.id; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('반복 할 일 삭제'), + content: const Text( + '이 반복 할 일과 앞으로의 일정을 모두 삭제할까요?\n이미 완료한 기록은 그대로 보존돼요.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('삭제'), + ), + ], + ), + ); + if (confirmed == true) { + await viewModel.deleteRecurringSeries(seriesId); + } + } } diff --git a/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.dart b/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.dart index 5f9df9ad..a846e258 100644 --- a/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.dart +++ b/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.dart @@ -11,6 +11,7 @@ import 'package:domain/usecases/goal/get_goals_local.dart'; import 'package:domain/usecases/todo/get_all_todos.dart'; import 'package:domain/usecases/todo/update_todo_dates.dart'; import 'package:domain/usecases/todo/update_todo_status.dart'; +import 'package:domain/usecases/todo/delete_recurring_todo.dart'; import 'package:domain/usecases/todo/delete_todo.dart'; import 'package:domain/usecases/todo/expand_recurring_todos_for_date.dart'; import 'package:presentation/viewmodels/todo/todo_manage_viewmodel.dart'; @@ -24,6 +25,7 @@ import '../../helpers/test_data.dart'; UpdateTodoDatesUseCase, GetGoalsLocalUseCase, ExpandRecurringTodosForDateUseCase, + DeleteRecurringTodoUseCase, ]) void main() { late TodoManageViewModel viewModel; @@ -33,6 +35,7 @@ void main() { late MockUpdateTodoDatesUseCase mockUpdateTodoDatesUseCase; late MockGetGoalsLocalUseCase mockGetGoalsLocalUseCase; late MockExpandRecurringTodosForDateUseCase mockExpandRecurringTodosForDateUseCase; + late MockDeleteRecurringTodoUseCase mockDeleteRecurringTodoUseCase; final testDate = DateTime(2025, 5, 5); late List testTodos; @@ -46,6 +49,7 @@ void main() { mockGetGoalsLocalUseCase = MockGetGoalsLocalUseCase(); mockExpandRecurringTodosForDateUseCase = MockExpandRecurringTodosForDateUseCase(); + mockDeleteRecurringTodoUseCase = MockDeleteRecurringTodoUseCase(); testTodos = TestData.createTestTodos(); testGoals = TestData.createTestGoals(); @@ -62,6 +66,7 @@ void main() { updateTodoDatesUseCase: mockUpdateTodoDatesUseCase, getGoalsLocalUseCase: mockGetGoalsLocalUseCase, expandRecurring: mockExpandRecurringTodosForDateUseCase, + deleteRecurring: mockDeleteRecurringTodoUseCase, initialDate: testDate, ); }); diff --git a/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.mocks.dart b/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.mocks.dart index 4eae7a3f..a58d5abb 100644 --- a/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.mocks.dart +++ b/toondo/packages/presentation/test/viewmodels/todo/todo_manage_viewmodel_test.mocks.dart @@ -10,6 +10,7 @@ import 'package:domain/entities/todo.dart' as _i6; import 'package:domain/repositories/goal_repository.dart' as _i3; import 'package:domain/repositories/todo_repository.dart' as _i2; import 'package:domain/usecases/goal/get_goals_local.dart' as _i10; +import 'package:domain/usecases/todo/delete_recurring_todo.dart' as _i13; import 'package:domain/usecases/todo/delete_todo.dart' as _i4; import 'package:domain/usecases/todo/expand_recurring_todos_for_date.dart' as _i12; @@ -223,3 +224,33 @@ class MockExpandRecurringTodosForDateUseCase extends _i1.Mock ) as _i5.Future>); } + +/// A class which mocks [DeleteRecurringTodoUseCase]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDeleteRecurringTodoUseCase extends _i1.Mock + implements _i13.DeleteRecurringTodoUseCase { + MockDeleteRecurringTodoUseCase() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.TodoRepository get repository => + (super.noSuchMethod( + Invocation.getter(#repository), + returnValue: _FakeTodoRepository_0( + this, + Invocation.getter(#repository), + ), + ) + as _i2.TodoRepository); + + @override + _i5.Future call(String? seriesId) => + (super.noSuchMethod( + Invocation.method(#call, [seriesId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); +} From 85b2d6784f884b637f3a40c18073b8533331067c Mon Sep 17 00:00:00 2001 From: Jun Seo Date: Sat, 6 Jun 2026 14:59:07 +0900 Subject: [PATCH 7/7] test(data): end-to-end recurring todo flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives a real TodoLocalDatasource (Hive) via a thin in-process TodoRepository so create → expand → materialize-on-complete → delete runs through the actual persistence layer, not mocks. Confirms a weekly Mon/Wed/Fri series surfaces on Mon and Wed but not Tue, that checking the Mon occurrence materializes a status=1.0 record, and that deleting the series drops future occurrences while preserving the completed Mon record. Co-Authored-By: Claude Opus 4.7 --- .../integration/recurring_todo_flow_test.dart | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 toondo/packages/data/test/integration/recurring_todo_flow_test.dart diff --git a/toondo/packages/data/test/integration/recurring_todo_flow_test.dart b/toondo/packages/data/test/integration/recurring_todo_flow_test.dart new file mode 100644 index 00000000..ab7850d4 --- /dev/null +++ b/toondo/packages/data/test/integration/recurring_todo_flow_test.dart @@ -0,0 +1,163 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:data/datasources/local/todo_local_datasource.dart'; +import 'package:data/models/recurrence_rule_model.dart'; +import 'package:data/models/todo_model.dart'; +import 'package:domain/entities/recurrence_rule.dart'; +import 'package:domain/entities/todo.dart'; +import 'package:domain/repositories/todo_repository.dart'; +import 'package:domain/usecases/todo/create_recurring_todo.dart'; +import 'package:domain/usecases/todo/delete_recurring_todo.dart'; +import 'package:domain/usecases/todo/expand_recurring_todos_for_date.dart'; + +// In-process repository that hooks the real local datasource without +// pulling in the remote/Dio stack. Mirrors the four methods exercised by +// the recurring flow; everything else is unimplemented because the test +// does not call them. +class _LocalOnlyTodoRepository implements TodoRepository { + _LocalOnlyTodoRepository(this.local); + final TodoLocalDatasource local; + + @override + Future createTodo(Todo todo) => local.saveTodo(todo); + + @override + Future> getRecurringSeries() async => local.getRecurringSeries(); + + @override + Future findOccurrence({ + required String seriesId, + required DateTime occurrenceDate, + }) async => + local.findOccurrence( + seriesId: seriesId, + occurrenceDate: occurrenceDate, + ); + + @override + Future deleteSeries(String seriesId) => + local.deleteSeriesAndUnfinishedOccurrences(seriesId); + + @override + Future materializeOccurrence(Todo occurrence) async { + await local.saveTodo(occurrence); + return occurrence; + } + + @override + Future updateTodoStatus(Todo todo, double status) async { + final updated = Todo( + id: todo.id, + title: todo.title, + startDate: todo.startDate, + endDate: todo.endDate, + goalId: todo.goalId, + status: status, + comment: todo.comment, + eisenhower: todo.eisenhower, + showOnHome: todo.showOnHome, + recurrence: todo.recurrence, + seriesId: todo.seriesId, + occurrenceDate: todo.occurrenceDate, + ); + await local.updateTodo(updated); + } + + // Unused by this test. + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late Directory tempDir; + late Box todoBox; + late Box deletedBox; + late TodoLocalDatasource ds; + late _LocalOnlyTodoRepository repo; + late CreateRecurringTodoUseCase create; + late ExpandRecurringTodosForDateUseCase expand; + late DeleteRecurringTodoUseCase deleteSeries; + var counter = 0; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('recurring_flow_'); + Hive.init(tempDir.path); + Hive.registerAdapter(TodoModelAdapter()); + Hive.registerAdapter(RecurrenceFrequencyModelAdapter()); + Hive.registerAdapter(RecurrenceEndKindAdapter()); + Hive.registerAdapter(RecurrenceEndModelAdapter()); + Hive.registerAdapter(RecurrenceRuleModelAdapter()); + }); + + tearDownAll(() async { + await Hive.close(); + if (await tempDir.exists()) await tempDir.delete(recursive: true); + }); + + setUp(() async { + counter++; + todoBox = await Hive.openBox('flow_todos_$counter'); + deletedBox = await Hive.openBox('flow_deleted_$counter'); + ds = TodoLocalDatasource(todoBox, deletedBox); + repo = _LocalOnlyTodoRepository(ds); + create = CreateRecurringTodoUseCase(repo); + expand = ExpandRecurringTodosForDateUseCase(repo); + deleteSeries = DeleteRecurringTodoUseCase(repo); + }); + + tearDown(() async { + await todoBox.close(); + await deletedBox.close(); + }); + + test('주 3회(월/수/금) 시리즈 생성 → expand → 한 회차 완료 → 삭제 후 완료 이력 보존', () async { + // 1. 주 3회 시리즈 생성 (시작: 월요일 2025-01-06) + final monday = DateTime(2025, 1, 6); + final series = Todo( + id: 'series-exercise', + title: '운동', + startDate: monday, + endDate: monday, + showOnHome: true, + recurrence: RecurrenceRule( + frequency: RecurrenceFrequency.weekly, + byWeekdays: const [1, 3, 5], + ), + ); + final created = await create(series); + expect(created, isTrue); + + // 2. 월/화/수 expand 결과 검증 + final mon = await expand(DateTime(2025, 1, 6)); + final tue = await expand(DateTime(2025, 1, 7)); + final wed = await expand(DateTime(2025, 1, 8)); + expect(mon, hasLength(1)); + expect(tue, isEmpty); + expect(wed, hasLength(1)); + + // 3. 월요일 회차를 완료 (가상 인스턴스 → 머터리얼라이즈) + final monOccurrence = mon.single; + expect(monOccurrence.seriesId, 'series-exercise'); + await repo.updateTodoStatus(monOccurrence, 1.0); + + // 4. 같은 날 다시 expand → 머터리얼라이즈된 완료 인스턴스가 반환되어야 + final monAgain = await expand(DateTime(2025, 1, 6)); + expect(monAgain.single.status, 1.0); + expect(monAgain.single.seriesId, 'series-exercise'); + + // 5. 시리즈 삭제 → 시리즈 + 미완료 occurrence 제거, 완료 이력 보존 + await deleteSeries('series-exercise'); + + // 시리즈가 사라졌으므로 미래 expand 결과는 빈 리스트 + expect(await expand(DateTime(2025, 1, 8)), isEmpty); + expect(await expand(DateTime(2025, 1, 10)), isEmpty); + + // 완료된 월요일 인스턴스는 box에 남아있다 (직접 확인) + final all = ds.getAllTodos(); + expect(all.any((t) => t.seriesId == 'series-exercise' && t.isFinished()), + isTrue); + expect(all.any((t) => t.id == 'series-exercise'), isFalse); + }); +}