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

Merged
ttipo merged 12 commits from feat/release-notes into main 2026-11-11 06:24:46 +09:00
9 changed files with 288 additions and 220 deletions
Showing only changes of commit 1b3f9ba7fe - Show all commits
@@ -11,6 +11,7 @@ import 'package:arasystem_app/core/utils/korean_text.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart';
import 'package:arasystem_app/features/mail/domain/search_conditions.dart';
import 'package:arasystem_app/shared/widgets/load_state_texts.dart';
/// 쓰기 화면 (보드 `App1Compose`). 보드에 없는 상태(불러오기 · 받지 못함 · 칸 안내 · 첨부 상태 · 나가기 확인 · 보낸 뒤 알림)의 글은
/// 웹 상수(`frontend/src/shared/constants/mail.ts` · `mail-send-later.ts` · `large-file.ts`)를 그대로 옮겼다 — 앱에서 새로 쓴 글은 그 줄에 적었다
@@ -356,12 +357,12 @@ abstract final class MailListTexts {
/// 다음 쪽을 받는 동안 — 화면 낭독기
static const String loadingMore = '메일을 더 불러오는 중';
// ── 받지 못함 ──
static const String retry = '다시 시도';
// ── 받지 못함 — 글은 함께 쓰는 받지 못함 틀(`shared/widgets/load_states.dart`)의 글과 같다 ──
static const String retry = LoadStateTexts.retry;
/// 없는 메일함(주소에 남은 옛 id) — 다시 해도 같아 빠져나갈 단추를 준다
static const String toInbox = '받은편지함으로';
static String traceCode(String code) => '문의 코드 $code';
static String traceCode(String code) => LoadStateTexts.traceCode(code);
// ── 빈 메일함 ──
/// "휴지통이 비어 있습니다"
@@ -24,6 +24,7 @@ import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/push_offer_line.dart';
import 'package:arasystem_app/shared/widgets/confirm_dialog.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';
@@ -280,22 +281,24 @@ class _MailListBody extends StatelessWidget {
const Widget skeleton = _SkeletonList();
final Widget content = switch (list.status) {
_ when boxesFailure != null => _FillViewport(
child: MailListError(error: boxesFailure, onRetry: onRetryMailboxes),
child: LoadErrorView(error: boxesFailure, onRetry: onRetryMailboxes),
),
_ when unknownMailbox => _FillViewport(
child: MailListError(
child: LoadErrorView(
error: ApiException.fromCode('RES_001'),
onRetry: onRetry,
onLeave: onLeave,
leaveLabel: MailListTexts.toInbox,
),
),
_ when !mailboxesReady => skeleton,
MailListStatus.loading => skeleton,
MailListStatus.failed => _FillViewport(
child: MailListError(
child: LoadErrorView(
error: list.error!,
onRetry: onRetry,
onLeave: onLeave,
leaveLabel: MailListTexts.toInbox,
),
),
MailListStatus.ready when list.items.isEmpty => _FillViewport(
@@ -22,6 +22,7 @@ import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_bar
import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_conditions_sheet.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_states.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.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';
@@ -273,7 +274,7 @@ class _SearchBody extends StatelessWidget {
final ApiException error =
search.error ?? ApiException.fromCode('SYS_001');
content = _FillViewport(
child: MailListError(
child: LoadErrorView(
error: error,
onRetry: onRetry,
// 찾던 메일함이 그사이 지워졌으면(RES_001) 다시 해도 같다 — 전체 메일함에서 찾는다
@@ -20,9 +20,9 @@ import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_pr
import 'package:arasystem_app/features/mail/presentation/widgets/compose_bars.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/compose_recipient_field.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/compose_sections.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_notices.dart';
import 'package:arasystem_app/shared/widgets/confirm_dialog.dart';
import 'package:arasystem_app/shared/widgets/load_states.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
@@ -749,7 +749,7 @@ class _ComposeError extends StatelessWidget {
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: MailListError(
child: LoadErrorView(
error: error,
onRetry: onRetry,
onLeave: onLeave,
@@ -22,13 +22,13 @@ import 'package:arasystem_app/features/mail/presentation/providers/message_reade
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_state.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_body_view.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_move_sheet.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_attachments.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_header.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_notices.dart';
import 'package:arasystem_app/shared/widgets/confirm_dialog.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';
@@ -533,7 +533,7 @@ class _ReaderError extends StatelessWidget {
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: MailListError(
child: LoadErrorView(
error: error,
onRetry: onRetry,
onLeave: onLeave,
@@ -1,4 +1,3 @@
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';
@@ -6,17 +5,15 @@ import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_icons.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';
/// 문의 코드로 보이는 추적 ID 앞자리 수 — 웹 `TRACE_CODE_LENGTH` 와 같다
const int kTraceCodeLength = 8;
// 가운데 상태의 틀([StateLayout]) · 받지 못함([LoadErrorView]) · 뼈대 막대([SkeletonBar])는 업데이트 내역과 함께 쓰려고
// `shared/widgets/load_states.dart` 로 옮겼다(모양 · 동작 그대로). 여기에는 메일 목록에만 쓰는 상태가 남는다.
/// 처음 불러오는 동안의 뼈대 줄 수 — 휴대폰 한 화면쯤
const int _skeletonRows = 8;
/// 빈 메일함 · 받지 못함의 아래 여백 — 안내가 화면 가운데보다 조금 위에 오게(보드의 `padding-bottom: 120px`)
const double _stateBottomSpace = 120;
/// 뼈대 줄의 막대 폭 — 보낸 사람 · 제목(미리 보기 막대는 줄 폭을 다 쓴다)
const double _skeletonWhoWidth = 0.4;
const double _skeletonSubjectWidth = 0.8;
@@ -40,7 +37,7 @@ class MailListEmpty extends StatelessWidget {
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return MailStateLayout(
return StateLayout(
icon: mailboxIconOf(role),
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
@@ -58,139 +55,6 @@ class MailListEmpty extends StatelessWidget {
}
}
/// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일함은 [받은편지함으로] — 다시 해도 같다)
/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다). 읽기 화면도 쓴다(없는 메일은 [목록으로]),
/// 검색 화면도 쓴다(찾던 메일함이 지워졌으면 [전체 메일함에서 찾기] — 제목은 보드 `App1SearchStates` 대로 작게)
class MailListError extends StatelessWidget {
const MailListError({
required this.error,
required this.onRetry,
this.onLeave,
this.leaveLabel = MailListTexts.toInbox,
this.titleStyle,
super.key,
});
final ApiException error;
final VoidCallback onRetry;
/// 없는 메일함(`RES_001`)일 때 갈 곳 — 없으면 늘 [다시 시도]
final VoidCallback? onLeave;
/// [onLeave] 단추의 이름
final String leaveLabel;
/// 제목 글자 — 없으면 [MailStateLayout] 의 기본(목록 보드의 크기)
final TextStyle? titleStyle;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final VoidCallback? leave = onLeave;
final bool notFound = error.code == 'RES_001' && leave != null;
final String? trace = traceCodeOf(error.traceId);
return MailStateLayout(
icon: Icons.error_outline,
iconColor: theme.colorScheme.error,
iconBackground: theme.colorScheme.errorContainer,
title: error.message,
titleStyle: titleStyle,
children: <Widget>[
if (trace != null)
Text(
MailListTexts.traceCode(trace),
style: theme.textTheme.bodySmall?.copyWith(color: extra.textMuted),
),
TextButton(
onPressed: notFound ? leave : onRetry,
child: Text(notFound ? leaveLabel : MailListTexts.retry),
),
],
);
}
}
/// 추적 ID → 화면의 문의 코드(앞 8자리). 없으면 null
String? traceCodeOf(String? traceId) {
final String? value = traceId?.trim();
if (value == null || value.isEmpty) return null;
return value.length > kTraceCodeLength
? value.substring(0, kTraceCodeLength)
: value;
}
/// 가운데 상태 화면의 틀 — 아이콘 원(72) · 제목(머리) · 그 아래 글 · 단추. 화면 가운데보다 조금 위에 온다.
/// 목록의 빈 메일함 · 받지 못함, 검색의 결과 없음 · 아직 못 찾음 · 찾을 말이 너무 많음이 쓴다
class MailStateLayout extends StatelessWidget {
const MailStateLayout({
required this.icon,
required this.iconColor,
required this.iconBackground,
required this.title,
required this.children,
this.titleStyle,
super.key,
});
final IconData icon;
final Color iconColor;
final Color iconBackground;
final String title;
final List<Widget> children;
/// 제목 글자 — 없으면 `titleMedium`(목록 보드 17 → 18). 검색 보드는 15(`titleSmall`)
final TextStyle? titleStyle;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s40,
0,
AppSpacing.s40,
_stateBottomSpace,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ExcludeSemantics(
child: Container(
width: AppSize.stateIcon,
height: AppSize.stateIcon,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: iconBackground,
),
child: Icon(icon, size: AppSize.stateIconGlyph, color: iconColor),
),
),
const SizedBox(height: AppSpacing.s16),
// 여러 줄(긴 사전 문구)이어도 가운데에 — 글 자체를 가운데 맞춘다
DefaultTextStyle.merge(
textAlign: TextAlign.center,
child: Semantics(
header: true,
child: KeepAllText(
title,
style:
titleStyle ??
theme.textTheme.titleMedium?.copyWith(
fontWeight: AppTypography.semiBold,
),
),
),
),
const SizedBox(height: AppSpacing.s8),
for (final Widget child in children)
DefaultTextStyle.merge(textAlign: TextAlign.center, child: child),
],
),
);
}
}
/// 처음 불러오는 동안의 뼈대 — 줄 모양의 회색 막대(화면 낭독기는 "메일 목록 불러오는 중" — 검색은 "찾는 중")
class MailListSkeleton extends StatelessWidget {
const MailListSkeleton({this.label = MailListTexts.loading, super.key});
@@ -201,72 +65,52 @@ class MailListSkeleton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final Color bar = Theme.of(context).colorScheme.surfaceContainerHighest;
return Semantics(
return LoadingSkeleton(
label: label,
liveRegion: true,
child: ExcludeSemantics(
child: Column(
children: <Widget>[
for (int index = 0; index < _skeletonRows; index++)
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s12,
AppSpacing.s14,
AppSpacing.s16,
AppSpacing.s14,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: AppSize.avatar,
height: AppSize.avatar,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: bar,
),
),
const SizedBox(width: AppSpacing.s10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
FractionallySizedBox(
widthFactor: _skeletonWhoWidth,
child: _SkeletonBar(color: bar),
),
const SizedBox(height: AppSpacing.s8),
FractionallySizedBox(
widthFactor: _skeletonSubjectWidth,
child: _SkeletonBar(color: bar),
),
const SizedBox(height: AppSpacing.s8),
_SkeletonBar(color: bar),
],
),
),
],
),
child: Column(
children: <Widget>[
for (int index = 0; index < _skeletonRows; index++)
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s12,
AppSpacing.s14,
AppSpacing.s16,
AppSpacing.s14,
),
],
),
),
);
}
}
class _SkeletonBar extends StatelessWidget {
const _SkeletonBar({required this.color});
final Color color;
@override
Widget build(BuildContext context) {
return Container(
height: AppSpacing.s10,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(AppRadius.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: AppSize.avatar,
height: AppSize.avatar,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: bar,
),
),
const SizedBox(width: AppSpacing.s10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
FractionallySizedBox(
widthFactor: _skeletonWhoWidth,
child: SkeletonBar(color: bar),
),
const SizedBox(height: AppSpacing.s8),
FractionallySizedBox(
widthFactor: _skeletonSubjectWidth,
child: SkeletonBar(color: bar),
),
const SizedBox(height: AppSpacing.s8),
SkeletonBar(color: bar),
],
),
),
],
),
),
],
),
);
}
@@ -2,12 +2,12 @@ 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/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.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';
/// 검색 화면의 상태 글 — 찾기 전 · 결과 없음 · 아직 못 찾음 · 찾을 말이 너무 많음 · 목록 끝의 [더 찾기] (보드 `App1Search` · `App1SearchStates`).
/// 가운데 상태는 목록과 같은 틀([MailStateLayout])이고, 제목은 보드대로 15(`titleSmall`) · 도움말 13(`bodySmall`)이다
/// 가운데 상태는 목록과 같은 틀([StateLayout] — `shared/widgets/load_states.dart`)이고, 제목은 보드대로 15(`titleSmall`) · 도움말 13(`bodySmall`)이다
TextStyle? _stateTitleStyle(ThemeData theme) =>
theme.textTheme.titleSmall?.copyWith(fontWeight: AppTypography.semiBold);
@@ -71,7 +71,7 @@ class MailSearchEmpty extends StatelessWidget {
: query.isEmpty
? MailSearchTexts.emptyConditionsHint
: MailSearchTexts.emptyHint;
return MailStateLayout(
return StateLayout(
icon: Icons.search,
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
@@ -114,7 +114,7 @@ class MailSearchPartial extends StatelessWidget {
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return MailStateLayout(
return StateLayout(
icon: Icons.search,
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
@@ -139,7 +139,7 @@ class MailSearchTooManyWords extends StatelessWidget {
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return MailStateLayout(
return StateLayout(
icon: Icons.error_outline,
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
@@ -0,0 +1,11 @@
/// 불러오기 상태(받지 못함)의 화면 글 — 모듈이 함께 쓰는 상태 위젯(`load_states.dart`)의 글이다.
///
/// 메일 목록의 받지 못함(웹 `MailLoadError` 와 같은 글)에서 옮겨 왔다 — 메일 글(`MailListTexts.retry` · `traceCode`)은 이것을 가리킨다.
/// 받지 못한 까닭의 문구는 여기 두지 않는다 — 에러 코드 사전(`ErrorLexicon`)에서만 온다.
abstract final class LoadStateTexts {
/// 받지 못함의 [다시 시도]
static const String retry = '다시 시도';
/// 문의 코드 — 추적 ID 앞자리("문의 코드 3f1c0a2e")
static String traceCode(String code) => '문의 코드 $code';
}
+208
View File
@@ -0,0 +1,208 @@
/// 불러오기 상태의 틀 — 가운데 상태(빈 목록 · 받지 못함 등) · 받지 못함 · 불러오는 중 뼈대(막대).
///
/// 메일 목록(`features/mail/presentation/widgets/mail_list_states.dart`)에 있던 것을 모양 · 동작 그대로 옮겨 왔다 —
/// 모듈끼리는 import 하지 않으므로 함께 쓰는 위젯은 여기에 둔다(메일 목록 · 검색 · 읽기 · 쓰기, 설정의 업데이트 내역이 쓴다).
/// 받지 못한 까닭의 문구는 에러 코드 사전(`ApiException.message`)에서만 온다.
library;
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/shared/widgets/keep_all_text.dart';
import 'package:arasystem_app/shared/widgets/load_state_texts.dart';
import 'package:flutter/material.dart';
/// 문의 코드로 보이는 추적 ID 앞자리 수 — 웹 `TRACE_CODE_LENGTH` 와 같다
const int kTraceCodeLength = 8;
/// 가운데 상태의 아래 여백 — 안내가 화면 가운데보다 조금 위에 오게(보드의 `padding-bottom: 120px`)
const double _stateBottomSpace = 120;
/// 가운데 상태 화면의 틀 — 아이콘 원(72) · 제목(머리) · 그 아래 글 · 단추. 화면 가운데보다 조금 위에 온다.
/// 메일 목록의 빈 메일함 · 받지 못함, 메일 검색의 결과 없음 · 아직 못 찾음 · 찾을 말이 너무 많음, 업데이트 내역의 글 없음 · 받지 못함이 쓴다
class StateLayout extends StatelessWidget {
const StateLayout({
required this.icon,
required this.iconColor,
required this.iconBackground,
required this.title,
required this.children,
this.titleStyle,
super.key,
});
final IconData icon;
final Color iconColor;
final Color iconBackground;
final String title;
final List<Widget> children;
/// 제목 글자 — 없으면 `titleMedium`(목록 보드 17 → 18). 검색 보드 · 받지 못함의 긴 문장은 15(`titleSmall`)
final TextStyle? titleStyle;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s40,
0,
AppSpacing.s40,
_stateBottomSpace,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ExcludeSemantics(
child: Container(
width: AppSize.stateIcon,
height: AppSize.stateIcon,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: iconBackground,
),
child: Icon(icon, size: AppSize.stateIconGlyph, color: iconColor),
),
),
const SizedBox(height: AppSpacing.s16),
// 여러 줄(긴 사전 문구)이어도 가운데에 — 글 자체를 가운데 맞춘다
DefaultTextStyle.merge(
textAlign: TextAlign.center,
child: Semantics(
header: true,
child: KeepAllText(
title,
style:
titleStyle ??
theme.textTheme.titleMedium?.copyWith(
fontWeight: AppTypography.semiBold,
),
),
),
),
const SizedBox(height: AppSpacing.s8),
for (final Widget child in children)
DefaultTextStyle.merge(textAlign: TextAlign.center, child: child),
],
),
);
}
}
/// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도]. 없는 것(`RES_001`)이고 [onLeave] 가 있으면 [다시 시도] 대신 [leaveLabel] 단추
/// (다시 해도 같으니 빠져나간다 — 메일 목록은 [받은편지함으로], 읽기 화면 · 업데이트 내역의 글은 [목록으로], 쓰기 화면은 [닫기]).
/// 웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다. 제목은 [titleStyle](없으면 [StateLayout] 의 기본)
class LoadErrorView extends StatelessWidget {
const LoadErrorView({
required this.error,
required this.onRetry,
this.onLeave,
this.leaveLabel,
this.titleStyle,
super.key,
}) : assert(
onLeave == null || leaveLabel != null,
'onLeave 를 주면 leaveLabel 도 준다',
);
final ApiException error;
final VoidCallback onRetry;
/// 없는 것(`RES_001`)일 때 갈 곳 — 없으면 늘 [다시 시도]
final VoidCallback? onLeave;
/// [onLeave] 단추의 이름 — [onLeave] 와 함께 준다
final String? leaveLabel;
/// 제목 글자 — 없으면 [StateLayout] 의 기본(목록 보드의 크기)
final TextStyle? titleStyle;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final VoidCallback? leave = onLeave;
// 없는 것이면 다시 해도 같다 — 빠져나갈 단추의 이름(그 밖에는 null — [다시 시도])
final String? leaveText = error.code == 'RES_001' && leave != null
? leaveLabel
: null;
final String? trace = traceCodeOf(error.traceId);
return StateLayout(
icon: Icons.error_outline,
iconColor: theme.colorScheme.error,
iconBackground: theme.colorScheme.errorContainer,
title: error.message,
titleStyle: titleStyle,
children: <Widget>[
if (trace != null)
Text(
LoadStateTexts.traceCode(trace),
style: theme.textTheme.bodySmall?.copyWith(color: extra.textMuted),
),
TextButton(
onPressed: leaveText != null ? leave : onRetry,
child: Text(leaveText ?? LoadStateTexts.retry),
),
],
);
}
}
/// 추적 ID → 화면의 문의 코드(앞 8자리). 없으면 null
String? traceCodeOf(String? traceId) {
final String? value = traceId?.trim();
if (value == null || value.isEmpty) return null;
return value.length > kTraceCodeLength
? value.substring(0, kTraceCodeLength)
: value;
}
/// 처음 불러오는 동안의 뼈대 틀 — 화면 낭독기는 [label] 한 줄로 읽고(바뀌면 알린다) 안의 막대는 읽지 않는다.
/// 막대의 모양(줄 꼴)은 화면마다 [child] 로 그린다 — 메일 목록 · 검색(`MailListSkeleton`), 업데이트 내역의 목록 · 글
class LoadingSkeleton extends StatelessWidget {
const LoadingSkeleton({required this.label, required this.child, super.key});
/// 화면 낭독기 글 — "메일 목록 불러오는 중" · "업데이트 내역 불러오는 중"
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
return Semantics(
label: label,
liveRegion: true,
child: ExcludeSemantics(child: child),
);
}
}
/// 뼈대의 회색 막대 하나 — 폭은 부르는 쪽이(`FractionallySizedBox` · `SizedBox`) 정한다.
/// 메일 목록 보드는 높이 10 · 모서리 4, 업데이트 내역 보드는 끝이 둥근 막대([rounded])다
class SkeletonBar extends StatelessWidget {
const SkeletonBar({
required this.color,
this.height = AppSpacing.s10,
this.rounded = false,
super.key,
});
final Color color;
final double height;
/// 끝을 둥글게(알약) — 아니면 모서리 4
final bool rounded;
@override
Widget build(BuildContext context) {
return Container(
height: height,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(
rounded ? height / 2 : AppRadius.xs,
),
),
);
}
}