Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/data/api/api_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ class ApiRepository implements IeoseoRepository {
@override
Future<void> deleteEvent(String id) => _client.delete('/events/$id');

@override
Future<DkEvent> completeEvent(String id) async {
final dynamic data = await _client.post('/events/$id/complete');
return DkEventDto.fromJson(_asMap(data));
}

@override
Future<DkEvent> reopenEvent(String id) async {
final dynamic data = await _client.post('/events/$id/reopen');
return DkEventDto.fromJson(_asMap(data));
}

// ── Tasks ───────────────────────────────────────────────
@override
Future<List<DkTask>> tasks({String? date}) async {
Expand Down
1 change: 1 addition & 0 deletions lib/data/api/dtos.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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/파생계산은 보내지 않는다.
Expand Down
29 changes: 29 additions & 0 deletions lib/data/data_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,35 @@ class DataController extends ChangeNotifier {
}
}

/// 이벤트 종료(완료) 처리. 자동 삭제 대신 유저 명시 액션(FRD 5.1).
Future<DkEvent> completeEvent(String id) => _setEventCompleted(id, true);

/// 종료 취소(재개).
Future<DkEvent> reopenEvent(String id) => _setEventCompleted(id, false);

Future<DkEvent> _setEventCompleted(String id, bool completed) async {
final List<DkEvent> 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<DkDebt> carryDebt(String id, {required String toDate}) async {
Expand Down
7 changes: 7 additions & 0 deletions lib/data/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ class DkEvent {
this.memo = '',
this.color = 'cool',
this.remindDays = const <int>[],
this.completed = false,
});

final String id;
Expand All @@ -209,6 +210,10 @@ class DkEvent {
final String color;
final List<int> remindDays;

/// 종료(완료) 처리 여부. true 면 홈 "다가오는 일정"에서 숨긴다(server `completed`).
/// 마감/기간이 지나도 자동 삭제하지 않고, 유저가 명시적으로 종료할 때만 true.
final bool completed;

/// nullable 날짜 필드(date/start/end)는 센티넬([_unset])로 받아 **null 로 비우기**도
/// 가능하게 한다(`field ?? this.field` 패턴은 null 설정을 못 함). 예: T1↔T2 전환 시 date 비우기.
DkEvent copyWith({
Expand All @@ -223,6 +228,7 @@ class DkEvent {
String? memo,
String? color,
List<int>? remindDays,
bool? completed,
}) {
return DkEvent(
id: id ?? this.id,
Expand All @@ -236,6 +242,7 @@ class DkEvent {
memo: memo ?? this.memo,
color: color ?? this.color,
remindDays: remindDays ?? this.remindDays,
completed: completed ?? this.completed,
);
}
}
Expand Down
21 changes: 21 additions & 0 deletions lib/data/repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ abstract class IeoseoRepository {
/// 이벤트 삭제(server: DELETE /events/{id} → 204).
Future<void> deleteEvent(String id);

/// 종료(완료) 처리(server: POST /events/{id}/complete). 갱신된 이벤트 반환.
/// 마감/기간이 지나도 자동 삭제하지 않고, 유저가 명시적으로 종료할 때 호출.
Future<DkEvent> completeEvent(String id);

/// 종료 취소(재개, server: POST /events/{id}/reopen).
Future<DkEvent> reopenEvent(String id);

// ── Tasks ───────────────────────────────────────────────
/// 태스크 목록(server: GET /tasks). [date] 지정 시 해당 일자만.
Future<List<DkTask>> tasks({String? date});
Expand Down Expand Up @@ -141,6 +148,20 @@ class MockRepository implements IeoseoRepository {
_events = _events.where((DkEvent e) => e.id != id).toList();
}

@override
Future<DkEvent> completeEvent(String id) async => _setCompleted(id, true);

@override
Future<DkEvent> 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<List<DkTask>> tasks({String? date}) async {
final Iterable<DkTask> all = date == null
Expand Down
13 changes: 13 additions & 0 deletions lib/screens/main_scaffold.dart
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,19 @@ class _MainScaffoldState extends State<MainScaffold>
),
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,
),
);
}

Expand Down
84 changes: 63 additions & 21 deletions lib/screens/sheets/event_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class EventSheetBody extends StatefulWidget {
required this.onClose,
this.onDelete,
this.onSubmit,
this.onComplete,
});

final DkEvent? event;
Expand All @@ -34,6 +35,9 @@ class EventSheetBody extends StatefulWidget {
/// 추가/저장 제출. 폼 값으로 만든 초안(신규는 id 빈 문자열)을 전달한다.
final ValueChanged<DkEvent>? onSubmit;

/// 종료(완료) 토글. 현재 이벤트를 전달하면 호출부가 `completed` 에 따라 종료/재개한다.
final ValueChanged<DkEvent>? onComplete;

@override
State<EventSheetBody> createState() => _EventSheetBodyState();
}
Expand Down Expand Up @@ -383,30 +387,66 @@ class _EventSheetBodyState extends State<EventSheetBody> {
}

Widget _stickyActions(DkTokens t, DkEvent? ev) {
return Row(
final bool isCompleted = ev?.completed ?? false;
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (!widget.isNew && ev != null) ...<Widget>[
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) ...<Widget>[
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: <Widget>[
if (!widget.isNew && ev != null) ...<Widget>[
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 ? '추가하기' : '저장'),
),
),
],
),
],
);
Expand All @@ -420,6 +460,7 @@ Future<void> showEventSheet(
required bool isNew,
ValueChanged<DkEvent>? onDelete,
ValueChanged<DkEvent>? onSubmit,
ValueChanged<DkEvent>? onComplete,
}) {
return showDkSheet<void>(
context,
Expand All @@ -432,6 +473,7 @@ Future<void> showEventSheet(
onClose: () => Navigator.of(sheetContext).maybePop(),
onDelete: onDelete,
onSubmit: onSubmit,
onComplete: onComplete,
),
),
);
Expand Down
5 changes: 4 additions & 1 deletion lib/screens/today/today_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ class TodayScreen extends StatelessWidget {

@override
Widget build(BuildContext context) {
final List<DkEvent> ordered = ddayOrdered(events);
// 종료(완료) 처리한 이벤트는 홈에서 숨긴다(미종료는 D+ 로 계속 노출). FRD 5.1.
final List<DkEvent> 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)
Expand Down
40 changes: 40 additions & 0 deletions test/data_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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: <DkTask>[],
events: <DkEvent>[e1],
debts: <DkDebt>[],
),
);
await c.load();

await c.completeEvent('e1');

expect(c.events.single.completed, isTrue);
});

test('종료 취소하면 completed=false 로 되돌린다', () async {
final DataController c = DataController(
MockRepository(
tasks: <DkTask>[],
events: <DkEvent>[e1.copyWith(completed: true)],
debts: <DkDebt>[],
),
);
await c.load();

await c.reopenEvent('e1');

expect(c.events.single.completed, isFalse);
});
});

group('autoCarryDebt (F6 낙관·롤백)', () {
test('낙관적으로 assigned 표시 후 실패 시 원래 상태로 롤백한다', () async {
final DataController c = DataController(_FailingAutoCarryRepo());
Expand Down
22 changes: 22 additions & 0 deletions test/today_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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: <DkEvent>[done, live]));

expect(find.text('끝난 일정'), findsNothing);
expect(find.text('남은 일정'), findsOneWidget);
});
}
Loading