generated from ttipo/project-base
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:
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user