generated from ttipo/project-base
feat(flutter): 일정을 서버에 저장 (ApiScheduleRepository)
일정 저장소가 "목록 전체 저장" 이라 서버에 맞지 않아 건별 계약으로 바꿨다. - 저장소: 날짜 일정 여러 건 만들기·내용 바꾸기·삭제, 반복 규칙 만들기·바꾸기·삭제, 완료 표시, 대상 떼기. 돌려받은 결과(id 가 붙은 일정·규칙)를 알림기가 ScheduleBook 에 반영한다 - 일반↔반복 전환은 알림기가 저장소 호출로 풀고, 새 것을 먼저 만든 뒤 옛것을 지운다(중간 실패에도 일정이 남는다). ScheduleBook.update 는 지우고 같은 규칙을 알림기 테스트로 옮겼다 - 서버 저장소: 불러올 때 날짜 일정·반복 규칙·완료 표시를 합치고, 기간 설정은 500건씩 나눠 보낸다. 아이 삭제·보관 때의 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다 - 완료 체크는 화면에 먼저 반영하고 저장에 실패하면 되돌린다 - 일정·아이 저장 화면은 실패하면(알림은 인터셉터) 입력을 둔 채 머문다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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('산책'));
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user