feat: 업데이트 내역 게시판 · 쓰기 화면 [보내기] 숨음과 목록 고르기 칸 고침 #26

Merged
ttipo merged 12 commits from feat/release-notes into main 2026-11-11 06:24:46 +09:00
22 changed files with 2916 additions and 45 deletions
Showing only changes of commit 1d01130c9d - Show all commits
+14
View File
@@ -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;
}
+53
View File
@@ -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')}';
}
@@ -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;
@@ -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<String, Object?>) 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<String> items;
/// 모양이 틀리면 null
static ReleaseNoteSection? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? heading = json['heading'];
final Object? items = json['items'];
if ((heading != null && heading is! String) || items is! List<Object?>) {
return null;
}
final List<String> lines = <String>[];
for (final Object? item in items) {
if (item is! String) return null;
lines.add(item);
}
return ReleaseNoteSection(
heading: heading as String?,
items: List<String>.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<ReleaseNoteSection> sections;
/// 바로 더 옛 글(목록에서 아래) — 없으면 null. 글 화면 끝의 "이전 글" 줄
final ReleaseNoteSummary? previous;
/// 바로 더 새 글(목록에서 위) — 없으면 null. 글 화면 끝의 "다음 글" 줄
final ReleaseNoteSummary? next;
/// 모양이 틀리면(묶음 · 다른 글 하나라도) null. `previous` · `next` 칸이 아예 없으면 없는 것(null)으로 본다
static ReleaseNote? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final ReleaseNoteSummary? head = ReleaseNoteSummary.tryParse(json);
final Object? rawSections = json['sections'];
if (head == null || rawSections is! List<Object?>) return null;
final List<ReleaseNoteSection> sections = <ReleaseNoteSection>[];
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<ReleaseNoteSection>.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<ReleaseNoteSummary> 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<String, Object?>) return null;
final Object? rawItems = json['items'];
final Object? pageInfo = json['pageInfo'];
if (rawItems is! List<Object?> || pageInfo is! Map<String, Object?>) {
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<ReleaseNoteSummary> items = <ReleaseNoteSummary>[];
for (final Object? raw in rawItems) {
final ReleaseNoteSummary? item = ReleaseNoteSummary.tryParse(raw);
if (item == null) return null;
items.add(item);
}
return ReleaseNotesPage(
items: List<ReleaseNoteSummary>.unmodifiable(items),
hasNext: hasNext,
nextCursor: nextCursor as String?,
total: (total as num?)?.toInt(),
);
}
}
@@ -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<ReleaseNotesPage> listNotes({
String? cursor,
int limit = kReleaseNotesPageSize,
bool silent = false,
}) async {
final Response<Object?> response = await _dio.get<Object?>(
ReleaseNotesApiPaths.releaseNotes,
queryParameters: <String, Object?>{'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<ReleaseNote> getNote(String id) async {
final Response<Object?> response = await _dio.get<Object?>(
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<Object?> response)
: super(
requestOptions: response.requestOptions,
error: ApiException.fromCode(kFallbackCode),
);
}
@@ -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<double> _skeletonLineWidths = <double>[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<ReleaseNoteSummary> onOpen;
@override
Widget build(BuildContext context) {
final ReleaseNoteSummary? newer = note.next;
final ReleaseNoteSummary? older = note.previous;
return ListView(
children: <Widget>[
_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: <Widget>[
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) ...<Widget>[
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<String> items = section.items;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (heading != null) ...<Widget>[
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++) ...<Widget>[
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<AppExtraColors>()!;
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: <Widget>[
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<ReleaseNoteSummary> onOpen;
@override
Widget build(BuildContext context) {
final Color line = Theme.of(
context,
).extension<AppExtraColors>()!.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: <Widget>[
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<AppExtraColors>()!;
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: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s2,
children: <Widget>[
Text(
ReleaseNotesTexts.neighborLine(label, date),
semanticsLabel: ReleaseNotesTexts.neighborLineSpoken(
label,
date,
),
style: theme.textTheme.bodySmall?.copyWith(
color: extra.textMuted,
height: AppTypography.lineHeightHeading,
fontFeatures: const <FontFeature>[
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<AppExtraColors>()!.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: <Widget>[
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) ...<Widget>[
const SizedBox(height: AppSpacing.s14),
FractionallySizedBox(
widthFactor: width,
child: SkeletonBar(color: bar, rounded: true),
),
],
],
),
),
);
}
}
@@ -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<double> _skeletonTitleWidths = <double>[
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/<id>`
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<void>(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<ReleaseNoteSummary> onOpen;
final VoidCallback onMore;
@override
Widget build(BuildContext context) {
final List<ReleaseNoteSummary> 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<AppExtraColors>()!;
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: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s2,
children: <Widget>[
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<AppExtraColors>()!;
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: <Widget>[
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<AppExtraColors>()!;
return StateLayout(
// 웹의 문서 그림(file-text — 접힌 모서리와 글 줄)과 같은 꼴
icon: Icons.description_outlined,
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
title: ReleaseNotesTexts.empty,
children: const <Widget>[],
);
}
}
/// 처음 받는 동안 — 줄 자리의 막대(보드 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<AppExtraColors>()!;
return LoadingSkeleton(
label: ReleaseNotesTexts.listLoading,
// 가로 화면처럼 낮은 화면에서는 넘치는 줄을 자른다(보드의 `overflow: hidden`)
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
children: <Widget>[
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: <Widget>[
FractionallySizedBox(
widthFactor: width,
child: SkeletonBar(
color: scheme.surfaceContainerHighest,
height: AppSize.skeletonTitleBarHeight,
rounded: true,
),
),
SizedBox(
width: AppSize.skeletonDateBarWidth,
child: SkeletonBar(
color: extra.borderSubtle,
rounded: true,
),
),
],
),
),
),
),
],
),
),
);
}
}
@@ -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 <ModuleSettingsSection>[],
this.sectionLocation,
super.key,
@@ -39,6 +42,9 @@ class SettingsPage extends ConsumerStatefulWidget {
/// 오픈소스 라이선스 화면의 경로
final String licensesLocation;
/// 업데이트 내역 목록의 경로
final String releaseNotesLocation;
/// 다른 모듈이 내놓은 설정 절
final List<ModuleSettingsSection> sections;
@@ -125,6 +131,12 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_VersionRow(version: version),
_LinkRow(
label: ReleaseNotesTexts.title,
onTap: () => unawaited(
context.push<void>(widget.releaseNotesLocation),
),
),
_LinkRow(
label: AppDownloadTexts.getNewVersion,
opensOutside: true,
@@ -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<ReleaseNoteController, ReleaseNoteState, String>
releaseNoteProvider = NotifierProvider.autoDispose
.family<ReleaseNoteController, ReleaseNoteState, String>(
ReleaseNoteController.new,
);
/// 업데이트 내역 글 — 계약 7절 `GET /api/release-notes/{id}` (보드 `ReleaseNotes` B3 · B4 · B5 · B6).
///
/// 알림 없이 받는다 — 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`)과 그 밖의 실패는 화면이 그 자리에 그린다.
/// 세션이 바뀌면 처음부터 다시 받고, 그 전에 보낸 요청의 응답은 버린다(세대 [_seq] · 시작할 때의 [Ref]).
class ReleaseNoteController extends Notifier<ReleaseNoteState> {
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<void> retry() async {
if (!ref.mounted || ref.read(releaseNotesAccountProvider) == null) return;
_seq++;
state = const ReleaseNoteState();
await _load(_seq);
}
Future<void> _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),
);
}
}
}
@@ -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 <ReleaseNoteSummary>[],
this.hasNext = false,
this.nextCursor,
this.error,
this.loadingMore = false,
});
final ReleaseNotesListStatus status;
/// 받은 줄 — 최근 글부터(서버 차례 그대로)
final List<ReleaseNoteSummary> 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<ReleaseNotesListController, ReleaseNotesListState>
releaseNotesListProvider =
NotifierProvider.autoDispose<
ReleaseNotesListController,
ReleaseNotesListState
>(ReleaseNotesListController.new);
/// 업데이트 내역 목록 — 계약 7절 `GET /api/release-notes` (보드 `ReleaseNotes` B2 · B7 · B8 · B9).
///
/// - 첫 쪽 · [다시 시도]는 알림 없이 받는다 — 받지 못하면 화면이 그 자리에 사전 문구 · 문의 코드 · [다시 시도]를 그린다.
/// - [더 보기]는 다음 쪽을 받아 뒤에 붙인다(이미 있는 글은 건너뛴다 — 그사이 글이 더해져 자리가 밀렸을 수 있다).
/// 받지 못하면 인터셉터가 알리고 받은 줄은 그대로 둔다(저절로 다시 하지 않는다 — [더 보기]를 다시 누른다).
/// - 늦은 응답: 처음부터 다시 받을 때마다(다시 시도 · 세션이 바뀜) 세대([_seq])가 오른다 — 그 전에 보낸 요청의 응답은 버린다.
/// 비동기 일은 시작할 때의 [Ref] 를 쥐고, 끝난 뒤 그 Ref 가 살아 있을 때만 상태를 바꾼다(다시 만들어졌거나 사라졌으면 버린다).
class ReleaseNotesListController extends Notifier<ReleaseNotesListState> {
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<void> retry() async {
if (!ref.mounted || ref.read(releaseNotesAccountProvider) == null) return;
_seq++;
state = const ReleaseNotesListState();
await _loadFirst(_seq);
}
Future<void> _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<void> 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<String> known = <String>{
for (final ReleaseNoteSummary note in state.items) note.id,
};
state = ReleaseNotesListState(
status: ReleaseNotesListStatus.ready,
items: List<ReleaseNoteSummary>.unmodifiable(<ReleaseNoteSummary>[
...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);
}
}
}
@@ -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<ReleaseNotesRepository> releaseNotesRepositoryProvider =
Provider<ReleaseNotesRepository>(
(Ref ref) => ReleaseNotesRepository(ref.watch(dioProvider)),
);
/// 업데이트 내역을 보는 직원 — 로그인했고 제한이 없을 때 그 주소, 그 밖에는 null.
///
/// 목록 · 글은 이것을 듣고 바뀌면 처음부터 다시 받는다 — 로그아웃 · 세션 끝 · 다른 직원 로그인 뒤에 앞 세션의 늦은 응답을 쓰지 않게.
/// 로그아웃은 늘 null 을 거치므로 같은 직원이 다시 들어와도 새로 시작한다(메일의 `mailAccountProvider` 와 같은 규칙 —
/// 모듈끼리 import 하지 않아 따로 둔다)
final Provider<String?> releaseNotesAccountProvider = Provider<String?>((
Ref ref,
) {
final SessionState? session = ref.watch(sessionProvider).value;
return session is SignedIn && session.info.restriction == null
? session.info.email
: null;
});
@@ -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';
}
@@ -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 {
@@ -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<AppExtraColors>()!;
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: <Widget>[
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<AppExtraColors>()!;
return Text(
koreanCalendarDate(date),
style: style?.copyWith(
color: extra.textMuted,
fontFeatures: const <FontFeature>[FontFeature.tabularFigures()],
),
);
}
}
@@ -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/<path>`)
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) => <RouteBase>[
@@ -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: <RouteBase>[
GoRoute(
path: ':noteId',
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState state) {
final String noteId = state.pathParameters['noteId']!;
return ReleaseNotePage(
// 글마다 새 화면 — 다른 글로 바꿔 끼우면 앞 글의 상태(받기 · 스크롤)를 이어 쓰지 않는다
key: ValueKey<String>(noteId),
noteId: noteId,
noteLocation: SettingsRoutes.releaseNote,
listLocation: SettingsRoutes.releaseNotes,
);
},
),
],
),
for (final ModuleSettingsSection section in context.settingsSections)
GoRoute(path: section.path, builder: section.builder),
],
+11 -8
View File
@@ -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 <String>[
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();
@@ -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 <Object?>[
'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일');
});
});
}
@@ -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<void> open(WidgetTester tester, String location) async {
await h.pump(tester, location: location);
await settle(tester);
}
Future<void> 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<void> scrollTo(WidgetTester tester, Finder finder) async {
await tester.scrollUntilVisible(
finder,
500,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
}
Future<void> tapVisible(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.pumpAndSettle();
await tester.tap(finder);
await settle(tester);
}
Future<void> 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<double> tops = <double>[
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 <String>[
'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<void> gate = Completer<void>();
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, <String>['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(<String, Object?>{
'items': '모양이 틀림',
});
await tester.tap(moreButton());
await settle(tester);
expect(h.notifier.shownCodes, <String>['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<void> gate = Completer<void>();
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 <FakeReleaseNote>[];
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, <String>['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, <String>['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, <String>['2026-11-05', '2026-11-05']);
expect(findCopy('화면의 안내 글을 다듬었습니다'), findsOneWidget);
});
testWidgets('받는 동안 글 자리를 막대로 잡아 둔다 — 화면 낭독기는 "글 불러오는 중"', (
WidgetTester tester,
) async {
final Completer<void> gate = Completer<void>();
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);
});
});
}
@@ -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<String?> {
@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<ReleaseNotesPage> 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<ReleaseNote> getNote(String id) async {
try {
return await super.getNote(id);
} finally {
notesFinished++;
}
}
}
/// 업데이트 내역 — 모델 읽기 · 저장소 · 목록 · 글 컨트롤러 (계약 7절)
void main() {
group('모델 읽기', () {
Map<String, Object?> summary(String id, {String? date, String? title}) =>
<String, Object?>{
'id': id,
'date': date ?? id.substring(0, 10),
'title': title ?? '제목 $id',
};
test('목록 한 쪽 — 줄 · 다음 쪽 · 글 수', () {
final ReleaseNotesPage page = ReleaseNotesPage.tryParse(<String, Object?>{
'items': <Object?>[summary('2026-11-18-2'), summary('2026-11-18')],
'pageInfo': <String, Object?>{
'hasNext': true,
'nextCursor': 'abc',
'total': 41,
},
})!;
expect(page.items.map((ReleaseNoteSummary n) => n.id), <String>[
'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(<String, Object?>{
'items': <Object?>[],
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
})!;
expect(last.items, isEmpty);
expect(last.hasMore, isFalse);
expect(last.total, isNull);
final ReleaseNotesPage noCursor = ReleaseNotesPage.tryParse(
<String, Object?>{
'items': <Object?>[],
'pageInfo': <String, Object?>{'hasNext': true, 'nextCursor': ''},
},
)!;
expect(noCursor.hasMore, isFalse);
});
test('모양이 틀리면 null — 줄 하나라도(없는 날 · 빈 id · 숫자 제목)', () {
Map<String, Object?> pageOf(List<Object?> items) => <String, Object?>{
'items': items,
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
};
expect(
ReleaseNotesPage.tryParse(
pageOf(<Object?>[summary('2026-11-18'), summary('2026-02-30')]),
),
isNull,
);
expect(
ReleaseNotesPage.tryParse(
pageOf(<Object?>[summary('', date: '2026-11-18')]),
),
isNull,
);
expect(
ReleaseNotesPage.tryParse(
pageOf(<Object?>[
<String, Object?>{
'id': '2026-11-18',
'date': '2026-11-18',
'title': 3,
},
]),
),
isNull,
);
expect(
ReleaseNotesPage.tryParse(<String, Object?>{'items': <Object?>[]}),
isNull,
);
expect(ReleaseNotesPage.tryParse('목록'), isNull);
});
test('글 — 묶음(머리 없는 묶음 포함) · 바로 더 옛 글 · 바로 더 새 글', () {
final ReleaseNote note = ReleaseNote.tryParse(<String, Object?>{
...summary('2026-11-05', title: '화면의 안내 글을 다듬었습니다'),
'sections': <Object?>[
<String, Object?>{
'heading': '메일 · 메일함',
'items': <Object?>['한 줄', '두 줄'],
},
<String, Object?>{
'heading': null,
'items': <Object?>['머리 없는 줄'],
},
],
'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>[
ReleaseNoteSection(heading: '메일 · 메일함', items: <String>['한 줄', '두 줄']),
ReleaseNoteSection(heading: null, items: <String>['머리 없는 줄']),
]);
expect(note.previous?.id, '2026-11-04');
expect(note.next?.id, '2026-11-18');
});
test('다른 글이 없으면(null) · 칸이 아예 없으면 없는 것', () {
final ReleaseNote first = ReleaseNote.tryParse(<String, Object?>{
...summary('2026-11-18'),
'sections': <Object?>[],
'previous': null,
'next': null,
})!;
expect(first.previous, isNull);
expect(first.next, isNull);
final ReleaseNote old = ReleaseNote.tryParse(<String, Object?>{
...summary('2026-11-18'),
'sections': <Object?>[],
})!;
expect(old.previous, isNull);
expect(old.next, isNull);
});
test('글의 모양이 틀리면 null — 묶음 · 다른 글 하나라도', () {
Map<String, Object?> noteWith(Map<String, Object?> extra) =>
<String, Object?>{
...summary('2026-11-18'),
'sections': <Object?>[],
...extra,
};
expect(
ReleaseNote.tryParse(
noteWith(<String, Object?>{
'sections': <Object?>[
<String, Object?>{
'heading': 1,
'items': <Object?>['줄'],
},
],
}),
),
isNull,
);
expect(
ReleaseNote.tryParse(
noteWith(<String, Object?>{
'sections': <Object?>[
<String, Object?>{
'heading': null,
'items': <Object?>['줄', null],
},
],
}),
),
isNull,
);
expect(
ReleaseNote.tryParse(
noteWith(<String, Object?>{
'previous': <String, Object?>{'id': '2026-11-04'},
}),
),
isNull,
);
expect(
ReleaseNote.tryParse(noteWith(<String, Object?>{'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: <Override>[
...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<void> openList() async {
final ProviderSubscription<ReleaseNotesListState> 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<void> settleAfter(bool Function() condition) async {
await pumpEventsUntil(condition);
await Future<void>.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, <String, Object?>{
'limit': kReleaseNotesPageSize,
});
expect(notes.listRequests.last.queryParameters['cursor'], 'n2');
expect(
ReleaseNotesApiPaths.releaseNote('2026/11 18'),
'/release-notes/2026%2F11%2018',
);
expect(notes.noteRequests, <String>['2026-11-30']);
},
);
test(
'응답 모양이 틀리면 SYS_001 — 인터셉터를 다 지난 뒤라 따로 가른다(ReleaseNotesMalformedResponse)',
() async {
notes.nextListResponse = FakeResponse.ok(<String, Object?>{'items': 1});
final Object error = await errorOf(repository().listNotes());
expect(error, isA<ReleaseNotesMalformedResponse>());
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 = <FakeReleaseNote>[
const FakeReleaseNote('2026-12-01', '업데이트 22'),
...notes.notes,
];
await container.read(releaseNotesListProvider.notifier).loadMore();
final List<String> ids = <String>[
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<void> late = Completer<void>();
int calls = 0;
notes.onList = (RequestOptions options) async {
calls++;
if (calls == 1) {
await late.future;
return FakeResponse.ok(<String, Object?>{
'items': <Object?>[
const FakeReleaseNote('2020-01-01', '앞 세션의 글').summaryJson(),
],
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
});
}
return notes.listPage(options);
};
final ProviderSubscription<ReleaseNotesListState> 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), <String>[
'업데이트 2',
'업데이트 1',
]);
late.complete();
await settleAfter(() => recording.listsFinished == 2);
expect(listState().status, ReleaseNotesListStatus.ready);
expect(listState().items.map((ReleaseNoteSummary n) => n.title), <String>[
'업데이트 2',
'업데이트 1',
]);
});
test('로그인하지 않았으면(세션 없음) 묻지 않는다', () async {
container.read(_accountProvider.notifier).set(null);
final ProviderSubscription<ReleaseNotesListState> list = container.listen(
releaseNotesListProvider,
(ReleaseNotesListState? previous, ReleaseNotesListState next) {},
);
addTearDown(list.close);
final ProviderSubscription<ReleaseNoteState> note = container.listen(
releaseNoteProvider('2026-11-18'),
(ReleaseNoteState? previous, ReleaseNoteState next) {},
);
addTearDown(note.close);
await Future<void>.delayed(Duration.zero);
expect(listState().status, ReleaseNotesListStatus.loading);
expect(notes.listRequests, isEmpty);
expect(notes.noteRequests, isEmpty);
});
test('글 — 세션이 바뀌면 다시 받고, 앞 세션의 늦은 응답(실패)은 버린다', () async {
final Completer<void> late = Completer<void>();
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<ReleaseNoteState> 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);
});
});
}
@@ -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,
@@ -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<Map<String, Object?>>? 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<String, Object?> summaryJson() => <String, Object?>{
'id': id,
'date': date,
'title': title,
};
/// 글 화면의 묶음
List<Map<String, Object?>> sectionsJson() =>
sections ??
<Map<String, Object?>>[
<String, Object?>{
'heading': null,
'items': <String>['$title — 본문'],
},
];
}
/// 묶음 하나의 꼴
Map<String, Object?> releaseNoteSectionJson(
String? heading,
List<String> items,
) => <String, Object?>{'heading': heading, 'items': items};
/// 보드 `ReleaseNotes` 의 네 글(맨 위 글은 다음 배포의 첫 글 초안 — 최근 글부터)
const List<FakeReleaseNote> kBoardReleaseNotes = <FakeReleaseNote>[
FakeReleaseNote(
'2026-11-18',
'메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
sections: <Map<String, Object?>>[
<String, Object?>{
'heading': '휴대폰 앱',
'items': <String>[
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
'앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
],
},
<String, Object?>{
'heading': '메일 검색',
'items': <String>['찾은 말에는 노란 표시가 붙습니다.'],
},
],
),
FakeReleaseNote(
'2026-11-05',
'화면의 안내 글을 다듬었습니다',
sections: <Map<String, Object?>>[
<String, Object?>{
'heading': null,
'items': <String>[
'단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.',
'브라우저 탭과 휴대폰 홈 화면의 아이콘을 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<FakeReleaseNote> notes = kBoardReleaseNotes;
/// 받은 목록 요청 · 글 요청의 id (차례대로)
final List<RequestOptions> listRequests = <RequestOptions>[];
final List<String> noteRequests = <String>[];
/// 두면 목록 · 글 요청이 이것이 끝날 때까지 기다린다(불러오는 중 · [더 보기]를 받는 중)
Completer<void>? listGate;
Completer<void>? 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 = <FakeReleaseNote>[
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(<String, Object?>{
'items': <Map<String, Object?>>[
for (final FakeReleaseNote note in notes.sublist(start, end))
note.summaryJson(),
],
'pageInfo': <String, Object?>{
'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(<String, Object?>{
...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<void>? 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<void>? 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);
});
}
}