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))),
),
],
],