generated from ttipo/project-base
feat(flutter): 설정 › 앱 정보에 업데이트 내역(목록 → 글)을 더한다
앱 버전 다음 줄에서 목록(제목 · 날짜, [더 보기])으로 들어가 줄을 누르면 글(날짜 · 제목 · 묶음, 다음 글 · 이전 글 줄)이 열린다. 없는 글 · 받지 못함 · 글 없음 상태를 두고, 날짜는 달력 날짜 글이라 시각으로 바꾸지 않는다(CalendarDate). 다른 글 줄은 지금 글을 바꿔 끼워 뒤로는 늘 목록이다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
@@ -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),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user