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))),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user