feat(flutter): 일정을 서버에 저장 (ApiScheduleRepository)

일정 저장소가 "목록 전체 저장" 이라 서버에 맞지 않아 건별 계약으로 바꿨다.

- 저장소: 날짜 일정 여러 건 만들기·내용 바꾸기·삭제, 반복 규칙 만들기·바꾸기·삭제, 완료 표시, 대상 떼기.
  돌려받은 결과(id 가 붙은 일정·규칙)를 알림기가 ScheduleBook 에 반영한다
- 일반↔반복 전환은 알림기가 저장소 호출로 풀고, 새 것을 먼저 만든 뒤 옛것을 지운다(중간 실패에도 일정이 남는다).
  ScheduleBook.update 는 지우고 같은 규칙을 알림기 테스트로 옮겼다
- 서버 저장소: 불러올 때 날짜 일정·반복 규칙·완료 표시를 합치고, 기간 설정은 500건씩 나눠 보낸다.
  아이 삭제·보관 때의 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다
- 완료 체크는 화면에 먼저 반영하고 저장에 실패하면 되돌린다
- 일정·아이 저장 화면은 실패하면(알림은 인터셉터) 입력을 둔 채 머문다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 18:23:55 +09:00
parent 100dbe3a35
commit 60efc65ee0
13 changed files with 681 additions and 119 deletions
+7
View File
@@ -16,3 +16,10 @@ Future<bool> succeeded(Future<void> 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);
@@ -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)),
],
@@ -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);
@@ -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<String, Object?> 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<String, Object?> 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<String>().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<String, Object?> json) => ScheduleEvent(
id: json['id']! as String,
date: parseIsoDate(json['date']! as String),
details: eventDetailsFromApi(json),
);
RepeatSpec repeatSpecFromApi(Map<String, Object?> 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<String, Object?> json) => RepeatRule(
id: json['id']! as String,
from: parseIsoDate(json['from']! as String),
spec: repeatSpecFromApi(json['spec']! as Map<String, Object?>),
details: eventDetailsFromApi(json['details']! as Map<String, Object?>),
);
Map<String, Object?> _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<ScheduleBook> fetch() async {
if (!_session.hasSession) return ScheduleBook.empty;
final done = <String>{};
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<List<ScheduleEvent>> createEvents(List<EventDraft> drafts) async {
final created = <ScheduleEvent>[];
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<List<dynamic>>(
_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<String, Object?>)));
}
return created;
}
@override
Future<ScheduleEvent> updateEvent(ScheduleEvent event, EventDetails details) async {
final response = await _dio.put<Map<String, dynamic>>(
'$_kEventsPath/${event.id}',
data: eventDetailsToApi(details),
);
return scheduleEventFromApi(response.data!);
}
@override
Future<void> deleteEvent(String eventId) => _dio.delete<void>('$_kEventsPath/$eventId');
@override
Future<RepeatRule> createRule(DateTime from, RepeatSpec spec, EventDetails details) async {
final response = await _dio.post<Map<String, dynamic>>(
_kRulesPath,
data: {'from': toIsoDate(from), 'spec': _specToApi(spec), 'details': eventDetailsToApi(details)},
);
return repeatRuleFromApi(response.data!);
}
@override
Future<RepeatRule> updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details) async {
final response = await _dio.put<Map<String, dynamic>>(
'$_kRulesPath/${rule.id}',
data: {'spec': _specToApi(spec), 'details': eventDetailsToApi(details)},
);
return repeatRuleFromApi(response.data!);
}
@override
Future<void> deleteRule(String ruleId) => _dio.delete<void>('$_kRulesPath/$ruleId');
@override
Future<void> 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<void>(path) : _dio.delete<void>(path);
}
/// 서버가 아이를 지우거나 보관하면서 이미 일정에서 그 아이를 뗐다
@override
Future<void> dropTarget(String targetId) async {}
}
@@ -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<ScheduleBook> fetch();
Future<void> saveEvents(List<ScheduleEvent> events);
/// 날짜 일정 여러 건 — id 를 붙여 보낸 순서대로 돌려준다
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts);
Future<void> saveRules(List<RepeatRule> rules);
/// 일정 내용 바꾸기 — 날짜·완료 표시는 그대로
Future<ScheduleEvent> updateEvent(ScheduleEvent event, EventDetails details);
Future<void> saveDone(Set<String> doneIds);
Future<void> deleteEvent(String eventId);
/// 끝나는 날 없는 반복 일정
Future<RepeatRule> createRule(DateTime from, RepeatSpec spec, EventDetails details);
/// 반복 주기·내용 바꾸기 — 시작한 날은 그대로
Future<RepeatRule> updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details);
/// 반복 일정 삭제 — 규칙 전체와 날짜별 완료 표시
Future<void> deleteRule(String ruleId);
/// 완료 표시 — 날짜 일정은 일정 id, 반복 일정은 "규칙id@날짜"
Future<void> setDone(String occurrenceId, {required bool done});
/// 아이를 지우거나 보관했을 때 일정에서 그 아이를 뗀다.
/// 서버 저장소는 할 일이 없다 — 서버가 아이를 지우거나 보관하면서 이미 정리했다.
Future<void> dropTarget(String targetId);
}
const String _kEventsKey = 'schedule.events.v1';
@@ -27,36 +51,94 @@ const String _kCaresKey = 'schedule.cares.v1';
List<Map<String, Object?>> _decodeList(String? raw) =>
raw == null ? const [] : (jsonDecode(raw) as List<Object?>).whereType<Map<String, Object?>>().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<ScheduleBook> 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<void> saveEvents(List<ScheduleEvent> events) =>
_prefs.setString(_kEventsKey, jsonEncode([for (final e in events) e.toJson()]));
Future<void> _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<void> saveRules(List<RepeatRule> rules) =>
_prefs.setString(_kRulesKey, jsonEncode([for (final r in rules) r.toJson()]));
Future<ScheduleBook> fetch() async => _read();
@override
Future<void> saveDone(Set<String> doneIds) => _prefs.setStringList(_kDoneKey, doneIds.toList());
Future<List<ScheduleEvent>> createEvents(List<EventDraft> 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<ScheduleEvent> updateEvent(ScheduleEvent event, EventDetails details) async {
final updated = event.copyWith(details: details);
await _write(_read().replaceEvent(updated));
return updated;
}
@override
Future<void> 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<RepeatRule> 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<RepeatRule> 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<void> 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<void> setDone(String occurrenceId, {required bool done}) async {
final book = _read();
if (book.isDone(occurrenceId) != done) await _write(book.toggleDone(occurrenceId));
}
@override
Future<void> dropTarget(String targetId) async => _write(_read().dropTarget(targetId));
}
final scheduleRepositoryProvider = Provider<ScheduleRepository>((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);
@@ -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<ScheduleEvent> list) =>
@@ -77,6 +77,9 @@ class EventDetails {
);
}
/// 새로 만들 날짜 일정 한 건 — 날짜와 내용 (id 는 저장소가 붙인다)
typedef EventDraft = ({DateTime date, EventDetails details});
/// 달력에 놓인 일정 하나
class ScheduleEvent {
const ScheduleEvent({
@@ -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 ? '반복 일정을 삭제했어요' : '일정을 삭제했어요');
}
@@ -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<ScheduleBook> {
ScheduleRepository get _repo => ref.read(scheduleRepositoryProvider);
int _serial = 0;
@override
Future<ScheduleBook> build() => ref.watch(scheduleRepositoryProvider).fetch();
Future<ScheduleBook> 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<ScheduleEvent> _materialize(List<EventDraft> drafts) => [
for (final d in drafts) ScheduleEvent(id: _newId('e'), date: dateOnly(d.date), details: d.details),
];
Future<void> _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<void> addEvents(List<EventDraft> drafts) => _commit(_book.addEvents(_materialize(drafts)));
Future<void> addEvents(List<EventDraft> drafts) async {
final created = await _repo.createEvents([
for (final d in drafts) (date: dateOnly(d.date), details: d.details),
]);
_set(_book.addEvents(created));
}
/// 끝나는 날 없는 반복 일정
Future<void> addRule(DateTime from, RepeatSpec spec, EventDetails details) => _commit(
_book.addRule(RepeatRule(id: _newId('r'), spec: spec, from: dateOnly(from), details: details)),
);
Future<void> addRule(DateTime from, RepeatSpec spec, EventDetails details) async {
_set(_book.addRule(await _repo.createRule(dateOnly(from), spec, details)));
}
Future<void> 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<void> 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<void> replace(ScheduleEvent target, List<EventDraft> drafts) =>
_commit(_book.replace(target, _materialize(drafts)));
Future<void> replace(ScheduleEvent target, List<EventDraft> 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<void> delete(ScheduleEvent target) => _commit(_book.delete(target));
/// 일정 삭제 — 반복 일정이면 규칙 전체를 지운다
Future<void> delete(ScheduleEvent target) async {
await _deleteTarget(target);
_set(_book.delete(target));
}
Future<void> toggleDone(String id) => _commit(_book.toggleDone(id));
Future<void> _deleteTarget(ScheduleEvent target) {
final ruleId = target.ruleId;
return ruleId == null ? _repo.deleteEvent(target.id) : _repo.deleteRule(ruleId);
}
/// 아이를 지우면 일정에서 그 아이만 뗀다
Future<void> dropTarget(String targetId) => _commit(_book.dropTarget(targetId));
/// 완료 체크 — 화면에는 바로 반영하고 저장한다. 저장에 실패하면 되돌린다 (알림은 인터셉터가 띄운다)
Future<void> 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<void> dropTarget(String targetId) async {
await _repo.dropTarget(targetId);
_set(_book.dropTarget(targetId));
}
}
final scheduleProvider = AsyncNotifierProvider<ScheduleNotifier, ScheduleBook>(ScheduleNotifier.new);
@@ -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<ScheduleView> {
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))),
),
],
],
@@ -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<String, Object?> _detailsJson() => {
'title': '병원',
'kind': 'reservation',
'category': 'hospital',
'targetIds': ['p1', 'self'],
'start': '10:30',
'end': '11:00',
'place': '동물병원',
'labelColor': '#FF6B6B',
};
Map<String, Object?> _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 = <int>[];
api.on('POST', '/schedule-events', (r) {
final events = r.json!['events'] as List<dynamic>;
sizes.add(events.length);
return ok([
for (final (i, e) in events.indexed) _eventJson('e${sizes.length}-$i', (e as Map<String, dynamic>)['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);
});
}
+1 -27
View File
@@ -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('산책'));
+168
View File
@@ -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<String> calls = [];
bool failSetDone = false;
@override
Future<ScheduleBook> fetch() => _inner.fetch();
@override
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts) {
calls.add('createEvents');
return _inner.createEvents(drafts);
}
@override
Future<ScheduleEvent> updateEvent(ScheduleEvent event, EventDetails details) {
calls.add('updateEvent');
return _inner.updateEvent(event, details);
}
@override
Future<void> deleteEvent(String eventId) {
calls.add('deleteEvent');
return _inner.deleteEvent(eventId);
}
@override
Future<RepeatRule> createRule(DateTime from, RepeatSpec spec, EventDetails details) {
calls.add('createRule');
return _inner.createRule(from, spec, details);
}
@override
Future<RepeatRule> updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details) {
calls.add('updateRule');
return _inner.updateRule(rule, spec, details);
}
@override
Future<void> deleteRule(String ruleId) {
calls.add('deleteRule');
return _inner.deleteRule(ruleId);
}
@override
Future<void> setDone(String occurrenceId, {required bool done}) {
if (failSetDone) throw Exception('저장 실패');
return _inner.setDone(occurrenceId, done: done);
}
@override
Future<void> 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']);
});
}