diff --git a/lib/data/api/api_repository.dart b/lib/data/api/api_repository.dart index bdd7ad5..8dade9d 100644 --- a/lib/data/api/api_repository.dart +++ b/lib/data/api/api_repository.dart @@ -57,6 +57,18 @@ class ApiRepository implements IeoseoRepository { @override Future deleteEvent(String id) => _client.delete('/events/$id'); + @override + Future completeEvent(String id) async { + final dynamic data = await _client.post('/events/$id/complete'); + return DkEventDto.fromJson(_asMap(data)); + } + + @override + Future reopenEvent(String id) async { + final dynamic data = await _client.post('/events/$id/reopen'); + return DkEventDto.fromJson(_asMap(data)); + } + // ── Tasks ─────────────────────────────────────────────── @override Future> tasks({String? date}) async { diff --git a/lib/data/api/dtos.dart b/lib/data/api/dtos.dart index ab3e6d1..b482d16 100644 --- a/lib/data/api/dtos.dart +++ b/lib/data/api/dtos.dart @@ -68,6 +68,7 @@ abstract final class DkEventDto { pinned: json['pinned'] == true, memo: _str(json['memo']) ?? '', color: _str(json['color']) ?? 'cool', + completed: json['completed'] == true, ); /// [DkEvent] → 생성 요청 본문(EventCreateRequest). id/파생계산은 보내지 않는다. diff --git a/lib/data/data_controller.dart b/lib/data/data_controller.dart index df3d9fb..e77b7a8 100644 --- a/lib/data/data_controller.dart +++ b/lib/data/data_controller.dart @@ -147,6 +147,35 @@ class DataController extends ChangeNotifier { } } + /// 이벤트 종료(완료) 처리. 자동 삭제 대신 유저 명시 액션(FRD 5.1). + Future completeEvent(String id) => _setEventCompleted(id, true); + + /// 종료 취소(재개). + Future reopenEvent(String id) => _setEventCompleted(id, false); + + Future _setEventCompleted(String id, bool completed) async { + final List snapshot = _events; + // 낙관적 업데이트 후 server 응답으로 교체, 실패 시 롤백. + _events = _events + .map((DkEvent e) => e.id == id ? e.copyWith(completed: completed) : e) + .toList(); + notifyListeners(); + try { + final DkEvent updated = completed + ? await _repo.completeEvent(id) + : await _repo.reopenEvent(id); + _events = _events + .map((DkEvent e) => e.id == updated.id ? updated : e) + .toList(); + notifyListeners(); + return updated; + } on Exception { + _events = snapshot; + notifyListeners(); + rethrow; + } + } + // ── Debts ─────────────────────────────────────────────── Future carryDebt(String id, {required String toDate}) async { diff --git a/lib/data/models.dart b/lib/data/models.dart index 5e395fa..9303eb6 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -186,6 +186,7 @@ class DkEvent { this.memo = '', this.color = 'cool', this.remindDays = const [], + this.completed = false, }); final String id; @@ -209,6 +210,10 @@ class DkEvent { final String color; final List remindDays; + /// 종료(완료) 처리 여부. true 면 홈 "다가오는 일정"에서 숨긴다(server `completed`). + /// 마감/기간이 지나도 자동 삭제하지 않고, 유저가 명시적으로 종료할 때만 true. + final bool completed; + /// nullable 날짜 필드(date/start/end)는 센티넬([_unset])로 받아 **null 로 비우기**도 /// 가능하게 한다(`field ?? this.field` 패턴은 null 설정을 못 함). 예: T1↔T2 전환 시 date 비우기. DkEvent copyWith({ @@ -223,6 +228,7 @@ class DkEvent { String? memo, String? color, List? remindDays, + bool? completed, }) { return DkEvent( id: id ?? this.id, @@ -236,6 +242,7 @@ class DkEvent { memo: memo ?? this.memo, color: color ?? this.color, remindDays: remindDays ?? this.remindDays, + completed: completed ?? this.completed, ); } } diff --git a/lib/data/repository.dart b/lib/data/repository.dart index 62f47e8..cbcc0c4 100644 --- a/lib/data/repository.dart +++ b/lib/data/repository.dart @@ -23,6 +23,13 @@ abstract class IeoseoRepository { /// 이벤트 삭제(server: DELETE /events/{id} → 204). Future deleteEvent(String id); + /// 종료(완료) 처리(server: POST /events/{id}/complete). 갱신된 이벤트 반환. + /// 마감/기간이 지나도 자동 삭제하지 않고, 유저가 명시적으로 종료할 때 호출. + Future completeEvent(String id); + + /// 종료 취소(재개, server: POST /events/{id}/reopen). + Future reopenEvent(String id); + // ── Tasks ─────────────────────────────────────────────── /// 태스크 목록(server: GET /tasks). [date] 지정 시 해당 일자만. Future> tasks({String? date}); @@ -141,6 +148,20 @@ class MockRepository implements IeoseoRepository { _events = _events.where((DkEvent e) => e.id != id).toList(); } + @override + Future completeEvent(String id) async => _setCompleted(id, true); + + @override + Future reopenEvent(String id) async => _setCompleted(id, false); + + DkEvent _setCompleted(String id, bool completed) { + final DkEvent updated = _events + .firstWhere((DkEvent e) => e.id == id) + .copyWith(completed: completed); + _events = _events.map((DkEvent e) => e.id == id ? updated : e).toList(); + return updated; + } + @override Future> tasks({String? date}) async { final Iterable all = date == null diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index e594349..e328583 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -310,6 +310,19 @@ class _MainScaffoldState extends State ), onSubmit: (DkEvent draft) => _run(() => _c.updateEvent(draft), success: '이벤트를 저장했어요'), + onComplete: (DkEvent e) => e.completed + ? _run( + () => _c.reopenEvent(e.id), + success: '종료를 취소했어요', + successIcon: 'repeat', + successTone: DkTone.info, + ) + : _run( + () => _c.completeEvent(e.id), + success: '일정을 종료했어요', + successIcon: 'check', + successTone: DkTone.success, + ), ); } diff --git a/lib/screens/sheets/event_sheet.dart b/lib/screens/sheets/event_sheet.dart index dd548b1..21f3a97 100644 --- a/lib/screens/sheets/event_sheet.dart +++ b/lib/screens/sheets/event_sheet.dart @@ -24,6 +24,7 @@ class EventSheetBody extends StatefulWidget { required this.onClose, this.onDelete, this.onSubmit, + this.onComplete, }); final DkEvent? event; @@ -34,6 +35,9 @@ class EventSheetBody extends StatefulWidget { /// 추가/저장 제출. 폼 값으로 만든 초안(신규는 id 빈 문자열)을 전달한다. final ValueChanged? onSubmit; + /// 종료(완료) 토글. 현재 이벤트를 전달하면 호출부가 `completed` 에 따라 종료/재개한다. + final ValueChanged? onComplete; + @override State createState() => _EventSheetBodyState(); } @@ -383,30 +387,66 @@ class _EventSheetBodyState extends State { } Widget _stickyActions(DkTokens t, DkEvent? ev) { - return Row( + final bool isCompleted = ev?.completed ?? false; + return Column( + mainAxisSize: MainAxisSize.min, children: [ - if (!widget.isNew && ev != null) ...[ - DkButton( - size: DkButtonSize.lg, - variant: DkButtonVariant.danger, - onPressed: () { - widget.onDelete?.call(ev); - widget.onClose(); - }, - leading: DkIcon('trash', size: 18, color: t.danger, strokeWidth: 2), + // 종료(완료) 토글 — 기존 이벤트만. 자동 삭제 대신 유저가 명시적으로 종료(FRD 5.1). + if (!widget.isNew && + ev != null && + widget.onComplete != null) ...[ + SizedBox( + width: double.infinity, + child: DkButton( + size: DkButtonSize.lg, + full: true, + variant: DkButtonVariant.outline, + onPressed: () { + widget.onComplete!.call(ev); + widget.onClose(); + }, + leading: DkIcon( + isCompleted ? 'repeat' : 'check', + size: 18, + color: t.fg, + strokeWidth: 2, + ), + child: Text(isCompleted ? '종료 취소' : '종료 처리'), + ), ), - const SizedBox(width: 10), + const SizedBox(height: 10), ], - Expanded( - child: DkButton( - size: DkButtonSize.lg, - full: true, - onPressed: () { - widget.onSubmit?.call(_draft()); - widget.onClose(); - }, - child: Text(widget.isNew ? '추가하기' : '저장'), - ), + Row( + children: [ + if (!widget.isNew && ev != null) ...[ + DkButton( + size: DkButtonSize.lg, + variant: DkButtonVariant.danger, + onPressed: () { + widget.onDelete?.call(ev); + widget.onClose(); + }, + leading: DkIcon( + 'trash', + size: 18, + color: t.danger, + strokeWidth: 2, + ), + ), + const SizedBox(width: 10), + ], + Expanded( + child: DkButton( + size: DkButtonSize.lg, + full: true, + onPressed: () { + widget.onSubmit?.call(_draft()); + widget.onClose(); + }, + child: Text(widget.isNew ? '추가하기' : '저장'), + ), + ), + ], ), ], ); @@ -420,6 +460,7 @@ Future showEventSheet( required bool isNew, ValueChanged? onDelete, ValueChanged? onSubmit, + ValueChanged? onComplete, }) { return showDkSheet( context, @@ -432,6 +473,7 @@ Future showEventSheet( onClose: () => Navigator.of(sheetContext).maybePop(), onDelete: onDelete, onSubmit: onSubmit, + onComplete: onComplete, ), ), ); diff --git a/lib/screens/today/today_screen.dart b/lib/screens/today/today_screen.dart index 9a3f5d8..889b363 100644 --- a/lib/screens/today/today_screen.dart +++ b/lib/screens/today/today_screen.dart @@ -45,7 +45,10 @@ class TodayScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final List ordered = ddayOrdered(events); + // 종료(완료) 처리한 이벤트는 홈에서 숨긴다(미종료는 D+ 로 계속 노출). FRD 5.1. + final List ordered = ddayOrdered( + events.where((DkEvent e) => !e.completed).toList(growable: false), + ); final int debtTotal = debts.fold(0, (int s, DkDebt d) => s + d.mins); final int debtOverdue = debts .where((DkDebt d) => d.status == DkDebtStatus.overdue) diff --git a/test/data_controller_test.dart b/test/data_controller_test.dart index 56573c8..ed7e6cd 100644 --- a/test/data_controller_test.dart +++ b/test/data_controller_test.dart @@ -128,6 +128,46 @@ void main() { }); }); + group('completeEvent / reopenEvent (종료 처리)', () { + const DkEvent e1 = DkEvent( + id: 'e1', + type: DkEventType.single, + title: '시험', + category: '자격증', + date: '2026-08-02', + ); + + test('종료 처리하면 completed=true 로 반영된다', () async { + final DataController c = DataController( + MockRepository( + tasks: [], + events: [e1], + debts: [], + ), + ); + await c.load(); + + await c.completeEvent('e1'); + + expect(c.events.single.completed, isTrue); + }); + + test('종료 취소하면 completed=false 로 되돌린다', () async { + final DataController c = DataController( + MockRepository( + tasks: [], + events: [e1.copyWith(completed: true)], + debts: [], + ), + ); + await c.load(); + + await c.reopenEvent('e1'); + + expect(c.events.single.completed, isFalse); + }); + }); + group('autoCarryDebt (F6 낙관·롤백)', () { test('낙관적으로 assigned 표시 후 실패 시 원래 상태로 롤백한다', () async { final DataController c = DataController(_FailingAutoCarryRepo()); diff --git a/test/today_test.dart b/test/today_test.dart index cbbdf72..95b2b40 100644 --- a/test/today_test.dart +++ b/test/today_test.dart @@ -69,4 +69,26 @@ void main() { await tester.tap(find.text('미룬 시간')); expect(opened, true); }); + + testWidgets('종료(완료) 처리한 이벤트는 홈에서 숨긴다', (WidgetTester tester) async { + const DkEvent done = DkEvent( + id: 'done', + type: DkEventType.single, + title: '끝난 일정', + category: '자격증', + date: '2026-12-01', + completed: true, + ); + const DkEvent live = DkEvent( + id: 'live', + type: DkEventType.single, + title: '남은 일정', + category: '자격증', + date: '2026-12-02', + ); + await _pumpTall(tester, _screen(events: [done, live])); + + expect(find.text('끝난 일정'), findsNothing); + expect(find.text('남은 일정'), findsOneWidget); + }); }