From 1d01130c9d83ec509ebb0340b10d7b751695dc27 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 10 Nov 2026 19:37:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EC=84=A4=EC=A0=95=20=E2=80=BA?= =?UTF-8?q?=20=EC=95=B1=20=EC=A0=95=EB=B3=B4=EC=97=90=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=82=B4=EC=97=AD(=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=E2=86=92=20=EA=B8=80)=EC=9D=84=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 버전 다음 줄에서 목록(제목 · 날짜, [더 보기])으로 들어가 줄을 누르면 글(날짜 · 제목 · 묶음, 다음 글 · 이전 글 줄)이 열린다. 없는 글 · 받지 못함 · 글 없음 상태를 두고, 날짜는 달력 날짜 글이라 시각으로 바꾸지 않는다(CalendarDate). 다른 글 줄은 지금 글을 바꿔 끼워 뒤로는 늘 목록이다. Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/theme/app_spacing.dart | 14 + flutter/lib/core/utils/calendar_date.dart | 53 ++ flutter/lib/core/utils/korean_format.dart | 8 + flutter/lib/features/settings/data/.gitkeep | 0 .../settings/data/models/release_note.dart | 190 +++++++ .../release_notes_repository.dart | 66 +++ .../presentation/pages/release_note_page.dart | 428 ++++++++++++++++ .../pages/release_notes_page.dart | 350 +++++++++++++ .../presentation/pages/settings_page.dart | 14 +- .../providers/release_note_controller.dart | 97 ++++ .../release_notes_list_controller.dart | 163 ++++++ .../providers/release_notes_providers.dart | 25 + .../presentation/release_notes_texts.dart | 44 ++ .../settings/presentation/settings_texts.dart | 1 + .../widgets/release_notes_parts.dart | 105 ++++ .../features/settings/settings_routes.dart | 37 +- flutter/test/app_test.dart | 19 +- .../test/core/utils/calendar_date_test.dart | 64 +++ .../settings/release_notes_page_test.dart | 477 +++++++++++++++++ .../features/settings/release_notes_test.dart | 483 ++++++++++++++++++ .../features/settings/settings_page_test.dart | 120 +++-- flutter/test/support/release_notes_fakes.dart | 203 ++++++++ 22 files changed, 2916 insertions(+), 45 deletions(-) create mode 100644 flutter/lib/core/utils/calendar_date.dart delete mode 100644 flutter/lib/features/settings/data/.gitkeep create mode 100644 flutter/lib/features/settings/data/models/release_note.dart create mode 100644 flutter/lib/features/settings/data/repositories/release_notes_repository.dart create mode 100644 flutter/lib/features/settings/presentation/pages/release_note_page.dart create mode 100644 flutter/lib/features/settings/presentation/pages/release_notes_page.dart create mode 100644 flutter/lib/features/settings/presentation/providers/release_note_controller.dart create mode 100644 flutter/lib/features/settings/presentation/providers/release_notes_list_controller.dart create mode 100644 flutter/lib/features/settings/presentation/providers/release_notes_providers.dart create mode 100644 flutter/lib/features/settings/presentation/release_notes_texts.dart create mode 100644 flutter/lib/features/settings/presentation/widgets/release_notes_parts.dart create mode 100644 flutter/test/core/utils/calendar_date_test.dart create mode 100644 flutter/test/features/settings/release_notes_page_test.dart create mode 100644 flutter/test/features/settings/release_notes_test.dart create mode 100644 flutter/test/support/release_notes_fakes.dart diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index cdf5dc3..5c32e74 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -229,4 +229,18 @@ abstract final class AppSize { /// 둥근 진행 표시(켜는 중 · 끄는 중)의 지름과 고리 두께 static const double progressRing = 22; static const double progressRingStroke = 2.6; + + // ── 앱 보드에서 정한 크기 (업데이트 내역 `ReleaseNotes` B — 2026-11-10 확정) ── + // 목록 줄은 설정의 두 줄 칸([settingsRowMinHeight] 68)과 같다 + /// 글 화면 끝의 다른 글 줄(다음 글 · 이전 글)의 최소 높이 + static const double noteLinkRowMinHeight = 64; + + /// 글의 점 목록 — 점의 지름(점 칸 폭은 `AppSpacing.s16`) + static const double bulletDot = 5; + + /// 목록을 불러오는 동안 줄의 제목 막대 높이(날짜 막대는 `AppSpacing.s10`) + static const double skeletonTitleBarHeight = 13; + + /// 목록을 불러오는 동안 줄의 날짜 막대 폭 + static const double skeletonDateBarWidth = 104; } diff --git a/flutter/lib/core/utils/calendar_date.dart b/flutter/lib/core/utils/calendar_date.dart new file mode 100644 index 0000000..ed7c6b8 --- /dev/null +++ b/flutter/lib/core/utils/calendar_date.dart @@ -0,0 +1,53 @@ +import 'package:flutter/foundation.dart'; + +/// 달력 날짜 — 시각이 아닌 "그날"(서버의 `YYYY-MM-DD` 글, 예: 업데이트 내역 글의 쓴 날 — 계약 7절). +/// +/// 시간대를 바꾸지 않는다 — 글을 직접 나눠 연 · 월 · 일 숫자로만 쥔다. `DateTime.parse('2026-11-18')` 은 기기 시간대의 자정이고 +/// 웹의 `new Date('2026-11-18')` 은 UTC 자정이라(음수 시간대에서 하루 앞) 어느 쪽이든 "시각"으로 바꾸면 그날이 흔들릴 수 있다. +@immutable +class CalendarDate { + const CalendarDate(this.year, this.month, this.day); + + final int year; + final int month; + final int day; + + /// `YYYY-MM-DD` 글 → 날짜. 모양이 다르거나 없는 날(2026-02-30 · 2026-13-01)이면 null + static CalendarDate? tryParse(Object? value) { + if (value is! String) return null; + final RegExpMatch? match = _pattern.firstMatch(value); + if (match == null) return null; + final int year = int.parse(match.group(1)!); + final int month = int.parse(match.group(2)!); + final int day = int.parse(match.group(3)!); + if (month < 1 || month > _monthsInYear) return null; + if (day < 1 || day > _daysInMonth(year, month)) return null; + return CalendarDate(year, month, day); + } + + /// 숫자 칸 셋 — 연 넷 · 월 둘 · 일 둘(ASCII 숫자만) + static final RegExp _pattern = RegExp(r'^([0-9]{4})-([0-9]{2})-([0-9]{2})$'); + + static const int _monthsInYear = 12; + + /// 그달의 날 수 — 다음 달 0일(= 이달 마지막 날). UTC 로 만들어 시간대 · 서머타임과 상관없이 날만 센다 + static int _daysInMonth(int year, int month) => + DateTime.utc(year, month + 1, 0).day; + + @override + bool operator ==(Object other) => + other is CalendarDate && + other.year == year && + other.month == month && + other.day == day; + + @override + int get hashCode => Object.hash(year, month, day); + + /// `YYYY-MM-DD` — 서버가 준 글과 같은 모양 + @override + String toString() => + '${year.toString().padLeft(4, '0')}-' + '${month.toString().padLeft(2, '0')}-' + '${day.toString().padLeft(2, '0')}'; +} diff --git a/flutter/lib/core/utils/korean_format.dart b/flutter/lib/core/utils/korean_format.dart index 76493e0..f3afd1b 100644 --- a/flutter/lib/core/utils/korean_format.dart +++ b/flutter/lib/core/utils/korean_format.dart @@ -2,8 +2,11 @@ /// /// `intl` 의 날짜 형식은 로캘 자료를 먼저 읽어야 해서(앱은 MaterialApp 이 읽는다) 순수 함수 시험에서 쓰기 번거롭다 — /// 웹 `Intl.DateTimeFormat('ko-KR')` 이 내는 글 그대로를 여기서 만든다. 시각은 **기기 시간대**로 바꾼 값(`toLocal()`)을 받는다. +/// 달력 날짜([CalendarDate] — 시각이 아닌 "그날")는 시간대를 바꾸지 않고 그대로 쓴다. library; +import 'package:arasystem_app/core/utils/calendar_date.dart'; + /// 개수 — 1234 → "1,234" (웹 `formatCount` 와 같다) String formatCount(int value) { final String digits = value.abs().toString(); @@ -49,6 +52,11 @@ String koreanScheduleTime(DateTime local) => String koreanFullDateTime(DateTime local) => '${local.year}년 ${koreanScheduleTime(local)}'; +/// 달력 날짜 — "2026년 11월 18일" (웹 `{ year: 'numeric', month: 'long', day: 'numeric' }` — 업데이트 내역의 쓴 날). +/// 시각이 아니라 시간대를 바꾸지 않는다 +String koreanCalendarDate(CalendarDate date) => + '${date.year}년 ${date.month}월 ${date.day}일'; + /// 같은 달력 날인가 (둘 다 같은 시간대로 바꾼 값) bool isSameCalendarDay(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; diff --git a/flutter/lib/features/settings/data/.gitkeep b/flutter/lib/features/settings/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/flutter/lib/features/settings/data/models/release_note.dart b/flutter/lib/features/settings/data/models/release_note.dart new file mode 100644 index 0000000..8a6797a --- /dev/null +++ b/flutter/lib/features/settings/data/models/release_note.dart @@ -0,0 +1,190 @@ +import 'package:arasystem_app/core/utils/calendar_date.dart'; +import 'package:flutter/foundation.dart'; + +/// 업데이트 내역 글의 머리 — 목록 줄 · 글 화면의 다른 글 줄 (계약 7절 "업데이트 내역" — `{ id, date, title }`). +/// +/// 모델은 손으로 쓴다(생성기 없음 — flutter 스킬 "모델 생성기"). 글은 서버가 다듬은 글 그대로다 — 마크다운 해석 없이 글로만 그린다. +@immutable +class ReleaseNoteSummary { + const ReleaseNoteSummary({ + required this.id, + required this.date, + required this.title, + }); + + /// 글 id — 글 파일 이름(`2026-11-18` · 같은 날 둘째부터 `2026-11-18-2`). 해석하지 않고 주소 · 요청에 그대로 쓴다 + final String id; + + /// 쓴 날 — 달력 날짜(시각이 아니다 — 시간대를 바꾸지 않고 "2026년 11월 18일"로 쓴다) + final CalendarDate date; + + final String title; + + /// 모양이 틀리면(빈 id · 날짜가 `YYYY-MM-DD` 가 아님 · 없는 날 포함) null + static ReleaseNoteSummary? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final Object? title = json['title']; + final CalendarDate? date = CalendarDate.tryParse(json['date']); + if (id is! String || id.isEmpty || title is! String || date == null) { + return null; + } + return ReleaseNoteSummary(id: id, date: date, title: title); + } + + @override + bool operator ==(Object other) => + other is ReleaseNoteSummary && + other.id == id && + other.date == date && + other.title == title; + + @override + int get hashCode => Object.hash(id, date, title); +} + +/// 글의 묶음 — 머리(`## 머리` — 머리 없이 쓴 묶음은 null)와 줄(`- 한 줄`) +@immutable +class ReleaseNoteSection { + const ReleaseNoteSection({required this.heading, required this.items}); + + final String? heading; + final List items; + + /// 모양이 틀리면 null + static ReleaseNoteSection? tryParse(Object? json) { + if (json is! Map) return null; + final Object? heading = json['heading']; + final Object? items = json['items']; + if ((heading != null && heading is! String) || items is! List) { + return null; + } + final List lines = []; + for (final Object? item in items) { + if (item is! String) return null; + lines.add(item); + } + return ReleaseNoteSection( + heading: heading as String?, + items: List.unmodifiable(lines), + ); + } + + @override + bool operator ==(Object other) => + other is ReleaseNoteSection && + other.heading == heading && + listEquals(other.items, items); + + @override + int get hashCode => Object.hash(heading, Object.hashAll(items)); +} + +/// 업데이트 내역 글 하나 — `GET /api/release-notes/{id}` 의 `{ id, date, title, sections, previous, next }` +@immutable +class ReleaseNote { + const ReleaseNote({ + required this.id, + required this.date, + required this.title, + required this.sections, + this.previous, + this.next, + }); + + final String id; + final CalendarDate date; + final String title; + final List sections; + + /// 바로 더 옛 글(목록에서 아래) — 없으면 null. 글 화면 끝의 "이전 글" 줄 + final ReleaseNoteSummary? previous; + + /// 바로 더 새 글(목록에서 위) — 없으면 null. 글 화면 끝의 "다음 글" 줄 + final ReleaseNoteSummary? next; + + /// 모양이 틀리면(묶음 · 다른 글 하나라도) null. `previous` · `next` 칸이 아예 없으면 없는 것(null)으로 본다 + static ReleaseNote? tryParse(Object? json) { + if (json is! Map) return null; + final ReleaseNoteSummary? head = ReleaseNoteSummary.tryParse(json); + final Object? rawSections = json['sections']; + if (head == null || rawSections is! List) return null; + final List sections = []; + for (final Object? raw in rawSections) { + final ReleaseNoteSection? section = ReleaseNoteSection.tryParse(raw); + if (section == null) return null; + sections.add(section); + } + final Object? rawPrevious = json['previous']; + final Object? rawNext = json['next']; + final ReleaseNoteSummary? previous = ReleaseNoteSummary.tryParse( + rawPrevious, + ); + final ReleaseNoteSummary? next = ReleaseNoteSummary.tryParse(rawNext); + if ((rawPrevious != null && previous == null) || + (rawNext != null && next == null)) { + return null; + } + return ReleaseNote( + id: head.id, + date: head.date, + title: head.title, + sections: List.unmodifiable(sections), + previous: previous, + next: next, + ); + } +} + +/// 목록 한 쪽 — `{ items, pageInfo }` (응답 포맷 규칙 "목록 응답" — 최근 글부터) +@immutable +class ReleaseNotesPage { + const ReleaseNotesPage({ + required this.items, + required this.hasNext, + this.nextCursor, + this.total, + }); + + final List items; + final bool hasNext; + + /// 다음 쪽 커서 — 해석하지 않고 그대로 다시 보낸다. 마지막 쪽이면 null + final String? nextCursor; + + /// 글 수 — 서버가 세지 못하면 null + final int? total; + + /// 다음 쪽이 있는가 — `hasNext` 이고 커서가 있을 때만 + bool get hasMore => hasNext && (nextCursor?.isNotEmpty ?? false); + + /// 모양이 틀리면(줄 하나라도) null + static ReleaseNotesPage? tryParse(Object? json) { + if (json is! Map) return null; + final Object? rawItems = json['items']; + final Object? pageInfo = json['pageInfo']; + if (rawItems is! List || pageInfo is! Map) { + return null; + } + final Object? hasNext = pageInfo['hasNext']; + final Object? nextCursor = pageInfo['nextCursor']; + final Object? total = pageInfo['total']; + if (hasNext is! bool || + (nextCursor != null && nextCursor is! String) || + (total != null && total is! num)) { + return null; + } + final List items = []; + for (final Object? raw in rawItems) { + final ReleaseNoteSummary? item = ReleaseNoteSummary.tryParse(raw); + if (item == null) return null; + items.add(item); + } + return ReleaseNotesPage( + items: List.unmodifiable(items), + hasNext: hasNext, + nextCursor: nextCursor as String?, + total: (total as num?)?.toInt(), + ); + } +} diff --git a/flutter/lib/features/settings/data/repositories/release_notes_repository.dart b/flutter/lib/features/settings/data/repositories/release_notes_repository.dart new file mode 100644 index 0000000..205102d --- /dev/null +++ b/flutter/lib/features/settings/data/repositories/release_notes_repository.dart @@ -0,0 +1,66 @@ +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/features/settings/data/models/release_note.dart'; +import 'package:dio/dio.dart'; + +/// 업데이트 내역 API 경로 — baseUrl(`…/api`) 뒤에 붙는다(계약 7절 "업데이트 내역"). 경로는 대소문자를 가린다 +abstract final class ReleaseNotesApiPaths { + static const String releaseNotes = '/release-notes'; + + /// 글 하나 — id 는 경로 칸으로 인코딩한다(서버는 모양 · 길이만 보고 파일 경로로 쓰지 않는다) + static String releaseNote(String id) => + '$releaseNotes/${Uri.encodeComponent(id)}'; +} + +/// 목록 한 쪽의 수 — 계약 기본값(20)과 같다(보드 "처음 20개 · [더 보기]로 20개씩") +const int kReleaseNotesPageSize = 20; + +/// 업데이트 내역 API — 계약 7절. 웹과 같은 경로를 Bearer 로 부른다(로그인한 직원만 — 제한된 세션은 `AUTH_010`). +/// 올리는 · 고치는 API 는 없다(글은 운영자가 저장소의 글 파일로 쓴다). +/// +/// 응답 언래핑 · 에러 코드 · 알림은 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다. +/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 [ReleaseNotesMalformedResponse](`SYS_001`). +class ReleaseNotesRepository { + const ReleaseNotesRepository(this._dio); + + final Dio _dio; + + /// 목록 한 쪽 — 최근 글부터. [cursor] 는 해석하지 않고 그대로 보낸다. + /// [silent] 면 실패를 알리지 않는다(첫 쪽 · [다시 시도]는 화면이 받지 못함을 그린다 — [더 보기]는 알린다) + Future listNotes({ + String? cursor, + int limit = kReleaseNotesPageSize, + bool silent = false, + }) async { + final Response response = await _dio.get( + ReleaseNotesApiPaths.releaseNotes, + queryParameters: {'limit': limit, 'cursor': ?cursor}, + options: silent ? ApiOptions.silent() : null, + ); + final ReleaseNotesPage? page = ReleaseNotesPage.tryParse(response.data); + if (page == null) throw ReleaseNotesMalformedResponse(response); + return page; + } + + /// 글 하나 — 알림은 띄우지 않는다(화면이 없는 글 · 받지 못함을 그린다). 없는 글 · 모양이 틀린 id 는 404 `RES_001` + Future getNote(String id) async { + final Response response = await _dio.get( + ReleaseNotesApiPaths.releaseNote(id), + options: ApiOptions.silent(), + ); + final ReleaseNote? note = ReleaseNote.tryParse(response.data); + if (note == null) throw ReleaseNotesMalformedResponse(response); + return note; + } +} + +/// 응답은 왔는데 모양이 계약과 다르다(`SYS_001`) — 응답이 인터셉터를 다 지난 뒤라 아무도 알리지 않았다. +/// 알림을 띄우는 요청([더 보기])이면 부르는 쪽이 알린다(메일의 `DownloadInterrupted` 와 같은 결) +class ReleaseNotesMalformedResponse extends DioException { + ReleaseNotesMalformedResponse(Response response) + : super( + requestOptions: response.requestOptions, + error: ApiException.fromCode(kFallbackCode), + ); +} diff --git a/flutter/lib/features/settings/presentation/pages/release_note_page.dart b/flutter/lib/features/settings/presentation/pages/release_note_page.dart new file mode 100644 index 0000000..dceb1c7 --- /dev/null +++ b/flutter/lib/features/settings/presentation/pages/release_note_page.dart @@ -0,0 +1,428 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/korean_format.dart'; +import 'package:arasystem_app/features/settings/data/models/release_note.dart'; +import 'package:arasystem_app/features/settings/presentation/providers/release_note_controller.dart'; +import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart'; +import 'package:arasystem_app/features/settings/presentation/widgets/release_notes_parts.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:arasystem_app/shared/widgets/load_states.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 글을 받는 동안 — 제목 막대 폭과 줄 막대 폭(보드에는 그리지 않았다 — 목록 막대와 같은 결) +const double _skeletonTitleWidth = 0.78; +const double _skeletonHeadingWidth = 0.32; +const List _skeletonLineWidths = [1, 0.86, 0.94, 0.6]; + +/// 업데이트 내역 글 — 보드 `ReleaseNotes` B3(맨 위 글) · B4(가운데 글) · B5(없는 글) · B6(받지 못함). +/// 목록 위에 쌓이는 전체 화면이다(탭 막대 없음 — 주소로 바로 열어도 목록이 아래에 있다). +/// +/// - 위 막대는 뒤로만이고 늘 목록으로 간다. 날짜(13) · 제목(18) · 묶음(머리 15 · 점 목록 15/1.6) 차례이고 길면 화면째 스크롤한다. +/// - 끝에 다른 글 줄(64) — 다음 글(더 새 글)이 위, 이전 글(더 옛 글)이 아래(목록과 같은 차례), 없는 쪽은 줄이 없다. +/// 누르면 이 글을 그 글로 바꿔 끼운다(쌓지 않는다 — 뒤로는 늘 목록). [목록] 단추는 두지 않는다. +/// - 받는 동안 글 자리를 막대로 잡아 두고, 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`)은 사전 문구 · 문의 코드 · [목록으로], +/// 그 밖의 실패는 사전 문구 · 문의 코드 · [다시 시도]다(알림 없음). +class ReleaseNotePage extends ConsumerWidget { + const ReleaseNotePage({ + required this.noteId, + required this.noteLocation, + required this.listLocation, + super.key, + }); + + /// 주소의 글 id + final String noteId; + + /// 글 화면의 경로 — 다른 글 줄이 쓴다 + final String Function(String noteId) noteLocation; + + /// 목록의 경로 — 아래에 쌓인 화면이 없을 때 뒤로 · [목록으로]가 간다 + final String listLocation; + + /// 목록으로 — 쌓인 목록으로 돌아간다(없으면 목록 주소로) + void _toList(BuildContext context) { + if (context.canPop()) { + context.pop(); + } else { + context.go(listLocation); + } + } + + /// 다른 글로 — 이 글을 바꿔 끼운다(뒤로는 늘 목록 — 보드 "앱은 위 막대의 뒤로가 늘 목록") + void _open(BuildContext context, ReleaseNoteSummary note) { + context.pushReplacement(noteLocation(note.id)); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ReleaseNoteState state = ref.watch(releaseNoteProvider(noteId)); + final ReleaseNoteController controller = ref.read( + releaseNoteProvider(noteId).notifier, + ); + final ReleaseNote? note = state.note; + return Scaffold( + appBar: ReleaseNotesAppBar(onBack: () => _toList(context)), + body: switch (state.status) { + ReleaseNoteStatus.ready when note != null => _NoteView( + note: note, + onOpen: (ReleaseNoteSummary other) => _open(context, other), + ), + ReleaseNoteStatus.failed => ReleaseNotesStateFill( + child: LoadErrorView( + error: state.error ?? ApiException.fromCode('SYS_001'), + onRetry: () => unawaited(controller.retry()), + onLeave: () => _toList(context), + leaveLabel: ReleaseNotesTexts.toList, + titleStyle: releaseNotesErrorTitleStyle(Theme.of(context)), + ), + ), + _ => const _NoteSkeleton(), + }, + ); + } +} + +/// 받은 글 — 글 + 끝의 다른 글 줄(화면째 스크롤) +class _NoteView extends StatelessWidget { + const _NoteView({required this.note, required this.onOpen}); + + final ReleaseNote note; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final ReleaseNoteSummary? newer = note.next; + final ReleaseNoteSummary? older = note.previous; + return ListView( + children: [ + _Article(note: note), + if (newer != null || older != null) + _NeighborLinks(newer: newer, older: older, onOpen: onOpen), + ], + ); + } +} + +/// 글 — 날짜 · 제목 · 묶음 (보드 B3: 둘레 위 20 · 옆 20 · 아래 24, 덩이 사이 20) +class _Article extends StatelessWidget { + const _Article({required this.note}); + + final ReleaseNote note; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s20, + AppSpacing.s20, + AppSpacing.s20, + AppSpacing.s24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ReleaseNoteDateText( + note.date, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: AppTypography.medium, + ), + ), + const SizedBox(height: AppSpacing.s4), + Semantics( + header: true, + child: KeepAllText( + note.title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: AppTypography.bold, + ), + ), + ), + for (final ReleaseNoteSection section in note.sections) ...[ + const SizedBox(height: AppSpacing.s20), + _Section(section: section), + ], + ], + ), + ); + } +} + +/// 묶음 — 머리(있으면 15 · 굵게) · 점 목록(줄 사이 6) +class _Section extends StatelessWidget { + const _Section({required this.section}); + + final ReleaseNoteSection section; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final String? heading = section.heading; + final List items = section.items; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (heading != null) ...[ + Semantics( + header: true, + child: KeepAllText( + heading, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: AppTypography.bold, + color: theme.colorScheme.onSurface, + ), + ), + ), + const SizedBox(height: AppSpacing.s8), + ], + for (int index = 0; index < items.length; index++) ...[ + if (index > 0) const SizedBox(height: AppSpacing.s6), + _Item(text: items[index]), + ], + ], + ); + } +} + +/// 점 목록의 한 줄 — 앞에 회색 점(5)이 첫 줄 가운데에 오고, 넘친 줄은 점 칸(16) 뒤에서 이어진다 +class _Item extends StatelessWidget { + const _Item({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final TextStyle? style = theme.textTheme.bodyMedium?.copyWith( + color: extra.textStrong, + height: AppTypography.lineHeightRelaxed, + ); + // 첫 줄의 높이 — 기기 글자 크기 설정을 따라 커져도 점이 첫 줄 가운데에 온다 + final double lineHeight = + MediaQuery.textScalerOf( + context, + ).scale(style?.fontSize ?? AppTypography.bodyMedium) * + AppTypography.lineHeightRelaxed; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExcludeSemantics( + child: SizedBox( + width: AppSpacing.s16, + height: lineHeight, + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: AppSize.bulletDot, + height: AppSize.bulletDot, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: extra.iconMuted, + ), + ), + ), + ), + ), + Expanded(child: KeepAllText(text, style: style)), + ], + ); + } +} + +/// 다른 글 줄 묶음 — 다음 글(위) · 이전 글(아래), 위아래 선과 줄 사이 선(보드 B3 · B4) +class _NeighborLinks extends StatelessWidget { + const _NeighborLinks({ + required this.newer, + required this.older, + required this.onOpen, + }); + + /// 바로 더 새 글 — 없으면 null + final ReleaseNoteSummary? newer; + + /// 바로 더 옛 글 — 없으면 null + final ReleaseNoteSummary? older; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final Color line = Theme.of( + context, + ).extension()!.borderSubtle; + final ReleaseNoteSummary? newerNote = newer; + final ReleaseNoteSummary? olderNote = older; + final BorderSide side = BorderSide(color: line, width: AppSize.borderWidth); + return DecoratedBox( + decoration: BoxDecoration( + border: Border(top: side, bottom: side), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (newerNote != null) + _NeighborRow( + label: ReleaseNotesTexts.newer, + note: newerNote, + onTap: () => onOpen(newerNote), + ), + if (newerNote != null && olderNote != null) + Divider(height: AppSize.borderWidth, color: line), + if (olderNote != null) + _NeighborRow( + label: ReleaseNotesTexts.older, + note: olderNote, + onTap: () => onOpen(olderNote), + ), + ], + ), + ); + } +} + +/// 다른 글 줄 — "다음 글 · 2026년 11월 18일"(13) · 제목(15) · › (최소 64 · 줄 전체를 누른다). +/// 화면 낭독기는 "다음 글, 2026년 11월 18일, 제목" 단추로 읽는다 +class _NeighborRow extends StatelessWidget { + const _NeighborRow({ + required this.label, + required this.note, + required this.onTap, + }); + + /// "다음 글" · "이전 글" + final String label; + final ReleaseNoteSummary note; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final String date = koreanCalendarDate(note.date); + return Semantics( + container: true, + button: true, + child: InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSize.noteLinkRowMinHeight, + ), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + AppSpacing.s20, + AppSpacing.s10, + AppSpacing.s12, + AppSpacing.s10, + ), + child: Row( + spacing: AppSpacing.s12, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s2, + children: [ + Text( + ReleaseNotesTexts.neighborLine(label, date), + semanticsLabel: ReleaseNotesTexts.neighborLineSpoken( + label, + date, + ), + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + height: AppTypography.lineHeightHeading, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + KeepAllText( + note.title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: AppTypography.medium, + height: AppTypography.lineHeightHeading, + ), + ), + ], + ), + ), + ExcludeSemantics( + child: Icon( + Icons.chevron_right, + size: AppSize.iconMedium, + color: extra.textMuted, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 글을 받는 동안 — 날짜 · 제목 · 머리 · 줄 자리의 막대(목록 막대와 같은 결 — 끝이 둥글다). 화면 낭독기는 "글 불러오는 중" +class _NoteSkeleton extends StatelessWidget { + const _NoteSkeleton(); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final Color bar = theme.colorScheme.surfaceContainerHighest; + final Color light = theme.extension()!.borderSubtle; + return LoadingSkeleton( + label: ReleaseNotesTexts.noteLoading, + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + AppSpacing.s20, + AppSpacing.s20, + AppSpacing.s20, + AppSpacing.s24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: AppSize.skeletonDateBarWidth, + child: SkeletonBar(color: light, rounded: true), + ), + const SizedBox(height: AppSpacing.s10), + FractionallySizedBox( + widthFactor: _skeletonTitleWidth, + child: SkeletonBar( + color: bar, + height: AppSize.skeletonTitleBarHeight, + rounded: true, + ), + ), + const SizedBox(height: AppSpacing.s28), + FractionallySizedBox( + widthFactor: _skeletonHeadingWidth, + child: SkeletonBar( + color: bar, + height: AppSize.skeletonTitleBarHeight, + rounded: true, + ), + ), + for (final double width in _skeletonLineWidths) ...[ + const SizedBox(height: AppSpacing.s14), + FractionallySizedBox( + widthFactor: width, + child: SkeletonBar(color: bar, rounded: true), + ), + ], + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/settings/presentation/pages/release_notes_page.dart b/flutter/lib/features/settings/presentation/pages/release_notes_page.dart new file mode 100644 index 0000000..cbdbd34 --- /dev/null +++ b/flutter/lib/features/settings/presentation/pages/release_notes_page.dart @@ -0,0 +1,350 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/settings/data/models/release_note.dart'; +import 'package:arasystem_app/features/settings/presentation/providers/release_notes_list_controller.dart'; +import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart'; +import 'package:arasystem_app/features/settings/presentation/widgets/release_notes_parts.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:arasystem_app/shared/widgets/load_states.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 불러오는 동안 줄의 제목 막대 폭 — 보드 B7 의 여섯 줄 +const List _skeletonTitleWidths = [ + 0.78, + 0.56, + 0.84, + 0.48, + 0.7, + 0.6, +]; + +/// 업데이트 내역 목록 — 보드 `ReleaseNotes` B2(목록) · B7(불러오는 중) · B8(받지 못함) · B9(글이 없을 때). +/// 설정 탭 › 앱 정보의 [업데이트 내역]이 여는 전체 화면이다(탭 막대 없음). +/// +/// - 위 막대: 뒤로 · "업데이트 내역". 줄마다 제목(16) · 날짜(13) · ›(줄 전체를 누른다, 68) — 누르면 글 화면을 위에 쌓는다. +/// - 최근 글부터 20개, 더 있으면 목록 아래 [더 보기](폭 전체 48 — 받는 동안 그 단추만 바쁨 표시). +/// 더 받지 못하면 알림으로 알리고 받은 줄은 그대로다. +/// - 받는 동안 줄 자리를 막대로 잡아 두고, 받지 못하면 그 자리에 사전 문구 · 문의 코드 · [다시 시도](알림 없음), +/// 글이 없으면 "아직 알려 드릴 업데이트가 없습니다." 한 줄이다. +class ReleaseNotesPage extends ConsumerWidget { + const ReleaseNotesPage({ + required this.noteLocation, + required this.exitLocation, + super.key, + }); + + /// 글 화면의 경로 — `/settings/release-notes/` + final String Function(String noteId) noteLocation; + + /// 뒤로 갈 화면이 쌓여 있지 않을 때(주소로 바로 연 목록) 갈 곳 — 설정 + final String exitLocation; + + void _leave(BuildContext context) { + if (context.canPop()) { + context.pop(); + } else { + context.go(exitLocation); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ReleaseNotesListState list = ref.watch(releaseNotesListProvider); + final ReleaseNotesListController controller = ref.read( + releaseNotesListProvider.notifier, + ); + return Scaffold( + appBar: ReleaseNotesAppBar( + title: ReleaseNotesTexts.title, + onBack: () => _leave(context), + ), + body: switch (list.status) { + ReleaseNotesListStatus.loading => const _ListSkeleton(), + ReleaseNotesListStatus.failed => ReleaseNotesStateFill( + child: LoadErrorView( + error: list.error ?? ApiException.fromCode('SYS_001'), + onRetry: () => unawaited(controller.retry()), + titleStyle: releaseNotesErrorTitleStyle(Theme.of(context)), + ), + ), + ReleaseNotesListStatus.ready when list.items.isEmpty => + const ReleaseNotesStateFill(child: _EmptyList()), + ReleaseNotesListStatus.ready => _NoteList( + list: list, + onOpen: (ReleaseNoteSummary note) => + unawaited(context.push(noteLocation(note.id))), + onMore: () => unawaited(controller.loadMore()), + ), + }, + ); + } +} + +/// 받은 목록 — 줄 + (더 있으면) 끝의 [더 보기] +class _NoteList extends StatelessWidget { + const _NoteList({ + required this.list, + required this.onOpen, + required this.onMore, + }); + + final ReleaseNotesListState list; + final ValueChanged onOpen; + final VoidCallback onMore; + + @override + Widget build(BuildContext context) { + final List items = list.items; + return ListView.builder( + itemCount: items.length + (list.hasMore ? 1 : 0), + itemBuilder: (BuildContext context, int index) { + if (index == items.length) { + return _MoreButton(busy: list.loadingMore, onPressed: onMore); + } + final ReleaseNoteSummary note = items[index]; + return _NoteRow(note: note, onTap: () => onOpen(note)); + }, + ); + } +} + +/// 목록 줄 — 제목 · 날짜 · › (보드 B2: 최소 68 · 왼쪽 20 · 오른쪽 12, 아래 선). 화면 낭독기는 "제목, 날짜" 단추로 읽는다 +class _NoteRow extends StatelessWidget { + const _NoteRow({required this.note, required this.onTap}); + + final ReleaseNoteSummary note; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return Semantics( + container: true, + button: true, + child: InkWell( + onTap: onTap, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.colorScheme.surfaceContainerHigh, + width: AppSize.borderWidth, + ), + ), + ), + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSize.settingsRowMinHeight, + ), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + AppSpacing.s20, + AppSpacing.s10, + AppSpacing.s12, + AppSpacing.s10, + ), + child: Row( + spacing: AppSpacing.s12, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s2, + children: [ + KeepAllText( + note.title, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: AppTypography.medium, + height: AppTypography.lineHeightHeading, + ), + ), + ReleaseNoteDateText( + note.date, + style: theme.textTheme.bodySmall?.copyWith( + height: AppTypography.lineHeightHeading, + ), + ), + ], + ), + ), + ExcludeSemantics( + child: Icon( + Icons.chevron_right, + size: AppSize.iconMedium, + color: extra.textMuted, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// [더 보기] — 폭 전체 48 · 회색 테두리(보드 B2, 둘레 위 16 · 옆 20 · 아래 24). 받는 동안은 둥근 진행 표시와 회색 글이고 다시 누르지 못한다. +/// 초점은 지킨다(꺼진 단추로 바꾸지 않고 누름만 받지 않는다 — 메일 검색의 [더 찾기]와 같다). 화면 낭독기는 꺼진 단추 "더 보기, 불러오는 중"으로 읽는다 +class _MoreButton extends StatelessWidget { + const _MoreButton({required this.busy, required this.onPressed}); + + final bool busy; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final AppExtraColors extra = theme.extension()!; + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s20, + AppSpacing.s16, + AppSpacing.s20, + AppSpacing.s24, + ), + child: Semantics( + container: true, + button: true, + enabled: !busy, + liveRegion: busy, + label: ReleaseNotesTexts.more, + value: busy ? ReleaseNotesTexts.moreBusy : null, + onTap: busy ? null : onPressed, + child: ExcludeSemantics( + child: OutlinedButton( + // 받는 동안도 단추는 그대로 둔다(초점이 옮겨 가지 않게) — 누름만 받지 않는다 + onPressed: busy ? _ignore : onPressed, + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(AppSize.touchMin), + foregroundColor: busy ? extra.iconMuted : extra.textStrong, + backgroundColor: scheme.surface, + overlayColor: busy ? Colors.transparent : null, + side: BorderSide( + color: busy + ? scheme.surfaceContainerHighest + : extra.borderStrong, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + textStyle: theme.textTheme.labelLarge, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: AppSpacing.s10, + children: [ + if (busy) + SizedBox.square( + dimension: AppSize.iconSmall, + child: CircularProgressIndicator( + strokeWidth: AppSize.progressRingStroke, + color: scheme.primary, + backgroundColor: AppColors.progressRingTrack, + strokeCap: StrokeCap.round, + ), + ), + const Text(ReleaseNotesTexts.more), + ], + ), + ), + ), + ), + ); + } + + static void _ignore() {} +} + +/// 글이 없을 때 — 문서 그림 원 · 한 줄(보드 B9 — 빈 메일함과 같은 꼴 · 같은 제목 크기) +class _EmptyList extends StatelessWidget { + const _EmptyList(); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return StateLayout( + // 웹의 문서 그림(file-text — 접힌 모서리와 글 줄)과 같은 꼴 + icon: Icons.description_outlined, + iconColor: extra.textMuted, + iconBackground: theme.colorScheme.surfaceContainerHigh, + title: ReleaseNotesTexts.empty, + children: const [], + ); + } +} + +/// 처음 받는 동안 — 줄 자리의 막대(보드 B7: 줄 68 · 제목 막대 13 · 날짜 막대 104 × 10, 끝이 둥글다). +/// 화면 낭독기는 "업데이트 내역 불러오는 중" +class _ListSkeleton extends StatelessWidget { + const _ListSkeleton(); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final AppExtraColors extra = theme.extension()!; + return LoadingSkeleton( + label: ReleaseNotesTexts.listLoading, + // 가로 화면처럼 낮은 화면에서는 넘치는 줄을 자른다(보드의 `overflow: hidden`) + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + children: [ + for (final double width in _skeletonTitleWidths) + DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: scheme.surfaceContainerHigh, + width: AppSize.borderWidth, + ), + ), + ), + child: SizedBox( + height: AppSize.settingsRowMinHeight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s20, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s10, + children: [ + FractionallySizedBox( + widthFactor: width, + child: SkeletonBar( + color: scheme.surfaceContainerHighest, + height: AppSize.skeletonTitleBarHeight, + rounded: true, + ), + ), + SizedBox( + width: AppSize.skeletonDateBarWidth, + child: SkeletonBar( + color: extra.borderSubtle, + rounded: true, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/settings/presentation/pages/settings_page.dart b/flutter/lib/features/settings/presentation/pages/settings_page.dart index 0603eac..b42f352 100644 --- a/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -12,6 +12,7 @@ import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart'; import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; import 'package:arasystem_app/features/settings/presentation/widgets/settings_notification_row.dart'; import 'package:arasystem_app/shared/widgets/confirm_dialog.dart'; @@ -24,13 +25,15 @@ import 'package:go_router/go_router.dart'; /// 글자는 테마 크기로 맞췄다(보드 15.5 → 16 · 14 → 15 · 13 → 13 — 앱 디자인 README 의 크기 정리와 같다). /// /// 한 화면에 절을 모두 둔다: 내 정보(이름 · 주소) · 알림(새 메일 알림) · 다른 모듈이 내놓은 절 · -/// 앱 정보(앱 버전 · [새 버전 받기] · 오픈소스 라이선스) · 문의(`GET /api/support-contact` — 로그인 화면과 같은 저장소, 화면을 열 때 받아 둔다) · [로그아웃]. +/// 앱 정보(앱 버전 · 업데이트 내역 · [새 버전 받기] · 오픈소스 라이선스) · 문의(`GET /api/support-contact` — 로그인 화면과 같은 저장소, 화면을 열 때 받아 둔다) · [로그아웃]. +/// 업데이트 내역(보드 `ReleaseNotes` B1)은 앱 버전 다음 줄이고 오픈소스 라이선스 줄과 같은 꼴이다 — 누르면 목록(전체 화면). /// [새 버전 받기]는 업데이트 화면의 단추처럼 포털의 휴대폰 앱 받기 화면을 바깥 브라우저로 연다(같은 런처 · 같은 실패 알림 — `core/app_download/`). /// [로그아웃]은 확인 창부터 연다(처음 초점은 [취소] — 로그아웃하면 이 휴대폰의 새 메일 알림도 멈춘다고 알린다). /// 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다. class SettingsPage extends ConsumerStatefulWidget { const SettingsPage({ required this.licensesLocation, + required this.releaseNotesLocation, this.sections = const [], this.sectionLocation, super.key, @@ -39,6 +42,9 @@ class SettingsPage extends ConsumerStatefulWidget { /// 오픈소스 라이선스 화면의 경로 final String licensesLocation; + /// 업데이트 내역 목록의 경로 + final String releaseNotesLocation; + /// 다른 모듈이 내놓은 설정 절 final List sections; @@ -125,6 +131,12 @@ class _SettingsPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _VersionRow(version: version), + _LinkRow( + label: ReleaseNotesTexts.title, + onTap: () => unawaited( + context.push(widget.releaseNotesLocation), + ), + ), _LinkRow( label: AppDownloadTexts.getNewVersion, opensOutside: true, diff --git a/flutter/lib/features/settings/presentation/providers/release_note_controller.dart b/flutter/lib/features/settings/presentation/providers/release_note_controller.dart new file mode 100644 index 0000000..a49dad5 --- /dev/null +++ b/flutter/lib/features/settings/presentation/providers/release_note_controller.dart @@ -0,0 +1,97 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/features/settings/data/models/release_note.dart'; +import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart'; +import 'package:arasystem_app/features/settings/presentation/providers/release_notes_providers.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; + +/// 글 화면의 상태 +enum ReleaseNoteStatus { + /// 받는 중 — 글 자리를 막대로 잡아 둔다 + loading, + + /// 받았다 + ready, + + /// 받지 못했다 — 없는 글(`RES_001`)은 [목록으로], 그 밖은 [다시 시도] + failed, +} + +/// 업데이트 내역 글 하나의 상태 +@immutable +class ReleaseNoteState { + const ReleaseNoteState({ + this.status = ReleaseNoteStatus.loading, + this.note, + this.error, + }); + + final ReleaseNoteStatus status; + + /// 받은 글 — [ReleaseNoteStatus.ready] 일 때만 + final ReleaseNote? note; + + /// 받지 못한 까닭 (코드 · 추적 ID) — [ReleaseNoteStatus.failed] 일 때만 + final ApiException? error; +} + +/// 업데이트 내역 글 하나 — 글 id 마다 하나. 글 화면이 있는 동안만 둔다(autoDispose — 다른 글로 옮기면 앞 응답은 버린다) +final NotifierProviderFamily +releaseNoteProvider = NotifierProvider.autoDispose + .family( + ReleaseNoteController.new, + ); + +/// 업데이트 내역 글 — 계약 7절 `GET /api/release-notes/{id}` (보드 `ReleaseNotes` B3 · B4 · B5 · B6). +/// +/// 알림 없이 받는다 — 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`)과 그 밖의 실패는 화면이 그 자리에 그린다. +/// 세션이 바뀌면 처음부터 다시 받고, 그 전에 보낸 요청의 응답은 버린다(세대 [_seq] · 시작할 때의 [Ref]). +class ReleaseNoteController extends Notifier { + ReleaseNoteController(this.noteId); + + /// 주소의 글 id + final String noteId; + + int _seq = 0; + + ReleaseNotesRepository get _repository => + ref.read(releaseNotesRepositoryProvider); + + @override + ReleaseNoteState build() { + final String? account = ref.watch(releaseNotesAccountProvider); + _seq++; + // 세션이 없으면(로그아웃하며 화면이 사라지는 중) 묻지 않는다 + if (account == null) return const ReleaseNoteState(); + unawaited(_load(_seq)); + return const ReleaseNoteState(); + } + + /// [다시 시도] — 처음부터 다시 받는다(그동안 막대) + Future retry() async { + if (!ref.mounted || ref.read(releaseNotesAccountProvider) == null) return; + _seq++; + state = const ReleaseNoteState(); + await _load(_seq); + } + + Future _load(int seq) async { + final Ref ref = this.ref; + try { + final ReleaseNote note = await _repository.getNote(noteId); + if (!ref.mounted || seq != _seq) return; + state = ReleaseNoteState(status: ReleaseNoteStatus.ready, note: note); + } on DioException catch (error) { + if (!ref.mounted || seq != _seq) return; + state = ReleaseNoteState( + status: ReleaseNoteStatus.failed, + error: toApiException(error), + ); + } + } +} diff --git a/flutter/lib/features/settings/presentation/providers/release_notes_list_controller.dart b/flutter/lib/features/settings/presentation/providers/release_notes_list_controller.dart new file mode 100644 index 0000000..9b678a0 --- /dev/null +++ b/flutter/lib/features/settings/presentation/providers/release_notes_list_controller.dart @@ -0,0 +1,163 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/features/settings/data/models/release_note.dart'; +import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart'; +import 'package:arasystem_app/features/settings/presentation/providers/release_notes_providers.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 목록의 상태 +enum ReleaseNotesListStatus { + /// 첫 쪽을 받는 중 — 줄 자리를 막대로 잡아 둔다 + loading, + + /// 받았다(글이 없을 수도 있다) + ready, + + /// 첫 쪽을 받지 못했다 — 받지 못함 + [다시 시도] + failed, +} + +/// 업데이트 내역 목록 — 받은 줄 · 다음 쪽 · [더 보기]를 받는 중 +@immutable +class ReleaseNotesListState { + const ReleaseNotesListState({ + this.status = ReleaseNotesListStatus.loading, + this.items = const [], + this.hasNext = false, + this.nextCursor, + this.error, + this.loadingMore = false, + }); + + final ReleaseNotesListStatus status; + + /// 받은 줄 — 최근 글부터(서버 차례 그대로) + final List items; + final bool hasNext; + + /// 다음 쪽 커서 — 해석하지 않고 그대로 보낸다 + final String? nextCursor; + + /// 첫 쪽을 받지 못한 까닭 (코드 · 추적 ID) + final ApiException? error; + + /// [더 보기]로 다음 쪽을 받는 중 — 그 단추만 바쁨 표시 + final bool loadingMore; + + /// 다음 쪽이 있다 — [더 보기]를 보인다 + bool get hasMore => hasNext && (nextCursor?.isNotEmpty ?? false); + + ReleaseNotesListState copyWith({bool? loadingMore}) => ReleaseNotesListState( + status: status, + items: items, + hasNext: hasNext, + nextCursor: nextCursor, + error: error, + loadingMore: loadingMore ?? this.loadingMore, + ); +} + +/// 업데이트 내역 목록 — 목록 화면이 있는 동안만 둔다(autoDispose — 다시 열면 새로 받는다) +final NotifierProvider +releaseNotesListProvider = + NotifierProvider.autoDispose< + ReleaseNotesListController, + ReleaseNotesListState + >(ReleaseNotesListController.new); + +/// 업데이트 내역 목록 — 계약 7절 `GET /api/release-notes` (보드 `ReleaseNotes` B2 · B7 · B8 · B9). +/// +/// - 첫 쪽 · [다시 시도]는 알림 없이 받는다 — 받지 못하면 화면이 그 자리에 사전 문구 · 문의 코드 · [다시 시도]를 그린다. +/// - [더 보기]는 다음 쪽을 받아 뒤에 붙인다(이미 있는 글은 건너뛴다 — 그사이 글이 더해져 자리가 밀렸을 수 있다). +/// 받지 못하면 인터셉터가 알리고 받은 줄은 그대로 둔다(저절로 다시 하지 않는다 — [더 보기]를 다시 누른다). +/// - 늦은 응답: 처음부터 다시 받을 때마다(다시 시도 · 세션이 바뀜) 세대([_seq])가 오른다 — 그 전에 보낸 요청의 응답은 버린다. +/// 비동기 일은 시작할 때의 [Ref] 를 쥐고, 끝난 뒤 그 Ref 가 살아 있을 때만 상태를 바꾼다(다시 만들어졌거나 사라졌으면 버린다). +class ReleaseNotesListController extends Notifier { + int _seq = 0; + + ReleaseNotesRepository get _repository => + ref.read(releaseNotesRepositoryProvider); + + @override + ReleaseNotesListState build() { + final String? account = ref.watch(releaseNotesAccountProvider); + _seq++; + // 세션이 없으면(로그아웃하며 화면이 사라지는 중) 묻지 않는다 — 세션 없는 요청이 갱신을 부르지 않게 + if (account == null) return const ReleaseNotesListState(); + unawaited(_loadFirst(_seq)); + return const ReleaseNotesListState(); + } + + /// [다시 시도] — 첫 쪽을 처음부터 다시 받는다(그동안 막대) + Future retry() async { + if (!ref.mounted || ref.read(releaseNotesAccountProvider) == null) return; + _seq++; + state = const ReleaseNotesListState(); + await _loadFirst(_seq); + } + + Future _loadFirst(int seq) async { + final Ref ref = this.ref; + try { + final ReleaseNotesPage page = await _repository.listNotes(silent: true); + if (!ref.mounted || seq != _seq) return; + state = ReleaseNotesListState( + status: ReleaseNotesListStatus.ready, + items: page.items, + hasNext: page.hasNext, + nextCursor: page.nextCursor, + ); + } on DioException catch (error) { + if (!ref.mounted || seq != _seq) return; + state = ReleaseNotesListState( + status: ReleaseNotesListStatus.failed, + error: toApiException(error), + ); + } + } + + /// [더 보기] — 다음 쪽을 받아 뒤에 붙인다. 받는 중 · 다음 쪽 없음이면 아무 일 없다 + Future loadMore() async { + final Ref ref = this.ref; + if (!ref.mounted) return; + final ReleaseNotesListState current = state; + final String? cursor = current.nextCursor; + if (current.status != ReleaseNotesListStatus.ready || + !current.hasMore || + current.loadingMore || + cursor == null) { + return; + } + final int seq = _seq; + state = current.copyWith(loadingMore: true); + try { + final ReleaseNotesPage page = await _repository.listNotes(cursor: cursor); + if (!ref.mounted || seq != _seq) return; + final Set known = { + for (final ReleaseNoteSummary note in state.items) note.id, + }; + state = ReleaseNotesListState( + status: ReleaseNotesListStatus.ready, + items: List.unmodifiable([ + ...state.items, + for (final ReleaseNoteSummary note in page.items) + if (!known.contains(note.id)) note, + ]), + hasNext: page.hasNext, + nextCursor: page.nextCursor, + ); + } on DioException catch (error) { + if (!ref.mounted || seq != _seq) return; + // 그 밖의 실패는 인터셉터가 이미 알렸다 — 모양이 틀린 응답만 아무도 알리지 않았다 + if (error is ReleaseNotesMalformedResponse) { + ref.read(errorNotifierProvider).show(toApiException(error)); + } + state = state.copyWith(loadingMore: false); + } + } +} diff --git a/flutter/lib/features/settings/presentation/providers/release_notes_providers.dart b/flutter/lib/features/settings/presentation/providers/release_notes_providers.dart new file mode 100644 index 0000000..afe9044 --- /dev/null +++ b/flutter/lib/features/settings/presentation/providers/release_notes_providers.dart @@ -0,0 +1,25 @@ +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 업데이트 내역 저장소 — 인터셉터(언래핑 · Bearer · 갱신 · 알림)가 걸린 `dioProvider` 의 Dio 를 쓴다 +final Provider releaseNotesRepositoryProvider = + Provider( + (Ref ref) => ReleaseNotesRepository(ref.watch(dioProvider)), + ); + +/// 업데이트 내역을 보는 직원 — 로그인했고 제한이 없을 때 그 주소, 그 밖에는 null. +/// +/// 목록 · 글은 이것을 듣고 바뀌면 처음부터 다시 받는다 — 로그아웃 · 세션 끝 · 다른 직원 로그인 뒤에 앞 세션의 늦은 응답을 쓰지 않게. +/// 로그아웃은 늘 null 을 거치므로 같은 직원이 다시 들어와도 새로 시작한다(메일의 `mailAccountProvider` 와 같은 규칙 — +/// 모듈끼리 import 하지 않아 따로 둔다) +final Provider releaseNotesAccountProvider = Provider(( + Ref ref, +) { + final SessionState? session = ref.watch(sessionProvider).value; + return session is SignedIn && session.info.restriction == null + ? session.info.email + : null; +}); diff --git a/flutter/lib/features/settings/presentation/release_notes_texts.dart b/flutter/lib/features/settings/presentation/release_notes_texts.dart new file mode 100644 index 0000000..e034ef0 --- /dev/null +++ b/flutter/lib/features/settings/presentation/release_notes_texts.dart @@ -0,0 +1,44 @@ +/// 업데이트 내역의 화면 글 — 원본은 보드 `ReleaseNotes`(`docs/design/webmail/ReleaseNotes.dc.html`)의 B(앱)다. +/// +/// 글은 보드 그대로이고, 화면 낭독기에만 읽히는 글 가운데 보드에 없는 것은 앱에서 새로 썼다(그 줄에 적었다). +/// 웹(`frontend/src/shared/constants/release-notes.ts` 의 `RELEASE_NOTES_TEXT`)과 같은 글이다 — 웹과 앱을 함께 쓰는 직원에게 같은 말. +/// 받지 못함 · 없는 글의 문구는 여기 두지 않는다 — 에러 코드 사전에서만 온다. 글 내용(제목 · 묶음 · 줄)은 서버가 주는 글 그대로 그린다. +/// 말투는 디자인 README 「화면 문구 — 사용자에게 말하듯」. +library; + +abstract final class ReleaseNotesTexts { + /// 설정 › 앱 정보의 줄 · 목록 화면의 제목 (보드 B1 · B2) + static const String title = '업데이트 내역'; + + /// 위 막대의 뒤로 — 화면 낭독기 (보드의 "뒤로") + static const String back = '뒤로'; + + /// 목록 끝 — 글이 한 쪽(20개)보다 많을 때만 (보드 B2) + static const String more = '더 보기'; + + /// [더 보기]를 누른 뒤 받는 동안 — 화면 낭독기가 단추 이름 다음에 읽는다 (앱에서 새로 쓴 글 — 보드는 바쁨 표시만) + static const String moreBusy = '불러오는 중'; + + /// 목록을 처음 받는 동안 — 화면 낭독기 (보드 B7) + static const String listLoading = '업데이트 내역 불러오는 중'; + + /// 글을 받는 동안 — 화면 낭독기 (보드 "상태와 [더 보기]" — 글 자리를 막대로, 웹과 같은 글) + static const String noteLoading = '글 불러오는 중'; + + /// 글이 하나도 없을 때 (보드 B9) + static const String empty = '아직 알려 드릴 업데이트가 없습니다.'; + + /// 없는 글의 단추 — 위 막대의 뒤로처럼 목록으로 (보드 B5) + static const String toList = '목록으로'; + + /// 글 끝의 다른 글 줄 — 바로 더 새 글(서버의 next — 위 줄) · 바로 더 옛 글(previous — 아래 줄) (보드 B3 · B4) + static const String newer = '다음 글'; + static const String older = '이전 글'; + + /// 다른 글 줄의 작은 글 — "다음 글 · 2026년 11월 18일" + static String neighborLine(String label, String date) => '$label · $date'; + + /// 그 줄을 화면 낭독기로 — 가운뎃점 대신 쉼표로 쉰다 (앱에서 새로 쓴 글) + static String neighborLineSpoken(String label, String date) => + '$label, $date'; +} diff --git a/flutter/lib/features/settings/presentation/settings_texts.dart b/flutter/lib/features/settings/presentation/settings_texts.dart index ee385f0..dab4839 100644 --- a/flutter/lib/features/settings/presentation/settings_texts.dart +++ b/flutter/lib/features/settings/presentation/settings_texts.dart @@ -1,6 +1,7 @@ /// 설정 모듈의 화면 글 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)의 글 그대로다. /// 새 메일 알림 줄의 글은 받은편지함 권하기 줄과 함께 쓰므로 `core/push/push_texts.dart` 에 있다(보드 `App1Push`). /// 앱 정보의 [새 버전 받기] 줄과 그 실패 알림은 업데이트 화면과 함께 쓰므로 `core/app_download/app_download_texts.dart` 에 있다(보드 `App1Push` ④). +/// 앱 정보의 업데이트 내역 줄 · 그 화면들의 글은 `release_notes_texts.dart` 에 있다(보드 `ReleaseNotes` B). /// /// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다. abstract final class SettingsTexts { diff --git a/flutter/lib/features/settings/presentation/widgets/release_notes_parts.dart b/flutter/lib/features/settings/presentation/widgets/release_notes_parts.dart new file mode 100644 index 0000000..bb11794 --- /dev/null +++ b/flutter/lib/features/settings/presentation/widgets/release_notes_parts.dart @@ -0,0 +1,105 @@ +/// 업데이트 내역 목록 · 글 화면이 함께 쓰는 부품 — 위 막대 · 가운데 상태 자리 · 날짜 글 (보드 `ReleaseNotes` B) +library; + +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/calendar_date.dart'; +import 'package:arasystem_app/core/utils/korean_format.dart'; +import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart'; +import 'package:flutter/material.dart'; + +/// 위 막대 — 뒤로 · (목록이면) 제목 "업데이트 내역" (보드 B2 · B3, 높이 64 · 아래 선). 글 화면은 뒤로만 둔다 +class ReleaseNotesAppBar extends StatelessWidget + implements PreferredSizeWidget { + const ReleaseNotesAppBar({required this.onBack, this.title, super.key}); + + final VoidCallback onBack; + + /// 제목 — 글 화면은 없다 + final String? title; + + @override + Size get preferredSize => const Size.fromHeight(AppSize.appBarHeight); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final String? text = title; + return AppBar( + toolbarHeight: AppSize.appBarHeight, + automaticallyImplyLeading: false, + // 뒤로(48)의 양옆 4 · 제목은 그 뒤 4 — 보드의 `padding: 0 4px` · `gap: 4px` · 제목 `padding-left: 4px` + leadingWidth: AppSize.touchMin + AppSpacing.s8, + titleSpacing: AppSpacing.s4, + shape: Border( + bottom: BorderSide( + color: extra.borderSubtle, + width: AppSize.borderWidth, + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back, size: AppSize.iconLarge), + tooltip: ReleaseNotesTexts.back, + color: extra.textStrong, + onPressed: onBack, + ), + title: text == null + ? null + : Semantics( + header: true, + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: AppTypography.bold, + ), + ), + ), + ); + } +} + +/// 가운데 상태(글 없음 · 받지 못함 · 없는 글)의 자리 — 남은 높이를 채우고 가운데에 둔다(큰 글자로 넘치면 스크롤) +class ReleaseNotesStateFill extends StatelessWidget { + const ReleaseNotesStateFill({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return CustomScrollView( + slivers: [ + SliverFillRemaining(hasScrollBody: false, child: Center(child: child)), + ], + ); + } +} + +/// 받지 못함 · 없는 글의 제목 글자 — 긴 사전 문장이라 15(보드 B5 · B6 · B8 — 메일 검색의 상태와 같다) +TextStyle? releaseNotesErrorTitleStyle(ThemeData theme) => + theme.textTheme.titleSmall?.copyWith(fontWeight: AppTypography.semiBold); + +/// 쓴 날 — "2026년 11월 18일"(달력 날짜 그대로 — 시간대를 바꾸지 않는다), 숫자는 고른 폭 +class ReleaseNoteDateText extends StatelessWidget { + const ReleaseNoteDateText(this.date, {this.style, super.key}); + + final CalendarDate date; + + /// 글자 — 색 · 숫자 폭은 이 위젯이 입힌다 + final TextStyle? style; + + @override + Widget build(BuildContext context) { + final AppExtraColors extra = Theme.of(context).extension()!; + return Text( + koreanCalendarDate(date), + style: style?.copyWith( + color: extra.textMuted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + } +} diff --git a/flutter/lib/features/settings/settings_routes.dart b/flutter/lib/features/settings/settings_routes.dart index 7c5c9d2..37cadd3 100644 --- a/flutter/lib/features/settings/settings_routes.dart +++ b/flutter/lib/features/settings/settings_routes.dart @@ -2,6 +2,8 @@ import 'package:arasystem_app/core/constants/app_constants.dart'; import 'package:arasystem_app/core/constants/app_info_provider.dart'; import 'package:arasystem_app/core/modules/app_modules.dart'; import 'package:arasystem_app/core/modules/module_routes.dart'; +import 'package:arasystem_app/features/settings/presentation/pages/release_note_page.dart'; +import 'package:arasystem_app/features/settings/presentation/pages/release_notes_page.dart'; import 'package:arasystem_app/features/settings/presentation/pages/settings_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -15,6 +17,13 @@ abstract final class SettingsRoutes { /// 오픈소스 라이선스 (전체 화면) static String get licenses => '$home/licenses'; + /// 업데이트 내역 목록 (전체 화면 — 보드 `ReleaseNotes` B) + static String get releaseNotes => '$home/release-notes'; + + /// 업데이트 내역 글 하나 (전체 화면 — 목록 위에 쌓인다). id 는 경로 칸으로 인코딩한다 + static String releaseNote(String noteId) => + '$releaseNotes/${Uri.encodeComponent(noteId)}'; + /// 다른 모듈이 내놓은 설정 절 (`/settings/`) static String section(String path) => '$home/$path'; } @@ -22,7 +31,8 @@ abstract final class SettingsRoutes { /// 설정 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다. /// /// 설정은 한 화면이다(앱 보드 `App1Shell` · `App1Push` ④ — `presentation/pages/settings_page.dart`). -/// 그 아래에는 오픈소스 라이선스(전체 화면)와 다른 모듈이 내놓은 절만 있다. +/// 그 아래에는 오픈소스 라이선스 · 업데이트 내역(목록과 그 위에 쌓이는 글 — 보드 `ReleaseNotes` B, 모두 전체 화면)과 +/// 다른 모듈이 내놓은 절만 있다. final ModuleRoutes settingsModuleRoutes = ModuleRoutes( id: AppModuleId.settings, buildRoutes: (ModuleRouteContext context) => [ @@ -30,6 +40,7 @@ final ModuleRoutes settingsModuleRoutes = ModuleRoutes( path: SettingsRoutes.home, builder: (BuildContext _, GoRouterState _) => SettingsPage( licensesLocation: SettingsRoutes.licenses, + releaseNotesLocation: SettingsRoutes.releaseNotes, sections: context.settingsSections, sectionLocation: SettingsRoutes.section, ), @@ -39,6 +50,30 @@ final ModuleRoutes settingsModuleRoutes = ModuleRoutes( parentNavigatorKey: context.rootNavigatorKey, builder: (BuildContext _, GoRouterState _) => const _LicensesPage(), ), + GoRoute( + path: 'release-notes', + parentNavigatorKey: context.rootNavigatorKey, + builder: (BuildContext _, GoRouterState _) => ReleaseNotesPage( + noteLocation: SettingsRoutes.releaseNote, + exitLocation: SettingsRoutes.home, + ), + routes: [ + GoRoute( + path: ':noteId', + parentNavigatorKey: context.rootNavigatorKey, + builder: (BuildContext _, GoRouterState state) { + final String noteId = state.pathParameters['noteId']!; + return ReleaseNotePage( + // 글마다 새 화면 — 다른 글로 바꿔 끼우면 앞 글의 상태(받기 · 스크롤)를 이어 쓰지 않는다 + key: ValueKey(noteId), + noteId: noteId, + noteLocation: SettingsRoutes.releaseNote, + listLocation: SettingsRoutes.releaseNotes, + ); + }, + ), + ], + ), for (final ModuleSettingsSection section in context.settingsSections) GoRoute(path: section.path, builder: section.builder), ], diff --git a/flutter/test/app_test.dart b/flutter/test/app_test.dart index 44aa28b..ac0bc7b 100644 --- a/flutter/test/app_test.dart +++ b/flutter/test/app_test.dart @@ -226,12 +226,22 @@ void main() { await tester.pumpAndSettle(); expect(find.widgetWithText(AppBar, SettingsTexts.home), findsOneWidget); + final Finder settingsScroll = find.descendant( + of: find.byType(SettingsPage), + matching: find.byType(Scrollable), + ); + // 아래쪽 절(문의)은 화면 밖이면 아직 그리지 않았으니 끌어 올려 찾는다(앱 정보에 업데이트 내역 줄이 늘었다) for (final String item in [ SettingsTexts.account, SettingsTexts.notifications, SettingsTexts.about, SettingsTexts.support, ]) { + await tester.scrollUntilVisible( + page(item), + 200, + scrollable: settingsScroll, + ); expect(page(item), findsOneWidget, reason: item); } @@ -240,14 +250,7 @@ void main() { OutlinedButton, SettingsTexts.signOut, ); - await tester.scrollUntilVisible( - signOut, - 200, - scrollable: find.descendant( - of: find.byType(SettingsPage), - matching: find.byType(Scrollable), - ), - ); + await tester.scrollUntilVisible(signOut, 200, scrollable: settingsScroll); await tester.pumpAndSettle(); await tester.tap(signOut); await tester.pumpAndSettle(); diff --git a/flutter/test/core/utils/calendar_date_test.dart b/flutter/test/core/utils/calendar_date_test.dart new file mode 100644 index 0000000..84d8240 --- /dev/null +++ b/flutter/test/core/utils/calendar_date_test.dart @@ -0,0 +1,64 @@ +import 'package:arasystem_app/core/utils/calendar_date.dart'; +import 'package:arasystem_app/core/utils/korean_format.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 달력 날짜 — 시각이 아닌 "그날"(업데이트 내역 글의 쓴 날 — 계약 7절). 시간대를 바꾸지 않는다 +void main() { + group('CalendarDate.tryParse', () { + test('`YYYY-MM-DD` 를 연 · 월 · 일로 읽는다', () { + expect( + CalendarDate.tryParse('2026-11-18'), + const CalendarDate(2026, 11, 18), + ); + expect( + CalendarDate.tryParse('2027-01-05'), + const CalendarDate(2027, 1, 5), + ); + expect( + CalendarDate.tryParse('2028-02-29'), + const CalendarDate(2028, 2, 29), + reason: '윤년', + ); + }); + + test('없는 날 · 모양이 다른 글은 null', () { + for (final Object? value in [ + '2026-02-29', + '2026-02-30', + '2026-04-31', + '2026-13-01', + '2026-00-10', + '2026-11-00', + '2026-1-5', + '2026-11-18-2', + '2026-11-18T00:00:00Z', + ' 2026-11-18', + '2026-11-18', + '', + null, + 20261118, + ]) { + expect(CalendarDate.tryParse(value), isNull, reason: '$value'); + } + }); + + test('글로 되돌리면 서버가 준 모양 그대로', () { + expect(CalendarDate.tryParse('2026-01-05').toString(), '2026-01-05'); + }); + }); + + group('koreanCalendarDate — 웹 { year · month: long · day } 와 같은 글', () { + test('"2026년 11월 18일" — 앞자리 0 없이', () { + expect( + koreanCalendarDate(const CalendarDate(2026, 11, 18)), + '2026년 11월 18일', + ); + expect(koreanCalendarDate(const CalendarDate(2027, 1, 5)), '2027년 1월 5일'); + }); + + test('시간대와 상관없이 그날 — 시각으로 바꾸지 않는다(UTC 자정이면 음수 시간대에서 하루 앞이 된다)', () { + final CalendarDate date = CalendarDate.tryParse('2026-11-01')!; + expect(koreanCalendarDate(date), '2026년 11월 1일'); + }); + }); +} diff --git a/flutter/test/features/settings/release_notes_page_test.dart b/flutter/test/features/settings/release_notes_page_test.dart new file mode 100644 index 0000000..eab47f2 --- /dev/null +++ b/flutter/test/features/settings/release_notes_page_test.dart @@ -0,0 +1,477 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart'; +import 'package:arasystem_app/features/settings/presentation/pages/release_note_page.dart'; +import 'package:arasystem_app/features/settings/presentation/pages/release_notes_page.dart'; +import 'package:arasystem_app/features/settings/presentation/pages/settings_page.dart'; +import 'package:arasystem_app/features/settings/presentation/providers/release_notes_list_controller.dart'; +import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart'; +import 'package:arasystem_app/features/settings/settings_routes.dart'; +import 'package:arasystem_app/shared/widgets/load_state_texts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/mail_harness.dart'; +import '../../support/push_screen_harness.dart'; +import '../../support/release_notes_fakes.dart'; + +/// 업데이트 내역 — 보드 `ReleaseNotes` B(목록 · 글 · 상태). 로그인한 앱 전체를 가짜 서버로 띄워 본다 +void main() { + late MailAppHarness h; + late FakeReleaseNotesBackend notes; + + setUp(() { + h = MailAppHarness(); + notes = FakeReleaseNotesBackend(h.server)..install(); + }); + + /// 앱을 띄워 [location] 까지 그리고 응답이 모두 올 때까지 + Future open(WidgetTester tester, String location) async { + await h.pump(tester, location: location); + await settle(tester); + } + + Future openList(WidgetTester tester) => + open(tester, SettingsRoutes.releaseNotes); + + ReleaseNotesListState listState() => + h.container.read(releaseNotesListProvider); + + /// 글 화면의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다 + String noteLocation(WidgetTester tester) => GoRouterState.of( + tester.element(find.byType(ReleaseNotePage)), + ).uri.toString(); + + Finder moreButton() => + find.widgetWithText(OutlinedButton, ReleaseNotesTexts.more); + + Finder neighbor(String label, String date) => + find.text(ReleaseNotesTexts.neighborLine(label, date)); + + Future scrollTo(WidgetTester tester, Finder finder) async { + await tester.scrollUntilVisible( + finder, + 500, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + } + + Future tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await settle(tester); + } + + Future back(WidgetTester tester) async { + await tester.tap(find.byTooltip(ReleaseNotesTexts.back)); + await settle(tester); + } + + group('목록 — 보드 B2', () { + testWidgets('최근 글부터 줄마다 제목 · 날짜 · ›, 한 쪽(20개)에 다 들면 [더 보기]가 없다', ( + WidgetTester tester, + ) async { + await openList(tester); + + expect(find.byType(ReleaseNotesPage), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing, reason: '탭 막대 없는 전체 화면'); + expect(find.text(ReleaseNotesTexts.title), findsOneWidget); + final List tops = [ + for (final FakeReleaseNote note in kBoardReleaseNotes) + tester.getTopLeft(findCopy(note.title)).dy, + ]; + for (int index = 1; index < tops.length; index++) { + expect(tops[index - 1], lessThan(tops[index]), reason: '최근 글부터'); + } + for (final String date in [ + '2026년 11월 18일', + '2026년 11월 5일', + '2026년 11월 4일', + '2026년 11월 3일', + ]) { + expect(find.text(date), findsOneWidget, reason: date); + } + expect(find.byIcon(Icons.chevron_right), findsNWidgets(4)); + expect(moreButton(), findsNothing); + final RequestOptions request = notes.listRequests.single; + expect(request.queryParameters['limit'], kReleaseNotesPageSize); + expect(request.queryParameters.containsKey('cursor'), isFalse); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('줄은 최소 68 이고 화면 낭독기는 제목 · 날짜를 단추 하나로 읽는다', ( + WidgetTester tester, + ) async { + await openList(tester); + + final Finder row = find.ancestor( + of: findCopy(kBoardReleaseNotes.first.title), + matching: find.byType(InkWell), + ); + expect(tester.getSize(row).height, greaterThanOrEqualTo(68)); + expect( + tester.getSemantics(row), + isSemantics( + label: '${kBoardReleaseNotes.first.title}\n2026년 11월 18일', + isButton: true, + ), + ); + }); + + testWidgets('[더 보기] — 다음 쪽을 뒤에 붙인다. 받는 동안은 그 단추만 바쁨 표시이고 다시 누르지 못한다', ( + WidgetTester tester, + ) async { + notes.seed(25); + await openList(tester); + expect(listState().items, hasLength(20)); + await scrollTo(tester, moreButton()); + + final Completer gate = Completer(); + notes.listGate = gate; + await tester.tap(moreButton()); + // 요청이 가짜 서버에 닿을 때까지(시계가 가야 온다) — 도는 표시가 있어 pumpAndSettle 은 쓰지 않는다 + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(notes.listRequests, hasLength(2)); + expect( + notes.listRequests.last.queryParameters['cursor'], + '${FakeReleaseNotesBackend.cursorPrefix}20', + ); + expect( + find.descendant( + of: moreButton(), + matching: find.byType(CircularProgressIndicator), + ), + findsOneWidget, + ); + expect( + tester.getSemantics(find.bySemanticsLabel(ReleaseNotesTexts.more)), + isSemantics( + label: ReleaseNotesTexts.more, + value: ReleaseNotesTexts.moreBusy, + isButton: true, + hasEnabledState: true, + isEnabled: false, + ), + ); + + // 받는 동안 다시 눌러도 겹쳐 보내지 않는다 + await tester.tap(moreButton()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(notes.listRequests, hasLength(2)); + expect(listState().loadingMore, isTrue); + + notes.listGate = null; + gate.complete(); + await settle(tester); + + expect(listState().items, hasLength(25)); + await scrollTo(tester, findCopy('업데이트 1')); + expect(moreButton(), findsNothing, reason: '마지막 쪽'); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('[더 보기]를 받지 못하면 알리고 받은 줄은 그대로 — 다시 누르면 받는다', ( + WidgetTester tester, + ) async { + notes.seed(25); + await openList(tester); + await scrollTo(tester, moreButton()); + notes.nextListResponse = FakeResponse.fail(500, 'SYS_001'); + + await tester.tap(moreButton()); + await settle(tester); + + expect(h.notifier.shownCodes, ['SYS_001']); + expect(listState().items, hasLength(20)); + expect(listState().loadingMore, isFalse); + expect( + find.descendant( + of: moreButton(), + matching: find.byType(CircularProgressIndicator), + ), + findsNothing, + ); + + await tester.tap(moreButton()); + await settle(tester); + + expect(listState().items, hasLength(25)); + }); + + testWidgets('[더 보기] 응답의 모양이 틀리면 화면이 알린다(SYS_001) — 받은 줄은 그대로', ( + WidgetTester tester, + ) async { + notes.seed(25); + await openList(tester); + await scrollTo(tester, moreButton()); + notes.nextListResponse = FakeResponse.ok({ + 'items': '모양이 틀림', + }); + + await tester.tap(moreButton()); + await settle(tester); + + expect(h.notifier.shownCodes, ['SYS_001']); + expect(listState().items, hasLength(20)); + expect(moreButton(), findsOneWidget); + }); + + testWidgets('뒤로 — 설정 탭으로 돌아온다', (WidgetTester tester) async { + await openList(tester); + + await back(tester); + + expect(find.byType(ReleaseNotesPage), findsNothing); + expect(find.byType(SettingsPage), findsOneWidget); + expect(find.byType(NavigationBar), findsOneWidget); + expect(h.location(), SettingsRoutes.home); + }); + }); + + group('목록의 상태 — 보드 B7 · B8 · B9', () { + testWidgets('받는 동안 줄 자리를 막대로 잡아 둔다 — 화면 낭독기는 "업데이트 내역 불러오는 중"', ( + WidgetTester tester, + ) async { + final Completer gate = Completer(); + notes.listGate = gate; + await h.pump(tester, location: SettingsRoutes.releaseNotes); + + expect( + find.bySemanticsLabel(ReleaseNotesTexts.listLoading), + findsOneWidget, + ); + expect(findCopy(kBoardReleaseNotes.first.title), findsNothing); + + notes.listGate = null; + gate.complete(); + await settle(tester); + + expect( + find.bySemanticsLabel(ReleaseNotesTexts.listLoading), + findsNothing, + ); + expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget); + }); + + testWidgets( + '받지 못하면 그 자리에 사전 문구 · 문의 코드 · [다시 시도] — 알리지 않고, [다시 시도]면 다시 받는다', + (WidgetTester tester) async { + notes.nextListResponse = FakeResponse.fail(500, 'SYS_001'); + await openList(tester); + + expect(findCopy(ErrorLexicon.getMessage('SYS_001')), findsOneWidget); + expect(find.text(LoadStateTexts.traceCode('trace-SY')), findsOneWidget); + expect(find.text(LoadStateTexts.retry), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + + await tester.tap(find.text(LoadStateTexts.retry)); + await settle(tester); + + expect(notes.listRequests, hasLength(2)); + expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + testWidgets('목록은 빠져나갈 곳이 없어 없는 경로(404)여도 [다시 시도]다', ( + WidgetTester tester, + ) async { + notes.nextListResponse = FakeResponse.fail(404, 'RES_001'); + await openList(tester); + + expect(findCopy(ErrorLexicon.getMessage('RES_001')), findsOneWidget); + expect(find.text(LoadStateTexts.retry), findsOneWidget); + expect(find.text(ReleaseNotesTexts.toList), findsNothing); + }); + + testWidgets('글이 없으면 한 줄 — "아직 알려 드릴 업데이트가 없습니다."', ( + WidgetTester tester, + ) async { + notes.notes = const []; + await openList(tester); + + expect(findCopy(ReleaseNotesTexts.empty), findsOneWidget); + expect(find.byIcon(Icons.description_outlined), findsOneWidget); + expect(moreButton(), findsNothing); + expect(find.text(LoadStateTexts.retry), findsNothing); + }); + }); + + group('글 — 보드 B3 · B4', () { + testWidgets('줄을 누르면 글 — 날짜 · 제목 · 묶음(머리 · 점 목록), 맨 위 글은 이전 글 줄만', ( + WidgetTester tester, + ) async { + await openList(tester); + + await tapVisible(tester, findCopy(kBoardReleaseNotes.first.title)); + + expect(find.byType(ReleaseNotePage), findsOneWidget); + expect(noteLocation(tester), SettingsRoutes.releaseNote('2026-11-18')); + expect( + find.byType(ReleaseNotesPage, skipOffstage: false), + findsOneWidget, + reason: '글은 목록 위에 하나만 쌓인다', + ); + expect(find.byType(NavigationBar), findsNothing); + expect(notes.noteRequests, ['2026-11-18']); + expect(find.text('2026년 11월 18일'), findsOneWidget); + expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget); + expect(findCopy('휴대폰 앱'), findsOneWidget); + expect(findCopy('메일 검색'), findsOneWidget); + expect( + findCopy('안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.'), + findsOneWidget, + ); + expect(findCopy('찾은 말에는 노란 표시가 붙습니다.'), findsOneWidget); + expect( + tester.getTopLeft(findCopy('휴대폰 앱')).dy, + lessThan(tester.getTopLeft(findCopy('메일 검색')).dy), + ); + await tester.ensureVisible( + neighbor(ReleaseNotesTexts.older, '2026년 11월 5일'), + ); + await tester.pumpAndSettle(); + expect(neighbor(ReleaseNotesTexts.older, '2026년 11월 5일'), findsOneWidget); + expect(find.textContaining(ReleaseNotesTexts.newer), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('가운데 글 — 다음 글(더 새 글)이 위 · 이전 글(더 옛 글)이 아래, 머리 없는 묶음은 줄만', ( + WidgetTester tester, + ) async { + await open(tester, SettingsRoutes.releaseNote('2026-11-05')); + + expect(findCopy('화면의 안내 글을 다듬었습니다'), findsOneWidget); + expect(findCopy('단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.'), findsOneWidget); + final Finder newer = neighbor(ReleaseNotesTexts.newer, '2026년 11월 18일'); + final Finder older = neighbor(ReleaseNotesTexts.older, '2026년 11월 4일'); + expect(newer, findsOneWidget); + expect(older, findsOneWidget); + expect( + tester.getTopLeft(newer).dy, + lessThan(tester.getTopLeft(older).dy), + ); + expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget); + expect(findCopy('왼쪽 메뉴를 정리했습니다'), findsOneWidget); + final Finder row = find.ancestor( + of: newer, + matching: find.byType(InkWell), + ); + expect(tester.getSize(row).height, greaterThanOrEqualTo(64)); + expect( + tester.getSemantics(row), + isSemantics( + label: + '${ReleaseNotesTexts.newer}, 2026년 11월 18일\n' + '${kBoardReleaseNotes.first.title}', + isButton: true, + ), + ); + }); + + testWidgets('다른 글 줄을 누르면 그 글로 바꿔 끼운다 — 뒤로는 늘 목록', ( + WidgetTester tester, + ) async { + await openList(tester); + await tapVisible(tester, findCopy(kBoardReleaseNotes.first.title)); + + await tapVisible( + tester, + neighbor(ReleaseNotesTexts.older, '2026년 11월 5일'), + ); + + expect(find.byType(ReleaseNotePage, skipOffstage: false), findsOneWidget); + expect(noteLocation(tester), SettingsRoutes.releaseNote('2026-11-05')); + expect(findCopy('단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.'), findsOneWidget); + expect(notes.noteRequests, ['2026-11-18', '2026-11-05']); + + await back(tester); + + expect(find.byType(ReleaseNotePage), findsNothing); + expect(find.byType(ReleaseNotesPage), findsOneWidget); + expect(findCopy(kBoardReleaseNotes.last.title), findsOneWidget); + }); + + testWidgets('주소로 바로 연 글도 뒤로는 목록이다', (WidgetTester tester) async { + await open(tester, SettingsRoutes.releaseNote('2026-11-04')); + expect(findCopy('왼쪽 메뉴를 정리했습니다'), findsOneWidget); + + await back(tester); + + expect(find.byType(ReleaseNotesPage), findsOneWidget); + expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget); + }); + }); + + group('글의 상태 — 보드 B5 · B6', () { + testWidgets('없는 글(404 RES_001)은 알리지 않고 사전 문구 · 문의 코드 · [목록으로] — 누르면 목록', ( + WidgetTester tester, + ) async { + await open(tester, SettingsRoutes.releaseNote('2026-01-01')); + + expect(findCopy(ErrorLexicon.getMessage('RES_001')), findsOneWidget); + expect(find.text(LoadStateTexts.traceCode('trace-RE')), findsOneWidget); + expect(find.text(ReleaseNotesTexts.toList), findsOneWidget); + expect(find.text(LoadStateTexts.retry), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + + await tester.tap(find.text(ReleaseNotesTexts.toList)); + await settle(tester); + + expect(find.byType(ReleaseNotePage), findsNothing); + expect(find.byType(ReleaseNotesPage), findsOneWidget); + expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget); + }); + + testWidgets('그 밖의 실패는 사전 문구 · 문의 코드 · [다시 시도] — 다시 받으면 글', ( + WidgetTester tester, + ) async { + notes.nextNoteResponse = FakeResponse.fail(500, 'SYS_001'); + await open(tester, SettingsRoutes.releaseNote('2026-11-05')); + + expect(findCopy(ErrorLexicon.getMessage('SYS_001')), findsOneWidget); + expect(find.text(LoadStateTexts.traceCode('trace-SY')), findsOneWidget); + expect(find.text(ReleaseNotesTexts.toList), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + + await tester.tap(find.text(LoadStateTexts.retry)); + await settle(tester); + + expect(notes.noteRequests, ['2026-11-05', '2026-11-05']); + expect(findCopy('화면의 안내 글을 다듬었습니다'), findsOneWidget); + }); + + testWidgets('받는 동안 글 자리를 막대로 잡아 둔다 — 화면 낭독기는 "글 불러오는 중"', ( + WidgetTester tester, + ) async { + final Completer gate = Completer(); + notes.noteGate = gate; + await h.pump(tester, location: SettingsRoutes.releaseNote('2026-11-05')); + + expect( + find.bySemanticsLabel(ReleaseNotesTexts.noteLoading), + findsOneWidget, + ); + + notes.noteGate = null; + gate.complete(); + await settle(tester); + + expect( + find.bySemanticsLabel(ReleaseNotesTexts.noteLoading), + findsNothing, + ); + expect(findCopy('화면의 안내 글을 다듬었습니다'), findsOneWidget); + }); + }); +} diff --git a/flutter/test/features/settings/release_notes_test.dart b/flutter/test/features/settings/release_notes_test.dart new file mode 100644 index 0000000..b124e42 --- /dev/null +++ b/flutter/test/features/settings/release_notes_test.dart @@ -0,0 +1,483 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/utils/calendar_date.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/settings/data/models/release_note.dart'; +import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart'; +import 'package:arasystem_app/features/settings/presentation/providers/release_note_controller.dart'; +import 'package:arasystem_app/features/settings/presentation/providers/release_notes_list_controller.dart'; +import 'package:arasystem_app/features/settings/presentation/providers/release_notes_providers.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/provider_harness.dart'; +import '../../support/release_notes_fakes.dart'; + +/// 업데이트 내역을 보는 직원 — 시험이 바꾼다(세션이 바뀜) +class _Account extends Notifier { + @override + String? build() => 'ara.kim@arasystem.it.kr'; + + void set(String? value) => state = value; +} + +final NotifierProvider<_Account, String?> _accountProvider = + NotifierProvider<_Account, String?>(_Account.new); + +/// 끝난 요청을 세는 저장소 — 늦은 응답이 컨트롤러에 닿은 뒤를 기다리려고(고정 대기 없이) +class _RecordingRepository extends ReleaseNotesRepository { + _RecordingRepository(super.dio); + + int listsFinished = 0; + int notesFinished = 0; + + @override + Future listNotes({ + String? cursor, + int limit = kReleaseNotesPageSize, + bool silent = false, + }) async { + try { + return await super.listNotes( + cursor: cursor, + limit: limit, + silent: silent, + ); + } finally { + listsFinished++; + } + } + + @override + Future getNote(String id) async { + try { + return await super.getNote(id); + } finally { + notesFinished++; + } + } +} + +/// 업데이트 내역 — 모델 읽기 · 저장소 · 목록 · 글 컨트롤러 (계약 7절) +void main() { + group('모델 읽기', () { + Map summary(String id, {String? date, String? title}) => + { + 'id': id, + 'date': date ?? id.substring(0, 10), + 'title': title ?? '제목 $id', + }; + + test('목록 한 쪽 — 줄 · 다음 쪽 · 글 수', () { + final ReleaseNotesPage page = ReleaseNotesPage.tryParse({ + 'items': [summary('2026-11-18-2'), summary('2026-11-18')], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'abc', + 'total': 41, + }, + })!; + + expect(page.items.map((ReleaseNoteSummary n) => n.id), [ + '2026-11-18-2', + '2026-11-18', + ]); + expect(page.items.first.date, const CalendarDate(2026, 11, 18)); + expect(page.items.first.title, '제목 2026-11-18-2'); + expect(page.hasMore, isTrue); + expect(page.total, 41); + }); + + test('마지막 쪽 — 커서가 없으면 더 없다(hasNext 여도) · 글 수는 없어도 된다', () { + final ReleaseNotesPage last = ReleaseNotesPage.tryParse({ + 'items': [], + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + })!; + expect(last.items, isEmpty); + expect(last.hasMore, isFalse); + expect(last.total, isNull); + + final ReleaseNotesPage noCursor = ReleaseNotesPage.tryParse( + { + 'items': [], + 'pageInfo': {'hasNext': true, 'nextCursor': ''}, + }, + )!; + expect(noCursor.hasMore, isFalse); + }); + + test('모양이 틀리면 null — 줄 하나라도(없는 날 · 빈 id · 숫자 제목)', () { + Map pageOf(List items) => { + 'items': items, + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }; + expect( + ReleaseNotesPage.tryParse( + pageOf([summary('2026-11-18'), summary('2026-02-30')]), + ), + isNull, + ); + expect( + ReleaseNotesPage.tryParse( + pageOf([summary('', date: '2026-11-18')]), + ), + isNull, + ); + expect( + ReleaseNotesPage.tryParse( + pageOf([ + { + 'id': '2026-11-18', + 'date': '2026-11-18', + 'title': 3, + }, + ]), + ), + isNull, + ); + expect( + ReleaseNotesPage.tryParse({'items': []}), + isNull, + ); + expect(ReleaseNotesPage.tryParse('목록'), isNull); + }); + + test('글 — 묶음(머리 없는 묶음 포함) · 바로 더 옛 글 · 바로 더 새 글', () { + final ReleaseNote note = ReleaseNote.tryParse({ + ...summary('2026-11-05', title: '화면의 안내 글을 다듬었습니다'), + 'sections': [ + { + 'heading': '메일 · 메일함', + 'items': ['한 줄', '두 줄'], + }, + { + 'heading': null, + 'items': ['머리 없는 줄'], + }, + ], + 'previous': summary('2026-11-04'), + 'next': summary('2026-11-18'), + })!; + + expect(note.id, '2026-11-05'); + expect(note.date, const CalendarDate(2026, 11, 5)); + expect(note.title, '화면의 안내 글을 다듬었습니다'); + expect(note.sections, const [ + ReleaseNoteSection(heading: '메일 · 메일함', items: ['한 줄', '두 줄']), + ReleaseNoteSection(heading: null, items: ['머리 없는 줄']), + ]); + expect(note.previous?.id, '2026-11-04'); + expect(note.next?.id, '2026-11-18'); + }); + + test('다른 글이 없으면(null) · 칸이 아예 없으면 없는 것', () { + final ReleaseNote first = ReleaseNote.tryParse({ + ...summary('2026-11-18'), + 'sections': [], + 'previous': null, + 'next': null, + })!; + expect(first.previous, isNull); + expect(first.next, isNull); + + final ReleaseNote old = ReleaseNote.tryParse({ + ...summary('2026-11-18'), + 'sections': [], + })!; + expect(old.previous, isNull); + expect(old.next, isNull); + }); + + test('글의 모양이 틀리면 null — 묶음 · 다른 글 하나라도', () { + Map noteWith(Map extra) => + { + ...summary('2026-11-18'), + 'sections': [], + ...extra, + }; + expect( + ReleaseNote.tryParse( + noteWith({ + 'sections': [ + { + 'heading': 1, + 'items': ['줄'], + }, + ], + }), + ), + isNull, + ); + expect( + ReleaseNote.tryParse( + noteWith({ + 'sections': [ + { + 'heading': null, + 'items': ['줄', null], + }, + ], + }), + ), + isNull, + ); + expect( + ReleaseNote.tryParse( + noteWith({ + 'previous': {'id': '2026-11-04'}, + }), + ), + isNull, + ); + expect( + ReleaseNote.tryParse(noteWith({'next': '2026-11-19'})), + isNull, + ); + expect( + ReleaseNote.tryParse(summary('2026-11-18')), + isNull, + reason: '묶음 없음', + ); + }); + }); + + group('저장소 · 컨트롤러', () { + late ProviderHarness h; + late FakeReleaseNotesBackend notes; + late ProviderContainer container; + late _RecordingRepository recording; + + setUp(() async { + h = ProviderHarness(); + notes = FakeReleaseNotesBackend(h.server)..install(); + await h.prepare(); + await h.store.save(tokensFor(1, now: h.clock.now)); + container = ProviderContainer( + overrides: [ + ...h.overrides, + releaseNotesAccountProvider.overrideWith( + (Ref ref) => ref.watch(_accountProvider), + ), + releaseNotesRepositoryProvider.overrideWith( + (Ref ref) => + recording = _RecordingRepository(ref.watch(dioProvider)), + ), + ], + retry: noAutomaticRetry, + ); + addTearDown(container.dispose); + }); + + ReleaseNotesRepository repository() => + container.read(releaseNotesRepositoryProvider); + + /// 목록을 열고(화면이 보는 것처럼 구독한다) 첫 쪽이 끝날 때까지 기다린다 + Future openList() async { + final ProviderSubscription subscription = container + .listen( + releaseNotesListProvider, + (ReleaseNotesListState? previous, ReleaseNotesListState next) {}, + ); + addTearDown(subscription.close); + await pumpEventsUntil( + () => + container.read(releaseNotesListProvider).status != + ReleaseNotesListStatus.loading, + reason: '첫 쪽', + ); + } + + ReleaseNotesListState listState() => + container.read(releaseNotesListProvider); + + /// [condition] 이 된 뒤 한 차례 더 — 응답을 받은 컨트롤러가 상태를 바꿀 자리까지 + Future settleAfter(bool Function() condition) async { + await pumpEventsUntil(condition); + await Future.delayed(Duration.zero); + } + + test( + '목록 — `GET /release-notes?limit=20`, 다음 쪽은 받은 커서 그대로 · id 는 경로 칸으로 인코딩', + () async { + notes.seed(3); + final ReleaseNotesPage first = await repository().listNotes(); + final ReleaseNotesPage next = await repository().listNotes( + cursor: 'n2', + ); + await repository().getNote('2026-11-30'); + + expect(first.items, hasLength(3)); + expect(next.items.single.id, '2026-11-28'); + expect(notes.listRequests.first.queryParameters, { + 'limit': kReleaseNotesPageSize, + }); + expect(notes.listRequests.last.queryParameters['cursor'], 'n2'); + expect( + ReleaseNotesApiPaths.releaseNote('2026/11 18'), + '/release-notes/2026%2F11%2018', + ); + expect(notes.noteRequests, ['2026-11-30']); + }, + ); + + test( + '응답 모양이 틀리면 SYS_001 — 인터셉터를 다 지난 뒤라 따로 가른다(ReleaseNotesMalformedResponse)', + () async { + notes.nextListResponse = FakeResponse.ok({'items': 1}); + final Object error = await errorOf(repository().listNotes()); + + expect(error, isA()); + expect(apiErrorOf(error).code, 'SYS_001'); + expect(h.notifier.shownCodes, isEmpty, reason: '저장소는 알리지 않는다'); + }, + ); + + test('글 — 없는 글은 404 RES_001 이고 알리지 않는다', () async { + final Object error = await errorOf(repository().getNote('2026-01-01')); + + expect(apiErrorOf(error).code, 'RES_001'); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('첫 쪽은 알리지 않는다 — 받지 못함을 상태에 두고 [다시 시도]로 다시 받는다', () async { + notes.nextListResponse = FakeResponse.fail(503, 'MAIL_001'); + await openList(); + + expect(listState().status, ReleaseNotesListStatus.failed); + expect(listState().error?.code, 'MAIL_001'); + expect(h.notifier.shownCodes, isEmpty); + + await container.read(releaseNotesListProvider.notifier).retry(); + + expect(listState().status, ReleaseNotesListStatus.ready); + expect(listState().items, hasLength(kBoardReleaseNotes.length)); + }); + + test('[더 보기]의 겹친 글은 건너뛴다 — 그사이 글이 더해져 자리가 밀렸을 때', () async { + notes.seed(21); + await openList(); + // 새 글이 맨 위에 더해졌다 — 다음 쪽(21번째 자리부터)에 이미 받은 마지막 줄이 다시 온다 + notes.notes = [ + const FakeReleaseNote('2026-12-01', '업데이트 22'), + ...notes.notes, + ]; + + await container.read(releaseNotesListProvider.notifier).loadMore(); + + final List ids = [ + for (final ReleaseNoteSummary note in listState().items) note.id, + ]; + expect(ids, hasLength(21)); + expect(ids.toSet(), hasLength(21)); + expect(listState().hasMore, isFalse); + }); + + test('세션이 바뀌면 처음부터 — 앞 세션의 늦은 응답은 버린다', () async { + notes.seed(2); + final Completer late = Completer(); + int calls = 0; + notes.onList = (RequestOptions options) async { + calls++; + if (calls == 1) { + await late.future; + return FakeResponse.ok({ + 'items': [ + const FakeReleaseNote('2020-01-01', '앞 세션의 글').summaryJson(), + ], + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }); + } + return notes.listPage(options); + }; + final ProviderSubscription subscription = container + .listen( + releaseNotesListProvider, + (ReleaseNotesListState? previous, ReleaseNotesListState next) {}, + ); + addTearDown(subscription.close); + await pumpEventsUntil(() => notes.listRequests.length == 1); + + container.read(_accountProvider.notifier).set('other@arasystem.it.kr'); + await pumpEventsUntil( + () => listState().status == ReleaseNotesListStatus.ready, + reason: '새 세션의 첫 쪽', + ); + expect(notes.listRequests, hasLength(2)); + expect(listState().items.map((ReleaseNoteSummary n) => n.title), [ + '업데이트 2', + '업데이트 1', + ]); + + late.complete(); + await settleAfter(() => recording.listsFinished == 2); + + expect(listState().status, ReleaseNotesListStatus.ready); + expect(listState().items.map((ReleaseNoteSummary n) => n.title), [ + '업데이트 2', + '업데이트 1', + ]); + }); + + test('로그인하지 않았으면(세션 없음) 묻지 않는다', () async { + container.read(_accountProvider.notifier).set(null); + final ProviderSubscription list = container.listen( + releaseNotesListProvider, + (ReleaseNotesListState? previous, ReleaseNotesListState next) {}, + ); + addTearDown(list.close); + final ProviderSubscription note = container.listen( + releaseNoteProvider('2026-11-18'), + (ReleaseNoteState? previous, ReleaseNoteState next) {}, + ); + addTearDown(note.close); + await Future.delayed(Duration.zero); + + expect(listState().status, ReleaseNotesListStatus.loading); + expect(notes.listRequests, isEmpty); + expect(notes.noteRequests, isEmpty); + }); + + test('글 — 세션이 바뀌면 다시 받고, 앞 세션의 늦은 응답(실패)은 버린다', () async { + final Completer late = Completer(); + int calls = 0; + notes.onNote = (RequestOptions options) async { + calls++; + if (calls == 1) { + await late.future; + return FakeResponse.fail(500, 'SYS_001'); + } + return notes.noteDetail('2026-11-05'); + }; + final ProviderSubscription subscription = container + .listen( + releaseNoteProvider('2026-11-05'), + (ReleaseNoteState? previous, ReleaseNoteState next) {}, + ); + addTearDown(subscription.close); + await pumpEventsUntil(() => notes.noteRequests.length == 1); + + container.read(_accountProvider.notifier).set('other@arasystem.it.kr'); + await pumpEventsUntil( + () => + container.read(releaseNoteProvider('2026-11-05')).status == + ReleaseNoteStatus.ready, + reason: '새 세션의 글', + ); + expect(notes.noteRequests, hasLength(2)); + + late.complete(); + await settleAfter(() => recording.notesFinished == 2); + + final ReleaseNoteState state = container.read( + releaseNoteProvider('2026-11-05'), + ); + expect(state.status, ReleaseNoteStatus.ready); + expect(state.note?.title, '화면의 안내 글을 다듬었습니다'); + expect(state.error, isNull); + }); + }); +} diff --git a/flutter/test/features/settings/settings_page_test.dart b/flutter/test/features/settings/settings_page_test.dart index f34d779..fa7b518 100644 --- a/flutter/test/features/settings/settings_page_test.dart +++ b/flutter/test/features/settings/settings_page_test.dart @@ -8,6 +8,9 @@ import 'package:arasystem_app/core/push/push_messaging.dart'; import 'package:arasystem_app/core/push/push_preferences.dart'; import 'package:arasystem_app/core/push/push_texts.dart'; import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; +import 'package:arasystem_app/features/settings/presentation/pages/release_notes_page.dart'; +import 'package:arasystem_app/features/settings/presentation/pages/settings_page.dart'; +import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart'; import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -16,6 +19,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../support/fakes.dart'; import '../../support/login_fakes.dart'; import '../../support/push_screen_harness.dart'; +import '../../support/release_notes_fakes.dart'; /// 설정 탭 — 앱 보드 `App1Shell`(설정 한 화면 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄) void main() { @@ -102,6 +106,31 @@ void main() { expect(find.byType(LicensePage), findsOneWidget); expect(find.byType(NavigationBar), findsNothing); }); + + testWidgets( + '[업데이트 내역]은 탭 막대 없이 전체 화면 목록으로 연다 — 뒤로 오면 설정 (보드 ReleaseNotes B1)', + (WidgetTester tester) async { + final FakeReleaseNotesBackend notes = FakeReleaseNotesBackend(h.server) + ..install(); + await h.pump(tester); + await h.openSettings(tester); + + await tapVisible(tester, find.text(ReleaseNotesTexts.title)); + + expect(find.byType(ReleaseNotesPage), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget); + expect(notes.listRequests, hasLength(1)); + + await tester.tap(find.byTooltip(ReleaseNotesTexts.back)); + await settle(tester); + + expect(find.byType(ReleaseNotesPage), findsNothing); + expect(find.byType(SettingsPage), findsOneWidget); + expect(find.byType(NavigationBar), findsOneWidget); + expect(h.app.location(), '/settings'); + }, + ); }); group('설정 › 앱 정보 › [새 버전 받기] — 앱 보드 App1Push ④', () { @@ -139,42 +168,63 @@ void main() { Finder snack(String text) => find.descendant(of: find.byType(SnackBar), matching: findCopy(text)); - testWidgets('줄 차례 — 앱 버전 · 새 버전 받기 · 오픈소스 라이선스, 끝 아이콘은 바깥으로 여는 아이콘', ( - WidgetTester tester, - ) async { - await h.pump(tester); - await h.openSettings(tester); + testWidgets( + '줄 차례 — 앱 버전 · 업데이트 내역 · 새 버전 받기 · 오픈소스 라이선스, 끝 아이콘은 바깥으로 여는 아이콘', + (WidgetTester tester) async { + await h.pump(tester); + await h.openSettings(tester); - final double version = tester - .getTopLeft(find.text(SettingsTexts.appVersion)) - .dy; - final double download = tester - .getTopLeft(find.text(AppDownloadTexts.getNewVersion)) - .dy; - final double licenses = tester - .getTopLeft(find.text(SettingsTexts.licenses)) - .dy; - expect(version, lessThan(download)); - expect(download, lessThan(licenses)); - expect( - rowIcon(AppDownloadTexts.getNewVersion, Icons.open_in_new), - findsOneWidget, - ); - expect( - rowIcon(AppDownloadTexts.getNewVersion, Icons.chevron_right), - findsNothing, - ); - expect( - rowIcon(SettingsTexts.licenses, Icons.chevron_right), - findsOneWidget, - ); - expect( - tester.getSize(downloadRow()).height, - greaterThanOrEqualTo(52), - reason: '한 줄 칸 52 — 앱 버전 · 오픈소스 라이선스와 같다', - ); - expect(launched, isEmpty); - }); + final double version = tester + .getTopLeft(find.text(SettingsTexts.appVersion)) + .dy; + final double releaseNotes = tester + .getTopLeft(find.text(ReleaseNotesTexts.title)) + .dy; + final double download = tester + .getTopLeft(find.text(AppDownloadTexts.getNewVersion)) + .dy; + final double licenses = tester + .getTopLeft(find.text(SettingsTexts.licenses)) + .dy; + expect(version, lessThan(releaseNotes)); + expect(releaseNotes, lessThan(download)); + expect(download, lessThan(licenses)); + expect( + rowIcon(ReleaseNotesTexts.title, Icons.chevron_right), + findsOneWidget, + reason: '앱 안의 화면 — 오픈소스 라이선스 줄과 같은 꼴', + ); + expect( + tester + .getSize( + find.ancestor( + of: find.text(ReleaseNotesTexts.title), + matching: find.byType(InkWell), + ), + ) + .height, + greaterThanOrEqualTo(52), + ); + expect( + rowIcon(AppDownloadTexts.getNewVersion, Icons.open_in_new), + findsOneWidget, + ); + expect( + rowIcon(AppDownloadTexts.getNewVersion, Icons.chevron_right), + findsNothing, + ); + expect( + rowIcon(SettingsTexts.licenses, Icons.chevron_right), + findsOneWidget, + ); + expect( + tester.getSize(downloadRow()).height, + greaterThanOrEqualTo(52), + reason: '한 줄 칸 52 — 앱 버전 · 오픈소스 라이선스와 같다', + ); + expect(launched, isEmpty); + }, + ); testWidgets('누르면 업데이트 화면과 같은 받기 화면을 바깥 브라우저로 연다 — 설정에 머문다', ( WidgetTester tester, diff --git a/flutter/test/support/release_notes_fakes.dart b/flutter/test/support/release_notes_fakes.dart new file mode 100644 index 0000000..054c706 --- /dev/null +++ b/flutter/test/support/release_notes_fakes.dart @@ -0,0 +1,203 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart'; +import 'package:dio/dio.dart'; + +import 'fakes.dart'; + +/// 가짜 업데이트 내역 글 하나 — 서버가 읽어 둔 글(계약 7절의 응답 칸) +class FakeReleaseNote { + const FakeReleaseNote(this.id, this.title, {String? date, this.sections}) + : _date = date; + + final String id; + final String title; + final String? _date; + + /// 묶음 — 없으면 머리 없는 묶음 하나에 줄 하나 + final List>? sections; + + /// 쓴 날 — 없으면 id 의 날짜 칸(`2026-11-18-2` → `2026-11-18`) + String get date => _date ?? id.substring(0, _dateLength); + + static const int _dateLength = 10; + + /// 목록 줄 · 다른 글 줄의 꼴 `{ id, date, title }` + Map summaryJson() => { + 'id': id, + 'date': date, + 'title': title, + }; + + /// 글 화면의 묶음 + List> sectionsJson() => + sections ?? + >[ + { + 'heading': null, + 'items': ['$title — 본문'], + }, + ]; +} + +/// 묶음 하나의 꼴 +Map releaseNoteSectionJson( + String? heading, + List items, +) => {'heading': heading, 'items': items}; + +/// 보드 `ReleaseNotes` 의 네 글(맨 위 글은 다음 배포의 첫 글 초안 — 최근 글부터) +const List kBoardReleaseNotes = [ + FakeReleaseNote( + '2026-11-18', + '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다', + sections: >[ + { + 'heading': '휴대폰 앱', + 'items': [ + '안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.', + '앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.', + ], + }, + { + 'heading': '메일 검색', + 'items': ['찾은 말에는 노란 표시가 붙습니다.'], + }, + ], + ), + FakeReleaseNote( + '2026-11-05', + '화면의 안내 글을 다듬었습니다', + sections: >[ + { + 'heading': null, + 'items': [ + '단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.', + '브라우저 탭과 휴대폰 홈 화면의 아이콘을 ARA SYSTEM 마크로 바꿨습니다.', + ], + }, + ], + ), + FakeReleaseNote('2026-11-04', '왼쪽 메뉴를 정리했습니다'), + FakeReleaseNote('2026-11-03', '2단계 인증과 앱 비밀번호를 쓸 수 있습니다'), +]; + +/// 가짜 업데이트 내역 서버 — `GET /release-notes`(커서 = 다음 글의 자리) · `GET /release-notes/{id}` (계약 7절) +class FakeReleaseNotesBackend { + FakeReleaseNotesBackend(this.server); + + final FakeServer server; + + /// 글 — 최근 글부터(서버 차례) + List notes = kBoardReleaseNotes; + + /// 받은 목록 요청 · 글 요청의 id (차례대로) + final List listRequests = []; + final List noteRequests = []; + + /// 두면 목록 · 글 요청이 이것이 끝날 때까지 기다린다(불러오는 중 · [더 보기]를 받는 중) + Completer? listGate; + Completer? noteGate; + + /// 두면 다음 목록 · 글 요청 하나에 이것으로 답한다(한 번 쓰고 지운다) — 실패 · 모양이 틀린 응답 + FakeResponse? nextListResponse; + FakeResponse? nextNoteResponse; + + /// 두면 목록 · 글 요청에 이것으로 답한다(기본 답은 [listPage] · [noteDetail]) — 요청마다 다른 때에 답하는 시험 + FakeHandler? onList; + FakeHandler? onNote; + + /// 글 [count] 개 — 2026-11-30 부터 하루씩 거슬러(최근 글부터), 제목 "업데이트 N"(가장 새 글이 N = count) + void seed(int count) { + final DateTime newest = DateTime.utc(2026, 11, 30); + notes = [ + for (int index = 0; index < count; index++) + FakeReleaseNote( + _dateOf(newest.subtract(Duration(days: index))), + '업데이트 ${count - index}', + ), + ]; + } + + static String _dateOf(DateTime day) => + '${day.year}-${day.month.toString().padLeft(2, '0')}-' + '${day.day.toString().padLeft(2, '0')}'; + + /// 커서의 머리 — 커서는 다음 글의 자리(`n20`) + static const String cursorPrefix = 'n'; + + static final RegExp _notePath = RegExp(r'^/release-notes/([^/]+)$'); + + /// 목록 한 쪽의 기본 답 — `limit`(없으면 20)개씩, 커서 자리부터 + FakeResponse listPage(RequestOptions options) { + final Object? rawLimit = options.queryParameters['limit']; + final int limit = rawLimit is int ? rawLimit : kReleaseNotesPageSize; + final Object? cursor = options.queryParameters['cursor']; + final int start = cursor is String + ? int.parse(cursor.substring(cursorPrefix.length)) + : 0; + final int end = math.min(start + limit, notes.length); + final bool hasNext = end < notes.length; + return FakeResponse.ok({ + 'items': >[ + for (final FakeReleaseNote note in notes.sublist(start, end)) + note.summaryJson(), + ], + 'pageInfo': { + 'hasNext': hasNext, + 'nextCursor': hasNext ? '$cursorPrefix$end' : null, + 'total': notes.length, + }, + }); + } + + /// 글 하나의 기본 답 — 없는 글은 404 `RES_001`, 다른 글은 목록 차례의 위(next) · 아래(previous) + FakeResponse noteDetail(String id) { + final int index = notes.indexWhere((FakeReleaseNote n) => n.id == id); + if (index < 0) return FakeResponse.fail(404, 'RES_001'); + final FakeReleaseNote note = notes[index]; + return FakeResponse.ok({ + ...note.summaryJson(), + 'sections': note.sectionsJson(), + 'previous': index + 1 < notes.length + ? notes[index + 1].summaryJson() + : null, + 'next': index > 0 ? notes[index - 1].summaryJson() : null, + }); + } + + void install() { + server.on('GET', ReleaseNotesApiPaths.releaseNotes, ( + RequestOptions options, + ) async { + listRequests.add(options); + final Completer? gate = listGate; + if (gate != null) await gate.future; + final FakeResponse? injected = nextListResponse; + if (injected != null) { + nextListResponse = null; + return injected; + } + final FakeHandler? handler = onList; + if (handler != null) return handler(options); + return listPage(options); + }); + server.onPattern('GET', _notePath, (RequestOptions options) async { + final String id = Uri.decodeComponent( + _notePath.firstMatch(options.path)!.group(1)!, + ); + noteRequests.add(id); + final Completer? gate = noteGate; + if (gate != null) await gate.future; + final FakeResponse? injected = nextNoteResponse; + if (injected != null) { + nextNoteResponse = null; + return injected; + } + final FakeHandler? handler = onNote; + if (handler != null) return handler(options); + return noteDetail(id); + }); + } +}