From 60efc65ee07f6780d1185d697f69f9369c46d58b Mon Sep 17 00:00:00 2001 From: TtiPo Date: Sun, 11 Oct 2026 18:23:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EC=9D=BC=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EC=97=90=20=EC=A0=80=EC=9E=A5=20(ApiSchedule?= =?UTF-8?q?Repository)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일정 저장소가 "목록 전체 저장" 이라 서버에 맞지 않아 건별 계약으로 바꿨다. - 저장소: 날짜 일정 여러 건 만들기·내용 바꾸기·삭제, 반복 규칙 만들기·바꾸기·삭제, 완료 표시, 대상 떼기. 돌려받은 결과(id 가 붙은 일정·규칙)를 알림기가 ScheduleBook 에 반영한다 - 일반↔반복 전환은 알림기가 저장소 호출로 풀고, 새 것을 먼저 만든 뒤 옛것을 지운다(중간 실패에도 일정이 남는다). ScheduleBook.update 는 지우고 같은 규칙을 알림기 테스트로 옮겼다 - 서버 저장소: 불러올 때 날짜 일정·반복 규칙·완료 표시를 합치고, 기간 설정은 500건씩 나눠 보낸다. 아이 삭제·보관 때의 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다 - 완료 체크는 화면에 먼저 반영하고 저장에 실패하면 되돌린다 - 일정·아이 저장 화면은 실패하면(알림은 인터셉터) 입력을 둔 채 머문다 Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/api/api_guard.dart | 7 + .../widgets/home_schedule_section.dart | 3 +- .../presentation/pages/pet_register_page.dart | 8 +- .../data/api_schedule_repository.dart | 157 ++++++++++++++++ .../schedule/data/schedule_repository.dart | 114 ++++++++++-- .../schedule/domain/schedule_book.dart | 43 +---- .../schedule/domain/schedule_event.dart | 3 + .../presentation/pages/event_editor_page.dart | 6 +- .../providers/schedule_providers.dart | 110 ++++++++---- .../presentation/widgets/schedule_view.dart | 3 +- .../test/api_schedule_repository_test.dart | 150 ++++++++++++++++ flutter/test/schedule_domain_test.dart | 28 +-- flutter/test/schedule_notifier_test.dart | 168 ++++++++++++++++++ 13 files changed, 681 insertions(+), 119 deletions(-) create mode 100644 flutter/lib/features/schedule/data/api_schedule_repository.dart create mode 100644 flutter/test/api_schedule_repository_test.dart create mode 100644 flutter/test/schedule_notifier_test.dart diff --git a/flutter/lib/core/api/api_guard.dart b/flutter/lib/core/api/api_guard.dart index 0c759e3..13cb35a 100644 --- a/flutter/lib/core/api/api_guard.dart +++ b/flutter/lib/core/api/api_guard.dart @@ -16,3 +16,10 @@ Future succeeded(Future action) async { return false; } } + +/// 이미 알린 서버 실패인지 — 화면의 catch 에서 이 실패만 삼키고 나머지는 다시 던진다. +/// } catch (e) { +/// if (!isReportedApiFailure(e)) rethrow; +/// } +bool isReportedApiFailure(Object error) => + error is ApiException || (error is DioException && error.error is ApiException); diff --git a/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart b/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart index f134abc..3c5fd41 100644 --- a/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart +++ b/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:tma_app/core/api/api_guard.dart'; import 'package:tma_app/core/constants/app_colors.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; import 'package:tma_app/core/router/app_routes.dart'; @@ -108,7 +109,7 @@ class HomeScheduleSection extends ConsumerWidget { done: book.isDone(t.id), missed: review && !book.isDone(t.id), first: i == 0, - onTap: () => unawaited(ref.read(scheduleProvider.notifier).toggleDone(t.id)), + onTap: () => unawaited(succeeded(ref.read(scheduleProvider.notifier).toggleDone(t.id))), ), if (hidden > 0) _MoreRow(count: hidden, onTap: () => openSchedule(context, ref, date: today)), ], diff --git a/flutter/lib/features/pet/presentation/pages/pet_register_page.dart b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart index 55492fd..227f94a 100644 --- a/flutter/lib/features/pet/presentation/pages/pet_register_page.dart +++ b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:tma_app/core/api/api_guard.dart'; import 'package:tma_app/core/constants/app_colors.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; import 'package:tma_app/core/router/app_routes.dart'; @@ -250,6 +251,9 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> { // 수정은 반려동물 정보에서 열었으므로 그 화면으로 돌아간다 if (mounted) context.pop(); } + } catch (e) { + // 실패 알림은 인터셉터가 띄웠다 — 입력한 값을 그대로 두고 다시 시도할 수 있게 한다 + if (!isReportedApiFailure(e)) rethrow; } finally { if (mounted) setState(() => _saving = false); } @@ -266,8 +270,8 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> { destructive: true, ); if (!ok || !mounted) return; - await ref.read(petListProvider.notifier).deletePet(editing.id); - // 아이의 펫 명함도 함께 지우고, 일정에서는 그 아이만 뗀다 + if (!await succeeded(ref.read(petListProvider.notifier).deletePet(editing.id))) return; + // 아이의 펫 명함도 함께 지우고, 일정에서는 그 아이만 뗀다 (서버 모드는 서버가 이미 정리했다) await ref.read(petCardsProvider.notifier).deleteForPet(editing.id); await ref.read(scheduleProvider.notifier).dropTarget(editing.id); await ref.read(diaryProvider.notifier).dropTarget(editing.id); diff --git a/flutter/lib/features/schedule/data/api_schedule_repository.dart b/flutter/lib/features/schedule/data/api_schedule_repository.dart new file mode 100644 index 0000000..c2b0af4 --- /dev/null +++ b/flutter/lib/features/schedule/data/api_schedule_repository.dart @@ -0,0 +1,157 @@ +import 'package:dio/dio.dart'; +import 'package:tma_app/core/api/api_paging.dart'; +import 'package:tma_app/core/api/session_store.dart'; +import 'package:tma_app/core/utils/color_utils.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/data/schedule_repository.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'; + +/// 일정 경로 (backend ScheduleEventsController · RepeatRulesController) +const String _kEventsPath = '/schedule-events'; +const String _kRulesPath = '/repeat-rules'; +const String _kCompletionsPath = '/repeat-rule-completions'; + +/// 한 번에 만드는 일정 수 (backend SCHEDULE_LIMITS.eventsPerRequest) — 기간 설정은 나눠 보낸다 +const int kEventsPerRequest = 500; + +/// 앱의 EventDetails → 서버 본문 +Map eventDetailsToApi(EventDetails d) => { + 'title': d.title, + 'kind': d.kind.name, + 'category': d.category.name, + 'targetIds': d.targetIds, + 'start': d.start?.hhmm, + 'end': d.end?.hhmm, + 'place': d.place, + 'labelColor': d.labelColor == null ? null : hexOf(d.labelColor!), + }; + +/// 서버 응답의 일정 내용 → 앱의 EventDetails +EventDetails eventDetailsFromApi(Map json) => EventDetails( + title: json['title']! as String, + kind: EventKind.byName(json['kind'] as String?), + category: EventCategory.byName(json['category'] as String?), + targetIds: (json['targetIds'] as List?)?.whereType().toList() ?? const [], + start: HourMinute.tryParse(json['start'] as String?), + end: HourMinute.tryParse(json['end'] as String?), + place: json['place'] as String?, + labelColor: colorFromHex(json['labelColor'] as String?), + ); + +ScheduleEvent scheduleEventFromApi(Map json) => ScheduleEvent( + id: json['id']! as String, + date: parseIsoDate(json['date']! as String), + details: eventDetailsFromApi(json), + ); + +RepeatSpec repeatSpecFromApi(Map json) => RepeatSpec( + RepeatPattern.values.byName(json['mode']! as String), + unit: RepeatUnit.values.byName(json['unit'] as String? ?? RepeatUnit.week.name), + n: (json['n'] as num?)?.toInt() ?? 1, + ); + +RepeatRule repeatRuleFromApi(Map json) => RepeatRule( + id: json['id']! as String, + from: parseIsoDate(json['from']! as String), + spec: repeatSpecFromApi(json['spec']! as Map), + details: eventDetailsFromApi(json['details']! as Map), + ); + +Map _specToApi(RepeatSpec spec) => {'mode': spec.mode.name, 'unit': spec.unit.name, 'n': spec.n}; + +/// 서버 저장소 — 어느 날에 무엇이 놓이는지는 지금처럼 앱이 계산하고, 서버는 원본(일정·규칙·완료 표시)을 둔다 +class ApiScheduleRepository implements ScheduleRepository { + ApiScheduleRepository({required Dio dio, required SessionStore session}) + : _dio = dio, + _session = session; + + final Dio _dio; + final SessionStore _session; + + @override + Future fetch() async { + if (!_session.hasSession) return ScheduleBook.empty; + + final done = {}; + final events = await fetchAllPages(_dio, _kEventsPath, (json) { + final event = scheduleEventFromApi(json); + if (json['isDone'] == true) done.add(event.id); + return event; + }); + final rules = await fetchAllPages(_dio, _kRulesPath, repeatRuleFromApi); + final completions = await fetchAllPages( + _dio, + _kCompletionsPath, + (json) => '${json['ruleId']! as String}@${json['date']! as String}', + ); + return ScheduleBook(events: events, rules: rules, doneIds: {...done, ...completions}); + } + + @override + Future> createEvents(List drafts) async { + final created = []; + for (var i = 0; i < drafts.length; i += kEventsPerRequest) { + final chunk = drafts.sublist(i, (i + kEventsPerRequest).clamp(0, drafts.length)); + final response = await _dio.post>( + _kEventsPath, + data: { + 'events': [ + for (final d in chunk) {'date': toIsoDate(d.date), ...eventDetailsToApi(d.details)}, + ], + }, + ); + created.addAll(response.data!.map((json) => scheduleEventFromApi(json as Map))); + } + return created; + } + + @override + Future updateEvent(ScheduleEvent event, EventDetails details) async { + final response = await _dio.put>( + '$_kEventsPath/${event.id}', + data: eventDetailsToApi(details), + ); + return scheduleEventFromApi(response.data!); + } + + @override + Future deleteEvent(String eventId) => _dio.delete('$_kEventsPath/$eventId'); + + @override + Future createRule(DateTime from, RepeatSpec spec, EventDetails details) async { + final response = await _dio.post>( + _kRulesPath, + data: {'from': toIsoDate(from), 'spec': _specToApi(spec), 'details': eventDetailsToApi(details)}, + ); + return repeatRuleFromApi(response.data!); + } + + @override + Future updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details) async { + final response = await _dio.put>( + '$_kRulesPath/${rule.id}', + data: {'spec': _specToApi(spec), 'details': eventDetailsToApi(details)}, + ); + return repeatRuleFromApi(response.data!); + } + + @override + Future deleteRule(String ruleId) => _dio.delete('$_kRulesPath/$ruleId'); + + @override + Future setDone(String occurrenceId, {required bool done}) { + final ruleId = ScheduleEvent.ruleIdOf(occurrenceId); + final path = ruleId == null + ? '$_kEventsPath/$occurrenceId/completion' + : '$_kRulesPath/$ruleId/completions/${occurrenceId.split('@').last}'; + return done ? _dio.put(path) : _dio.delete(path); + } + + /// 서버가 아이를 지우거나 보관하면서 이미 일정에서 그 아이를 뗐다 + @override + Future dropTarget(String targetId) async {} +} diff --git a/flutter/lib/features/schedule/data/schedule_repository.dart b/flutter/lib/features/schedule/data/schedule_repository.dart index 55ad968..54a1119 100644 --- a/flutter/lib/features/schedule/data/schedule_repository.dart +++ b/flutter/lib/features/schedule/data/schedule_repository.dart @@ -2,21 +2,45 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/core/api/dio_provider.dart'; +import 'package:tma_app/core/api/session_store.dart'; +import 'package:tma_app/core/config/app_config.dart'; import 'package:tma_app/core/storage/preferences_provider.dart'; +import 'package:tma_app/core/utils/date_format.dart'; +import 'package:tma_app/features/schedule/data/api_schedule_repository.dart'; import 'package:tma_app/features/schedule/domain/care_preset.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'; -/// 일정 저장소 계약 — 백엔드 API 가 생기면 Dio 구현으로 바꾼다. +/// 일정 저장소 계약 — 서버(ApiScheduleRepository) / 기기(LocalScheduleRepository). +/// 바꾼 결과(id 가 붙은 일정·규칙)를 돌려주면 알림기가 [ScheduleBook] 에 반영한다. abstract interface class ScheduleRepository { Future fetch(); - Future saveEvents(List events); + /// 날짜 일정 여러 건 — id 를 붙여 보낸 순서대로 돌려준다 + Future> createEvents(List drafts); - Future saveRules(List rules); + /// 일정 내용 바꾸기 — 날짜·완료 표시는 그대로 + Future updateEvent(ScheduleEvent event, EventDetails details); - Future saveDone(Set doneIds); + Future deleteEvent(String eventId); + + /// 끝나는 날 없는 반복 일정 + Future createRule(DateTime from, RepeatSpec spec, EventDetails details); + + /// 반복 주기·내용 바꾸기 — 시작한 날은 그대로 + Future updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details); + + /// 반복 일정 삭제 — 규칙 전체와 날짜별 완료 표시 + Future deleteRule(String ruleId); + + /// 완료 표시 — 날짜 일정은 일정 id, 반복 일정은 "규칙id@날짜" + Future setDone(String occurrenceId, {required bool done}); + + /// 아이를 지우거나 보관했을 때 일정에서 그 아이를 뗀다. + /// 서버 저장소는 할 일이 없다 — 서버가 아이를 지우거나 보관하면서 이미 정리했다. + Future dropTarget(String targetId); } const String _kEventsKey = 'schedule.events.v1'; @@ -27,36 +51,94 @@ const String _kCaresKey = 'schedule.cares.v1'; List> _decodeList(String? raw) => raw == null ? const [] : (jsonDecode(raw) as List).whereType>().toList(); -/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다. +/// 기기 저장 모드 — SharedPreferences 에 JSON 으로 저장한다 (서버 없이 화면을 볼 때·테스트). class LocalScheduleRepository implements ScheduleRepository { - LocalScheduleRepository(this._prefs); + LocalScheduleRepository(this._prefs, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now; final SharedPreferences _prefs; + final DateTime Function() _clock; + int _serial = 0; - @override - Future fetch() async => ScheduleBook( + /// 같은 순간에 여러 건을 만들어도 겹치지 않는 id + String _newId(String prefix) => '$prefix${_clock().microsecondsSinceEpoch}_${_serial++}'; + + ScheduleBook _read() => ScheduleBook( events: _decodeList(_prefs.getString(_kEventsKey)).map(ScheduleEvent.fromJson).toList(), rules: _decodeList(_prefs.getString(_kRulesKey)).map(RepeatRule.fromJson).toList(), doneIds: (_prefs.getStringList(_kDoneKey) ?? const []).toSet(), ); - @override - Future saveEvents(List events) => - _prefs.setString(_kEventsKey, jsonEncode([for (final e in events) e.toJson()])); + Future _write(ScheduleBook book) async { + await _prefs.setString(_kEventsKey, jsonEncode([for (final e in book.events) e.toJson()])); + await _prefs.setString(_kRulesKey, jsonEncode([for (final r in book.rules) r.toJson()])); + await _prefs.setStringList(_kDoneKey, book.doneIds.toList()); + } @override - Future saveRules(List rules) => - _prefs.setString(_kRulesKey, jsonEncode([for (final r in rules) r.toJson()])); + Future fetch() async => _read(); @override - Future saveDone(Set doneIds) => _prefs.setStringList(_kDoneKey, doneIds.toList()); + Future> createEvents(List drafts) async { + final created = [ + for (final d in drafts) ScheduleEvent(id: _newId('e'), date: dateOnly(d.date), details: d.details), + ]; + await _write(_read().addEvents(created)); + return created; + } + + @override + Future updateEvent(ScheduleEvent event, EventDetails details) async { + final updated = event.copyWith(details: details); + await _write(_read().replaceEvent(updated)); + return updated; + } + + @override + Future deleteEvent(String eventId) async { + final book = _read(); + final event = book.events.where((e) => e.id == eventId).firstOrNull; + if (event != null) await _write(book.delete(event)); + } + + @override + Future createRule(DateTime from, RepeatSpec spec, EventDetails details) async { + final rule = RepeatRule(id: _newId('r'), spec: spec, from: dateOnly(from), details: details); + await _write(_read().addRule(rule)); + return rule; + } + + @override + Future updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details) async { + final updated = rule.copyWith(spec: spec, details: details); + await _write(_read().replaceRule(updated)); + return updated; + } + + @override + Future deleteRule(String ruleId) async { + final book = _read(); + final rule = book.rules.where((r) => r.id == ruleId).firstOrNull; + if (rule != null) await _write(book.delete(rule.instanceOn(rule.from))); + } + + @override + Future setDone(String occurrenceId, {required bool done}) async { + final book = _read(); + if (book.isDone(occurrenceId) != done) await _write(book.toggleDone(occurrenceId)); + } + + @override + Future dropTarget(String targetId) async => _write(_read().dropTarget(targetId)); } final scheduleRepositoryProvider = Provider((ref) { - return LocalScheduleRepository(ref.watch(sharedPreferencesProvider)); + return switch (ref.watch(dataModeProvider)) { + DataMode.local => LocalScheduleRepository(ref.watch(sharedPreferencesProvider)), + DataMode.api => ApiScheduleRepository(dio: ref.watch(dioProvider), session: ref.watch(sessionStoreProvider)), + }; }); -/// 자주 쓰는 일반 일정 목록 — 지우거나 더한 목록이 다음 일정 추가에도 남는다 +/// 자주 쓰는 일반 일정 목록 — 지우거나 더한 목록이 다음 일정 추가에도 남는다 (기기에 둔다) class CarePresetRepository { CarePresetRepository(this._prefs); diff --git a/flutter/lib/features/schedule/domain/schedule_book.dart b/flutter/lib/features/schedule/domain/schedule_book.dart index 7ddce45..91675b7 100644 --- a/flutter/lib/features/schedule/domain/schedule_book.dart +++ b/flutter/lib/features/schedule/domain/schedule_book.dart @@ -8,7 +8,7 @@ const int kUpcomingLookaheadDays = 90; const int kUpcomingLimit = 4; /// 일정 전체 — 날짜가 정해진 일정 + 끝없는 반복 규칙 + 완료 표시. -/// 바꾸는 메서드는 새 객체를 돌려준다 (저장은 호출한 쪽이 한다). +/// 바꾸는 메서드는 새 객체를 돌려준다 (저장은 저장소가 하고, 알림기가 결과를 여기에 반영한다). class ScheduleBook { const ScheduleBook({this.events = const [], this.rules = const [], this.doneIds = const {}}); @@ -78,41 +78,12 @@ class ScheduleBook { ScheduleBook addRule(RepeatRule rule) => copyWith(rules: [...rules, rule]); - /// 일정 내용 수정 (기간 설정 없이) — 반복 여부 전환까지 처리한다 (시안 updateEvent) - /// - 반복 일정 + [spec] 있음 → 규칙의 주기·내용을 바꾼다 - /// - 반복 일정 + [spec] 없음 → 반복을 끄고 보고 있던 날짜의 일정 하나로 남긴다 - /// - 일반 일정 + [spec] 있음 → 그 날짜부터 반복 규칙으로 바꾼다 - ScheduleBook update( - ScheduleEvent target, - EventDetails details, { - RepeatSpec? spec, - required String Function() newEventId, - required String Function() newRuleId, - }) { - final ruleId = target.ruleId; - if (ruleId != null) { - if (spec != null) { - return copyWith(rules: [ - for (final r in rules) r.id == ruleId ? r.copyWith(spec: spec, details: details) : r, - ]); - } - return copyWith( - rules: rules.where((r) => r.id != ruleId).toList(), - events: [...events, ScheduleEvent(id: newEventId(), date: target.date, details: details)], - doneIds: _withoutRule(ruleId), - ); - } - if (spec != null) { - return copyWith( - events: events.where((e) => e.id != target.id).toList(), - rules: [...rules, RepeatRule(id: newRuleId(), spec: spec, from: target.date, details: details)], - doneIds: {...doneIds}..remove(target.id), - ); - } - return copyWith(events: [ - for (final e in events) e.id == target.id ? e.copyWith(details: details) : e, - ]); - } + /// 날짜 일정 하나를 바꾼 것으로 대신한다 + ScheduleBook replaceEvent(ScheduleEvent event) => + copyWith(events: [for (final e in events) e.id == event.id ? event : e]); + + /// 반복 규칙 하나를 바꾼 것으로 대신한다 + ScheduleBook replaceRule(RepeatRule rule) => copyWith(rules: [for (final r in rules) r.id == rule.id ? rule : r]); /// 일정(반복이면 규칙 전체)을 지우고 [list] 로 대신한다 — 기간 설정으로 바꾼 수정 ScheduleBook replace(ScheduleEvent target, List list) => diff --git a/flutter/lib/features/schedule/domain/schedule_event.dart b/flutter/lib/features/schedule/domain/schedule_event.dart index ec69dfe..16f46dd 100644 --- a/flutter/lib/features/schedule/domain/schedule_event.dart +++ b/flutter/lib/features/schedule/domain/schedule_event.dart @@ -77,6 +77,9 @@ class EventDetails { ); } +/// 새로 만들 날짜 일정 한 건 — 날짜와 내용 (id 는 저장소가 붙인다) +typedef EventDraft = ({DateTime date, EventDetails details}); + /// 달력에 놓인 일정 하나 class ScheduleEvent { const ScheduleEvent({ diff --git a/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart b/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart index 38e3007..c013fb6 100644 --- a/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart +++ b/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:tma_app/core/api/api_guard.dart'; import 'package:tma_app/core/constants/app_colors.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; import 'package:tma_app/core/theme/app_typography.dart'; @@ -322,6 +323,9 @@ class _EventEditorState extends ConsumerState<_EventEditor> { toast.show('일정을 추가했어요'); } if (mounted) context.pop(); + } catch (e) { + // 실패 알림은 인터셉터가 띄웠다 — 화면을 열어 둔 채 다시 시도할 수 있게 한다 + if (!isReportedApiFailure(e)) rethrow; } finally { if (mounted) setState(() => _saving = false); } @@ -338,7 +342,7 @@ class _EventEditorState extends ConsumerState<_EventEditor> { if (!ok || !mounted) return; // 먼저 화면을 닫아 지워진 일정을 그리지 않게 한다 context.pop(); - await ref.read(scheduleProvider.notifier).delete(editing); + if (!await succeeded(ref.read(scheduleProvider.notifier).delete(editing))) return; ref.read(toastProvider.notifier).show(editing.isRepeat ? '반복 일정을 삭제했어요' : '일정을 삭제했어요'); } diff --git a/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart b/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart index cba2468..976cbfe 100644 --- a/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart +++ b/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/api/session_store.dart'; import 'package:tma_app/core/utils/date_format.dart'; import 'package:tma_app/features/schedule/data/schedule_repository.dart'; import 'package:tma_app/features/schedule/domain/care_preset.dart'; @@ -6,60 +7,99 @@ 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'; -/// 새로 만들 일정 한 건 — 날짜와 내용 (id 는 저장할 때 붙인다) -typedef EventDraft = ({DateTime date, EventDetails details}); - /// 일정 전체 (날짜 일정 + 반복 규칙 + 완료 표시) +/// 저장은 저장소가 건별로 하고, 돌려받은 결과(id 가 붙은 일정·규칙)를 [ScheduleBook] 에 반영한다. class ScheduleNotifier extends AsyncNotifier { ScheduleRepository get _repo => ref.read(scheduleRepositoryProvider); - int _serial = 0; @override - Future build() => ref.watch(scheduleRepositoryProvider).fetch(); + Future build() { + // 회원이 바뀌면(로그인·로그아웃) 다시 읽는다 + ref.watch(sessionEpochProvider); + return ref.watch(scheduleRepositoryProvider).fetch(); + } ScheduleBook get _book => state.value ?? ScheduleBook.empty; - /// 같은 순간에 여러 건을 만들어도 겹치지 않는 id - String _newId(String prefix) => '$prefix${DateTime.now().microsecondsSinceEpoch}_${_serial++}'; - - List _materialize(List drafts) => [ - for (final d in drafts) ScheduleEvent(id: _newId('e'), date: dateOnly(d.date), details: d.details), - ]; - - Future _commit(ScheduleBook next) async { - await _repo.saveEvents(next.events); - await _repo.saveRules(next.rules); - await _repo.saveDone(next.doneIds); + void _set(ScheduleBook next) { if (ref.mounted) state = AsyncData(next); } - Future addEvents(List drafts) => _commit(_book.addEvents(_materialize(drafts))); + Future addEvents(List drafts) async { + final created = await _repo.createEvents([ + for (final d in drafts) (date: dateOnly(d.date), details: d.details), + ]); + _set(_book.addEvents(created)); + } /// 끝나는 날 없는 반복 일정 - Future addRule(DateTime from, RepeatSpec spec, EventDetails details) => _commit( - _book.addRule(RepeatRule(id: _newId('r'), spec: spec, from: dateOnly(from), details: details)), - ); + Future addRule(DateTime from, RepeatSpec spec, EventDetails details) async { + _set(_book.addRule(await _repo.createRule(dateOnly(from), spec, details))); + } - Future edit(ScheduleEvent target, EventDetails details, {RepeatSpec? spec}) => _commit( - _book.update( - target, - details, - spec: spec, - newEventId: () => _newId('e'), - newRuleId: () => _newId('r'), - ), - ); + /// 일정 내용 수정 (기간 설정 없이) — 반복 여부 전환까지 처리한다 (시안 updateEvent) + /// - 반복 일정 + [spec] 있음 → 규칙의 주기·내용을 바꾼다 + /// - 반복 일정 + [spec] 없음 → 반복을 끄고 보고 있던 날짜의 일정 하나로 남긴다 (규칙의 완료 표시는 지운다) + /// - 일반 일정 + [spec] 있음 → 그 날짜부터 반복 규칙으로 바꾼다 + /// 바꾸는 일은 새 것을 먼저 만들고 옛것을 지운다 — 중간에 실패해도 일정이 사라지지 않는다. + Future edit(ScheduleEvent target, EventDetails details, {RepeatSpec? spec}) async { + final rule = _book.ruleOf(target); + if (rule != null) { + if (spec != null) { + _set(_book.replaceRule(await _repo.updateRule(rule, spec, details))); + return; + } + final created = await _repo.createEvents([(date: target.date, details: details)]); + await _repo.deleteRule(rule.id); + _set(_book.delete(target).addEvents(created)); + return; + } + if (spec != null) { + final created = await _repo.createRule(target.date, spec, details); + await _repo.deleteEvent(target.id); + _set(_book.delete(target).addRule(created)); + return; + } + _set(_book.replaceEvent(await _repo.updateEvent(target, details))); + } /// 기간 설정으로 바꾼 수정 — 원래 일정(반복이면 규칙 전체)을 새 목록으로 대신한다 - Future replace(ScheduleEvent target, List drafts) => - _commit(_book.replace(target, _materialize(drafts))); + Future replace(ScheduleEvent target, List drafts) async { + final created = await _repo.createEvents([ + for (final d in drafts) (date: dateOnly(d.date), details: d.details), + ]); + await _deleteTarget(target); + _set(_book.replace(target, created)); + } - Future delete(ScheduleEvent target) => _commit(_book.delete(target)); + /// 일정 삭제 — 반복 일정이면 규칙 전체를 지운다 + Future delete(ScheduleEvent target) async { + await _deleteTarget(target); + _set(_book.delete(target)); + } - Future toggleDone(String id) => _commit(_book.toggleDone(id)); + Future _deleteTarget(ScheduleEvent target) { + final ruleId = target.ruleId; + return ruleId == null ? _repo.deleteEvent(target.id) : _repo.deleteRule(ruleId); + } - /// 아이를 지우면 일정에서 그 아이만 뗀다 - Future dropTarget(String targetId) => _commit(_book.dropTarget(targetId)); + /// 완료 체크 — 화면에는 바로 반영하고 저장한다. 저장에 실패하면 되돌린다 (알림은 인터셉터가 띄운다) + Future toggleDone(String id) async { + final done = !_book.isDone(id); + _set(_book.toggleDone(id)); + try { + await _repo.setDone(id, done: done); + } on Exception { + if (_book.isDone(id) == done) _set(_book.toggleDone(id)); + rethrow; + } + } + + /// 아이를 지우면 일정에서 그 아이만 뗀다 (서버 모드는 서버가 이미 정리했다) + Future dropTarget(String targetId) async { + await _repo.dropTarget(targetId); + _set(_book.dropTarget(targetId)); + } } final scheduleProvider = AsyncNotifierProvider(ScheduleNotifier.new); diff --git a/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart b/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart index dc1c0e9..6f02750 100644 --- a/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart +++ b/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:tma_app/core/api/api_guard.dart'; import 'package:tma_app/core/constants/app_colors.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; import 'package:tma_app/core/router/app_routes.dart'; @@ -94,7 +95,7 @@ class _ScheduleViewState extends ConsumerState { event: e, done: book.isDone(e.id), onTap: () => unawaited(context.push(AppRoutes.eventEdit(e.id))), - onToggleDone: () => unawaited(ref.read(scheduleProvider.notifier).toggleDone(e.id)), + onToggleDone: () => unawaited(succeeded(ref.read(scheduleProvider.notifier).toggleDone(e.id))), ), ], ], diff --git a/flutter/test/api_schedule_repository_test.dart b/flutter/test/api_schedule_repository_test.dart new file mode 100644 index 0000000..812f0d5 --- /dev/null +++ b/flutter/test/api_schedule_repository_test.dart @@ -0,0 +1,150 @@ +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/data/api_schedule_repository.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_event.dart'; + +import 'support/fake_api.dart'; + +const EventDetails _details = EventDetails( + title: '병원', + kind: EventKind.reservation, + category: EventCategory.hospital, + targetIds: ['p1', 'self'], + start: HourMinute(10, 30), + end: HourMinute(11, 0), + place: '동물병원', + labelColor: Color(0xFFFF6B6B), +); + +Map _detailsJson() => { + 'title': '병원', + 'kind': 'reservation', + 'category': 'hospital', + 'targetIds': ['p1', 'self'], + 'start': '10:30', + 'end': '11:00', + 'place': '동물병원', + 'labelColor': '#FF6B6B', + }; + +Map _eventJson(String id, String date, {bool isDone = false}) => + {'id': id, 'date': date, 'isDone': isDone, ..._detailsJson()}; + +void main() { + late FakeApi api; + late ApiScheduleRepository repo; + + setUp(() { + api = FakeApi(); + repo = ApiScheduleRepository(dio: api.dio, session: api.session); + }); + + test('일정 내용은 서버 형식(HH:mm · #RRGGBB)으로 오가도 같다', () { + expect(eventDetailsToApi(_details), _detailsJson()); + + final back = eventDetailsFromApi(_detailsJson()); + expect(back.start, const HourMinute(10, 30)); + expect(back.labelColor, const Color(0xFFFF6B6B)); + expect(back.targetIds, ['p1', 'self']); + }); + + test('불러오기는 날짜 일정·반복 규칙·완료 표시를 하나의 일정 장부로 합친다', () async { + api.on('GET', '/schedule-events', (r) => ok(page([ + _eventJson('e1', '2026-10-12', isDone: true), + _eventJson('e2', '2026-10-13'), + ]))); + api.on('GET', '/repeat-rules', (r) => ok(page([ + { + 'id': 'r1', + 'from': '2026-10-11', + 'spec': {'mode': 'every', 'unit': 'week', 'n': 2}, + 'details': _detailsJson(), + }, + ]))); + api.on('GET', '/repeat-rule-completions', (r) => ok(page([ + {'ruleId': 'r1', 'date': '2026-10-11'}, + ]))); + + final book = await repo.fetch(); + + expect(book.events.map((e) => e.id), ['e1', 'e2']); + expect(book.rules.single.spec, const RepeatSpec(RepeatPattern.every, unit: RepeatUnit.week, n: 2)); + expect(book.doneIds, {'e1', 'r1@2026-10-11'}); + expect(book.eventsOn(DateTime(2026, 10, 25)).single.ruleId, 'r1'); + }); + + test('기간 설정처럼 많은 일정은 500건씩 나눠 보낸다', () async { + final sizes = []; + api.on('POST', '/schedule-events', (r) { + final events = r.json!['events'] as List; + sizes.add(events.length); + return ok([ + for (final (i, e) in events.indexed) _eventJson('e${sizes.length}-$i', (e as Map)['date'] as String), + ], status: 201); + }); + + final drafts = [ + for (var i = 0; i < 1001; i++) (date: DateTime(2026, 1, 1).add(Duration(days: i)), details: _details), + ]; + final created = await repo.createEvents(drafts); + + expect(sizes, [500, 500, 1]); + expect(created, hasLength(1001)); + expect(created.first.date, DateTime(2026, 1, 1)); + }); + + test('완료 표시는 날짜 일정과 반복 일정의 경로가 다르다', () async { + api.on('PUT', '/schedule-events/:id/completion', (r) => noContent); + api.on('DELETE', '/repeat-rules/:id/completions/:date', (r) { + expect(r.params, {'id': 'r1', 'date': '2026-10-11'}); + return noContent; + }); + + await repo.setDone('e1', done: true); + await repo.setDone(ScheduleEvent.instanceId('r1', DateTime(2026, 10, 11)), done: false); + + expect(api.sent('PUT', '/schedule-events/e1/completion'), hasLength(1)); + expect(api.sent('DELETE', '/repeat-rules/r1/completions/2026-10-11'), hasLength(1)); + }); + + test('반복 주기·내용 바꾸기는 시작한 날을 보내지 않는다', () async { + api.on('PUT', '/repeat-rules/:id', (r) { + expect(r.json!.keys, unorderedEquals(['spec', 'details'])); + return ok({ + 'id': 'r1', + 'from': '2026-10-11', + 'spec': {'mode': 'daily', 'unit': 'week', 'n': 1}, + 'details': _detailsJson(), + }); + }); + final rule = RepeatRule( + id: 'r1', + spec: const RepeatSpec(RepeatPattern.every), + from: DateTime(2026, 10, 11), + details: _details, + ); + + final updated = await repo.updateRule(rule, const RepeatSpec(RepeatPattern.daily), _details); + + expect(updated.spec.mode, RepeatPattern.daily); + }); + + test('아이를 지울 때 일정 정리는 서버가 이미 했다 — 아무것도 보내지 않는다', () async { + await repo.dropTarget('p1'); + + expect(api.requests, isEmpty); + }); + + test('로그인 전에는 불러오지 않는다', () async { + final signedOut = FakeApi(tokens: null); + + final book = await ApiScheduleRepository(dio: signedOut.dio, session: signedOut.session).fetch(); + + expect(book.events, isEmpty); + expect(signedOut.requests, isEmpty); + }); +} diff --git a/flutter/test/schedule_domain_test.dart b/flutter/test/schedule_domain_test.dart index 6dd677d..496b323 100644 --- a/flutter/test/schedule_domain_test.dart +++ b/flutter/test/schedule_domain_test.dart @@ -105,33 +105,7 @@ void main() { 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); - }); + // 일반 ↔ 반복 전환 규칙은 저장소를 거치므로 schedule_notifier_test 가 검사한다 test('반복 일정을 지우면 규칙 전체가 지워진다', () { final rule = RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('산책')); diff --git a/flutter/test/schedule_notifier_test.dart b/flutter/test/schedule_notifier_test.dart new file mode 100644 index 0000000..fb04f5d --- /dev/null +++ b/flutter/test/schedule_notifier_test.dart @@ -0,0 +1,168 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/core/config/app_config.dart'; +import 'package:tma_app/core/storage/preferences_provider.dart'; +import 'package:tma_app/features/schedule/data/schedule_repository.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'; +import 'package:tma_app/features/schedule/presentation/providers/schedule_providers.dart'; + +const EventDetails _walk = EventDetails( + title: '산책', + kind: EventKind.general, + category: EventCategory.walk, + targetIds: ['pet-1'], +); + +final DateTime _day = DateTime(2026, 9, 30); + +/// 저장소에 들어온 호출 순서를 기록한다 — 기기 저장소를 감싼다 +class _RecordingRepository implements ScheduleRepository { + _RecordingRepository(this._inner); + + final ScheduleRepository _inner; + final List calls = []; + bool failSetDone = false; + + @override + Future fetch() => _inner.fetch(); + + @override + Future> createEvents(List drafts) { + calls.add('createEvents'); + return _inner.createEvents(drafts); + } + + @override + Future updateEvent(ScheduleEvent event, EventDetails details) { + calls.add('updateEvent'); + return _inner.updateEvent(event, details); + } + + @override + Future deleteEvent(String eventId) { + calls.add('deleteEvent'); + return _inner.deleteEvent(eventId); + } + + @override + Future createRule(DateTime from, RepeatSpec spec, EventDetails details) { + calls.add('createRule'); + return _inner.createRule(from, spec, details); + } + + @override + Future updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details) { + calls.add('updateRule'); + return _inner.updateRule(rule, spec, details); + } + + @override + Future deleteRule(String ruleId) { + calls.add('deleteRule'); + return _inner.deleteRule(ruleId); + } + + @override + Future setDone(String occurrenceId, {required bool done}) { + if (failSetDone) throw Exception('저장 실패'); + return _inner.setDone(occurrenceId, done: done); + } + + @override + Future dropTarget(String targetId) => _inner.dropTarget(targetId); +} + +void main() { + late ProviderContainer container; + late _RecordingRepository repo; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + repo = _RecordingRepository(LocalScheduleRepository(prefs)); + container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + dataModeProvider.overrideWithValue(DataMode.local), + scheduleRepositoryProvider.overrideWithValue(repo), + ], + ); + addTearDown(container.dispose); + await container.read(scheduleProvider.future); + }); + + ScheduleNotifier notifier() => container.read(scheduleProvider.notifier); + ScheduleBook book() => container.read(scheduleBookProvider); + + test('일반 일정을 반복으로 바꾸면 그 날부터 규칙이 된다 — 규칙을 먼저 만들고 일정을 지운다', () async { + await notifier().addEvents([(date: _day, details: _walk)]); + final target = book().events.single; + repo.calls.clear(); + + await notifier().edit(target, _walk, spec: const RepeatSpec(RepeatPattern.daily)); + + expect(book().events, isEmpty); + expect(book().rules.single.from, _day); + expect(book().eventsOn(DateTime(2026, 10, 3)).single.details.title, '산책'); + expect(repo.calls, ['createRule', 'deleteEvent']); + // 저장소에도 같은 결과가 남는다 + final saved = await repo.fetch(); + expect(saved.events, isEmpty); + expect(saved.rules, hasLength(1)); + }); + + test('반복을 끄면 보고 있던 날짜의 일정 하나만 남고 그 규칙의 완료 표시는 지운다', () async { + await notifier().addRule(_day, const RepeatSpec(RepeatPattern.daily), _walk); + final instance = book().eventsOn(DateTime(2026, 10, 1)).single; + await notifier().toggleDone(instance.id); + expect(book().isDone(instance.id), isTrue); + repo.calls.clear(); + + await notifier().edit(instance, _walk); + + expect(book().rules, isEmpty); + expect(book().events.single.date, DateTime(2026, 10, 1)); + expect(book().doneIds, isEmpty); + expect(repo.calls, ['createEvents', 'deleteRule']); + }); + + test('반복 주기만 바꾸면 같은 규칙이 남고 시작한 날도 그대로다', () async { + await notifier().addRule(_day, const RepeatSpec(RepeatPattern.daily), _walk); + final instance = book().eventsOn(DateTime(2026, 10, 2)).single; + + await notifier().edit(instance, _walk, spec: const RepeatSpec(RepeatPattern.every, unit: RepeatUnit.day, n: 2)); + + final rule = book().rules.single; + expect(rule.id, instance.ruleId); + expect(rule.from, _day); + expect(rule.spec.label, '2일 마다'); + }); + + test('완료 체크를 저장하지 못하면 화면의 체크도 되돌린다', () async { + await notifier().addEvents([(date: _day, details: _walk)]); + final id = book().events.single.id; + repo.failSetDone = true; + + await expectLater(notifier().toggleDone(id), throwsException); + + expect(book().isDone(id), isFalse); + }); + + test('기간 설정으로 바꾸면 새 일정을 먼저 만들고 원래 일정을 지운다', () async { + await notifier().addEvents([(date: _day, details: _walk)]); + final target = book().events.single; + repo.calls.clear(); + + await notifier().replace(target, [ + (date: DateTime(2026, 10, 1), details: _walk), + (date: DateTime(2026, 10, 2), details: _walk), + ]); + + expect(book().events.map((e) => e.date.day), [1, 2]); + expect(repo.calls, ['createEvents', 'deleteEvent']); + }); +}