feat(flutter): 일정·기록 탭의 일정 관리와 홈 일정 구현

마이페이지 다음 흐름 — 홈 "일정" 과 일정·기록 탭이 실제 일정을 다룬다.
- 달력: 일정 색 점, 월↔주 접기, 12개월 보기 / 고른 날의 일정 목록과 완료 체크
- 일정 추가·수정: 대상(여러 마리·나), 예약/일반 일정, 시간, 장소, 라벨 색
  - 반복(매일·평일·주말·N일/주/개월마다) — 끝나는 날 없는 반복은 날짜를 만들지 않고 규칙으로 보관
  - 기간 설정 + 하루 횟수 — 기간 안의 날짜(반복 주기에 맞는 날)마다 만든다
- 홈: 다음 일정(도는 테두리), 오늘 할 일(4개까지·밤 11시 하루 정리), 예약 일정 D-day
- 아이를 지우면 일정에서 그 아이만 떼고, 회원탈퇴는 일정도 지운다
- 일정은 기기에 저장한다 (백엔드 API 가 생기면 ScheduleRepository 구현을 바꾼다)

시안과 다른 점: "함께 관리"는 서버가 필요해 안내만, 다이어리 탭은 다음 단계,
기간+반복에 맞는 날이 없으면 저장하지 않고 알린다 (시안은 빈 저장 후 "추가했어요").

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:32:37 +09:00
parent f2a4451b92
commit a32c0d1337
25 changed files with 4118 additions and 69 deletions
+197
View File
@@ -0,0 +1,197 @@
import 'dart:ui' show Color;
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/utils/hour_minute.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
import 'package:tma_app/features/schedule/domain/schedule_book.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
EventDetails _details(
String title, {
EventKind kind = EventKind.general,
EventCategory category = EventCategory.etc,
List<String> targets = const ['pet-1'],
HourMinute? start,
}) =>
EventDetails(title: title, kind: kind, category: category, targetIds: targets, start: start);
ScheduleEvent _event(String id, DateTime date, EventDetails d) => ScheduleEvent(id: id, date: date, details: d);
void main() {
group('HourMinute', () {
test('12시간제로 보여 준다', () {
expect(const HourMinute(0, 5).label, '오전 12:05');
expect(const HourMinute(9, 0).label, '오전 9:00');
expect(const HourMinute(12, 30).label, '오후 12:30');
expect(const HourMinute(23, 59).label, '오후 11:59');
});
test('저장 형식을 읽고, 틀린 값은 null', () {
expect(HourMinute.tryParse('07:45'), const HourMinute(7, 45));
expect(HourMinute.tryParse('하루 종일'), isNull);
expect(HourMinute.tryParse('24:00'), isNull);
});
test('하루를 넘는 분은 다음 날로 돌린다', () {
expect(HourMinute.fromMinutes(24 * 60 + 1), const HourMinute(0, 1));
});
});
group('RepeatSpec', () {
final from = DateTime(2026, 9, 28); // 월요일
test('시작일 전에는 반복하지 않는다', () {
expect(const RepeatSpec(RepeatPattern.daily).matches(from, DateTime(2026, 9, 27)), isFalse);
});
test('평일만 / 주말만', () {
const weekday = RepeatSpec(RepeatPattern.weekday);
const weekend = RepeatSpec(RepeatPattern.weekend);
expect(weekday.matches(from, DateTime(2026, 10, 2)), isTrue); // 금
expect(weekday.matches(from, DateTime(2026, 10, 3)), isFalse); // 토
expect(weekend.matches(from, DateTime(2026, 10, 4)), isTrue); // 일
expect(weekend.matches(from, DateTime(2026, 10, 5)), isFalse); // 월
});
test('격주는 같은 요일 2주마다', () {
const biweekly = RepeatSpec(RepeatPattern.every, n: 2);
expect(biweekly.matches(from, DateTime(2026, 10, 5)), isFalse);
expect(biweekly.matches(from, DateTime(2026, 10, 12)), isTrue);
});
test('3일마다', () {
const every3 = RepeatSpec(RepeatPattern.every, unit: RepeatUnit.day, n: 3);
expect(every3.matches(from, DateTime(2026, 10, 1)), isTrue);
expect(every3.matches(from, DateTime(2026, 10, 2)), isFalse);
});
test('매월은 같은 날짜 — 그 날이 없는 달은 건너뛴다', () {
final jan31 = DateTime(2026, 1, 31);
const monthly = RepeatSpec(RepeatPattern.every, unit: RepeatUnit.month);
expect(monthly.matches(jan31, DateTime(2026, 2, 28)), isFalse);
expect(monthly.matches(jan31, DateTime(2026, 3, 31)), isTrue);
});
test('사람이 읽는 문구', () {
expect(const RepeatSpec(RepeatPattern.weekday).label, '평일마다');
expect(const RepeatSpec(RepeatPattern.every, unit: RepeatUnit.month, n: 2).label, '2개월 마다');
});
});
group('ScheduleBook', () {
final day = DateTime(2026, 9, 28);
test('하루 일정은 시간순, 하루 종일은 뒤에 — 반복 일정도 함께', () {
final book = ScheduleBook(
events: [
_event('a', day, _details('하루 종일 일정')),
_event('b', day, _details('저녁', start: const HourMinute(19, 0))),
],
rules: [
RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('아침 산책', start: const HourMinute(7, 0))),
],
);
expect(book.eventsOn(day).map((e) => e.details.title), ['아침 산책', '저녁', '하루 종일 일정']);
expect(book.eventsOn(day).first.id, 'r1@2026-09-28');
});
test('반복 일정 id 로 그 날짜의 일정을 찾는다', () {
final book = ScheduleBook(rules: [
RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('산책')),
]);
final found = book.find('r1@2026-10-01');
expect(found?.date, DateTime(2026, 10, 1));
expect(found?.isRepeat, isTrue);
});
test('일반 일정을 반복으로 바꾸면 그 날부터 규칙이 된다', () {
final target = _event('a', day, _details('산책'));
final book = ScheduleBook(events: [target]).update(
target,
_details('산책'),
spec: const RepeatSpec(RepeatPattern.daily),
newEventId: () => 'e-new',
newRuleId: () => 'r-new',
);
expect(book.events, isEmpty);
expect(book.rules.single.from, day);
expect(book.eventsOn(DateTime(2026, 9, 30)).single.details.title, '산책');
});
test('반복을 끄면 보고 있던 날짜의 일정 하나만 남고 그날 완료 표시는 지운다', () {
final rule = RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('산책'));
final instance = rule.instanceOn(DateTime(2026, 10, 1));
final book = ScheduleBook(rules: [rule], doneIds: {instance.id}).update(
instance,
_details('산책'),
newEventId: () => 'e-new',
newRuleId: () => 'r-new',
);
expect(book.rules, isEmpty);
expect(book.events.single.date, DateTime(2026, 10, 1));
expect(book.doneIds, isEmpty);
});
test('반복 일정을 지우면 규칙 전체가 지워진다', () {
final rule = RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('산책'));
final book = ScheduleBook(rules: [rule]).delete(rule.instanceOn(day));
expect(book.eventsOn(DateTime(2026, 10, 5)), isEmpty);
});
test('완료 체크는 켜고 끈다', () {
final book = const ScheduleBook().toggleDone('a');
expect(book.isDone('a'), isTrue);
expect(book.toggleDone('a').isDone('a'), isFalse);
});
test('아이를 지우면 그 아이만 떼고, 대상이 없는 일정은 지운다', () {
final book = ScheduleBook(events: [
_event('both', day, _details('산책', targets: ['pet-1', 'pet-2'])),
_event('only', day, _details('미용', targets: ['pet-1'])),
]).dropTarget('pet-1');
expect(book.events.single.id, 'both');
expect(book.events.single.details.targetIds, ['pet-2']);
});
test('예약 일정은 오늘 이후 가까운 순 4개까지, 일반 일정은 빠진다', () {
final res = _details('병원', kind: EventKind.reservation, category: EventCategory.hospital);
final book = ScheduleBook(events: [
_event('past', DateTime(2026, 9, 1), res),
for (var i = 5; i >= 1; i--) _event('r$i', DateTime(2026, 10, i), res),
_event('care', DateTime(2026, 9, 29), _details('산책')),
]);
expect(book.upcomingReservations(day).map((e) => e.id), ['r1', 'r2', 'r3', 'r4']);
});
test('오늘 할 일은 오늘의 일반 일정만', () {
final book = ScheduleBook(events: [
_event('care', day, _details('산책')),
_event('res', day, _details('병원', kind: EventKind.reservation)),
]);
expect(book.todayTasks(day).single.id, 'care');
});
});
test('일정은 저장 형식으로 바꿨다가 그대로 되살린다', () {
final e = ScheduleEvent(
id: 'e1',
date: DateTime(2026, 9, 28),
details: const EventDetails(
title: '병원',
kind: EventKind.reservation,
category: EventCategory.hospital,
targetIds: ['pet-1', kSelfTargetId],
start: HourMinute(14, 0),
end: HourMinute(15, 30),
place: '동물병원',
labelColor: Color(0xFF3FB58C),
),
);
final back = ScheduleEvent.fromJson(e.toJson());
expect(back.details.targetIds, ['pet-1', kSelfTargetId]);
expect(back.details.end, const HourMinute(15, 30));
expect(back.details.color, const Color(0xFF3FB58C));
expect(back.details.kind, EventKind.reservation);
});
}
+141
View File
@@ -0,0 +1,141 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/hour_minute.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
import 'package:tma_app/features/schedule/presentation/pages/event_editor_page.dart';
import 'support/app_harness.dart';
DateTime get _today => dateOnly(DateTime.now());
/// 기기에 미리 넣어 둘 일정
String _events(List<ScheduleEvent> events) => jsonEncode([for (final e in events) e.toJson()]);
Map<String, Object> _withPet({List<ScheduleEvent> events = const []}) => {
'pets.v1': jsonEncode([samplePet().toJson()]),
if (events.isNotEmpty) 'schedule.events.v1': _events(events),
};
Future<void> _openRecords(WidgetTester tester) async {
await tester.tap(find.bySemanticsLabel('일정·기록'));
await settle(tester);
}
/// 홈 화면의 세로 목록까지 내려 [finder] 를 보이게 한다 (가로로 넘기는 아이 목록과 구분)
Future<void> _scrollHomeTo(WidgetTester tester, Finder finder) =>
tester.scrollUntilVisible(finder, 200, scrollable: find.byType(Scrollable).first);
Future<void> _tapVisible(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.pump();
await tester.tap(finder);
await settle(tester);
}
void main() {
testWidgets('일정 관리에서 일정을 추가하면 목록과 홈 오늘 할 일에 나타나고, 홈에서 완료 체크한다', (tester) async {
final prefs = await launchSignedIn(tester, extra: _withPet());
await _openRecords(tester);
expect(find.textContaining('오늘 일정', findRichText: true), findsOneWidget);
expect(find.text('이 날은 일정이 없어요.'), findsOneWidget);
await tester.tap(find.text('일정 추가'));
await settle(tester);
expect(find.byType(EventEditorPage), findsOneWidget);
// 이름 없이 저장하면 안내만 하고 머문다
await _tapVisible(tester, find.text('일정 저장'));
// 입력칸 안내 문구 + 토스트
expect(find.text('일정 이름을 입력해주세요'), findsNWidgets(2));
expect(find.byType(EventEditorPage), findsOneWidget);
await tester.enterText(find.byType(TextField).first, '저녁 산책');
await _tapVisible(tester, find.text('일정 저장'));
expect(find.byType(EventEditorPage), findsNothing);
expect(find.text('저녁 산책'), findsOneWidget);
expect(prefs.getString('schedule.events.v1'), contains('저녁 산책'));
await tester.tap(find.bySemanticsLabel('홈'));
await settle(tester);
await _scrollHomeTo(tester, find.text('0/1 완료'));
// 다음 일정 카드 + 오늘 할 일 목록
expect(find.text('저녁 산책'), findsNWidgets(2));
await tester.tap(find.text('저녁 산책').last);
await settle(tester);
expect(find.text('1/1 완료'), findsOneWidget);
expect(find.text('오늘의 일정을 전부 끝냈어요! 고생하셨습니다 🧡'), findsOneWidget);
});
testWidgets('일반 일정 칩으로 이름을 채우고 반복을 켜면 끝없는 반복 일정으로 저장된다', (tester) async {
final prefs = await launchSignedIn(tester, extra: _withPet());
await _openRecords(tester);
await tester.tap(find.text('일정 추가'));
await settle(tester);
await tester.tap(find.text('목욕'));
await tester.pump();
expect(find.widgetWithText(TextField, '목욕'), findsOneWidget);
await _tapVisible(tester, find.bySemanticsLabel('반복 일정'));
expect(find.text('매일 반복'), findsOneWidget);
await _tapVisible(tester, find.text('격주'));
expect(find.text('2주 마다 반복'), findsOneWidget);
await _tapVisible(tester, find.text('일정 저장'));
expect(find.text('여러 날짜에 일정을 추가했어요'), findsOneWidget);
expect(prefs.getString('schedule.rules.v1'), contains('목욕'));
expect(find.text('목욕'), findsOneWidget);
});
testWidgets('일정을 누르면 수정 화면이 열리고, 제거하면 목록에서 사라진다', (tester) async {
final walk = ScheduleEvent(
id: 'e1',
date: _today,
details: const EventDetails(
title: '미용 예약',
kind: EventKind.reservation,
category: EventCategory.grooming,
targetIds: ['pet-1'],
start: HourMinute(14, 0),
),
);
await launchSignedIn(tester, extra: _withPet(events: [walk]));
await _openRecords(tester);
expect(find.text('오후 2:00'), findsOneWidget);
await tester.tap(find.text('미용 예약'));
await settle(tester);
expect(find.text('일정 수정'), findsOneWidget);
await _tapVisible(tester, find.text('일정 제거'));
expect(find.text('일정을 제거할까요?'), findsOneWidget);
await tester.tap(find.text('제거하기'));
await settle(tester);
expect(find.byType(EventEditorPage), findsNothing);
expect(find.text('미용 예약'), findsNothing);
expect(find.text('이 날은 일정이 없어요.'), findsOneWidget);
});
testWidgets('다가오는 예약 일정은 홈에 D-day 로 보인다', (tester) async {
final visit = ScheduleEvent(
id: 'e1',
date: DateTime(_today.year, _today.month, _today.day + 3),
details: const EventDetails(
title: '예방접종',
kind: EventKind.reservation,
category: EventCategory.vaccine,
targetIds: ['pet-1'],
),
);
await launchSignedIn(tester, extra: _withPet(events: [visit]));
await _scrollHomeTo(tester, find.text('D-3'));
expect(find.text('예방접종'), findsOneWidget);
expect(find.text('예약된 일정이 없어요'), findsNothing);
});
}