From 1b3f9ba7fecace778c3fe65f27a7211bcfed3d9b Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 10 Nov 2026 19:36:59 +0900 Subject: [PATCH] =?UTF-8?q?refactor(flutter):=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=98=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EC=83=81=ED=83=9C=20=EC=9C=84=EC=A0=AF=EC=9D=84=20?= =?UTF-8?q?shared=20=EB=A1=9C=20=EC=98=AE=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 업데이트 내역(설정 모듈)도 같은 상태 틀을 쓰는데 모듈끼리 import 하지 않으므로 shared/widgets/load_states.dart 로 옮겼다. 모양 · 동작은 그대로다(MailStateLayout → StateLayout · MailListError → LoadErrorView · 막대 SkeletonBar · LoadingSkeleton). 메일 줄 꼴의 막대와 빈 메일함 등은 메일에 남는다. Co-Authored-By: Claude Opus 5.5 --- .../mail/presentation/mail_texts.dart | 7 +- .../presentation/pages/mail_list_page.dart | 9 +- .../presentation/pages/mail_search_page.dart | 3 +- .../pages/message_compose_page.dart | 4 +- .../pages/message_reader_page.dart | 4 +- .../widgets/mail_list_states.dart | 252 ++++-------------- .../widgets/mail_search_states.dart | 10 +- .../lib/shared/widgets/load_state_texts.dart | 11 + flutter/lib/shared/widgets/load_states.dart | 208 +++++++++++++++ 9 files changed, 288 insertions(+), 220 deletions(-) create mode 100644 flutter/lib/shared/widgets/load_state_texts.dart create mode 100644 flutter/lib/shared/widgets/load_states.dart diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index 2ac2c16..c17ecd9 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -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); // ── 빈 메일함 ── /// "휴지통이 비어 있습니다" diff --git a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart index f41be4f..dc9336c 100644 --- a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart +++ b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart @@ -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( diff --git a/flutter/lib/features/mail/presentation/pages/mail_search_page.dart b/flutter/lib/features/mail/presentation/pages/mail_search_page.dart index 3b19957..64ae6ab 100644 --- a/flutter/lib/features/mail/presentation/pages/mail_search_page.dart +++ b/flutter/lib/features/mail/presentation/pages/mail_search_page.dart @@ -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) 다시 해도 같다 — 전체 메일함에서 찾는다 diff --git a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart index 25290a7..bccd38f 100644 --- a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart +++ b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart @@ -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, diff --git a/flutter/lib/features/mail/presentation/pages/message_reader_page.dart b/flutter/lib/features/mail/presentation/pages/message_reader_page.dart index d772425..b3b95c5 100644 --- a/flutter/lib/features/mail/presentation/pages/message_reader_page.dart +++ b/flutter/lib/features/mail/presentation/pages/message_reader_page.dart @@ -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, diff --git a/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart b/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart index 9f9ccd9..fd415ea 100644 --- a/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart +++ b/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart @@ -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()!; - 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()!; - 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: [ - 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 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: [ - 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: [ - 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: [ - 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: [ - 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: [ + 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: [ + 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: [ + 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), + ], + ), + ), + ], + ), + ), + ], ), ); } diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart index ac811ce..5428369 100644 --- a/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart @@ -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()!; - 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()!; - return MailStateLayout( + return StateLayout( icon: Icons.error_outline, iconColor: extra.textMuted, iconBackground: theme.colorScheme.surfaceContainerHigh, diff --git a/flutter/lib/shared/widgets/load_state_texts.dart b/flutter/lib/shared/widgets/load_state_texts.dart new file mode 100644 index 0000000..1337442 --- /dev/null +++ b/flutter/lib/shared/widgets/load_state_texts.dart @@ -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'; +} diff --git a/flutter/lib/shared/widgets/load_states.dart b/flutter/lib/shared/widgets/load_states.dart new file mode 100644 index 0000000..8a64db5 --- /dev/null +++ b/flutter/lib/shared/widgets/load_states.dart @@ -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 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: [ + 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()!; + 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: [ + 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, + ), + ), + ); + } +}