refactor(flutter): 메일 화면의 불러오기 상태 위젯을 shared 로 옮긴다

업데이트 내역(설정 모듈)도 같은 상태 틀을 쓰는데 모듈끼리 import 하지 않으므로 shared/widgets/load_states.dart 로 옮겼다.
모양 · 동작은 그대로다(MailStateLayout → StateLayout · MailListError → LoadErrorView · 막대 SkeletonBar · LoadingSkeleton). 메일 줄 꼴의 막대와 빈 메일함 등은 메일에 남는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 19:36:59 +09:00
parent cd958f3bf4
commit 1b3f9ba7fe
9 changed files with 288 additions and 220 deletions
@@ -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,