generated from ttipo/project-base
feat(flutter): 메일 목록 화면을 만들고 앱 틀이 화면마다 탭 막대를 숨길 수 있게 한다 (App1MailList)
앱 틀(core/router): - 틀을 Scaffold 가 아니라 Column + 탭 막대로 — 탭 안 화면마다 자기 Scaffold 를 두어 전역 알림이 [메일 쓰기] 위에 뜬다. 탭 막대는 위 안전 영역을 빼고(실기기에서 상태 표시줄만큼 두꺼워지지 않게), 화면이 ShellBarHidden 으로 숨길 수 있다(모듈마다 셈 · 보이는지는 TickerMode, 자판이 열리면 뺌) 메일 목록(features/mail): - 메일함 서랍(바꿔 끼움 · 안 읽은 수는 회색 글), 줄(사진 · 보낸 사람 · 시각 / 제목 · 첨부 / 미리 보기 한 줄 · 안 읽음 굵게 · 중요 별), 커서로 이어 받기 · 당겨서 새로고침 · 빈 메일함 · 30일 줄 - 고르기 막대(읽음 · 중요 · 이동 · 삭제) — 먼저 바꾸고 failedIds 만 되돌리기, 이동 · 삭제는 응답 id 만, 100통씩, 휴지통으로 옮기고 [되돌리기], 휴지통에서는 확인 뒤 영구 삭제, 붙잡힌 메일은 고를 수 없음 - 새 메일 확인: 보이고 앱이 앞에 있을 때만 1분마다, 다시 받은 쪽이 끝까지면 지운 옛 메일을 뺀다 - [되돌리기] 알림은 [메일 쓰기]와 한 자리(화면 낭독기를 켠 동안은 닫지 않음), 단추 없는 알림은 전역 스낵바 - 시험 346건(새로 119) 리뷰 반영: 탭 막대 높이, 고르는 중 알림 줄 위치, 이어 받기 중 새로고침 실패, 붙잡힌 메일 다시 보기, 메일함 역할을 알기 전 그리지 않음, 선택 풀기 · 되살리기를 계약에 맞춤, 맞추기 한 번에 하나. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,9 @@ library;
|
||||
/// 홈 화면 · 작업 전환 화면에 보이는 앱 이름
|
||||
const String kAppName = 'ARA SYSTEM';
|
||||
|
||||
/// 아래 탭 막대(모듈 메뉴)의 화면 낭독기 이름 — 앱 디자인 README "앱 틀"(보드 `App1Shell` 의 `aria-label`)
|
||||
const String kAppMenuLabel = '앱 메뉴';
|
||||
|
||||
/// 회사 메일 도메인 — 화면 안내 글에만 쓴다(로그인 화면의 "회사 메일 주소(@…)").
|
||||
/// 허용 도메인 검사는 서버가 한다. 웹 `frontend/src/shared/constants/brand.ts` 의 `COMPANY_MAIL_DOMAIN` 과 같은 값
|
||||
const String kCompanyMailDomain = 'arasystem.it.kr';
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import 'package:arasystem_app/core/constants/app_constants.dart';
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:arasystem_app/core/router/shell_bar.dart';
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/theme/app_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// 로그인 뒤 화면의 틀 — 아래 탭 막대가 웹의 전역 앱 레일에 해당한다.
|
||||
/// 로그인 뒤 화면의 틀 — 아래 탭 막대가 웹의 전역 앱 레일에 해당한다(앱 보드 `App1Shell`).
|
||||
///
|
||||
/// 탭은 켜진 모듈만, 모듈 목록 차례 그대로다. 탭마다 이동 기록을 따로 지킨다(StatefulShellRoute.indexedStack).
|
||||
/// 지금 탭을 다시 누르면 그 모듈의 첫 화면으로 돌아간다.
|
||||
/// 모양은 임시다 — 탭 막대 보드가 확정되면 이 위젯만 바꾼다.
|
||||
class AppShell extends StatelessWidget {
|
||||
/// - 탭은 켜진 모듈만, 모듈 목록 차례 그대로다. 탭마다 이동 기록을 따로 지킨다(StatefulShellRoute.indexedStack).
|
||||
/// 지금 탭을 다시 누르면 그 모듈의 첫 화면으로 돌아간다.
|
||||
/// - **틀은 Scaffold 가 아니다** — 탭 안 화면마다 자기 Scaffold 를 둔다. 그래서 그 화면의 Scaffold 가 전역 알림(스낵바)을 받고,
|
||||
/// 알림은 화면의 넓은 단추([메일 쓰기]) 위에 뜬다(틀이 Scaffold 면 알림이 단추를 가린다).
|
||||
/// - 지금 탭의 모듈이 막대를 숨겼거나([ShellBarHidden] — 여러 통 고르기) 자판이 열려 있으면 막대를 뺀다.
|
||||
/// 막대가 있을 때는 화면에서 아래 안전 영역을 뺀다(막대가 그 자리를 채운다).
|
||||
class AppShell extends ConsumerWidget {
|
||||
const AppShell({
|
||||
required this.navigationShell,
|
||||
required this.modules,
|
||||
@@ -22,27 +30,99 @@ class AppShell extends StatelessWidget {
|
||||
/// NavigationBar 는 탭이 둘 이상이어야 한다
|
||||
static const int _minTabs = 2;
|
||||
|
||||
AppModuleId? get _currentModule {
|
||||
final int index = navigationShell.currentIndex;
|
||||
return index >= 0 && index < modules.length ? modules[index].id : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: navigationShell,
|
||||
bottomNavigationBar: modules.length < _minTabs
|
||||
? null
|
||||
: NavigationBar(
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onDestinationSelected: (int index) => navigationShell.goBranch(
|
||||
index,
|
||||
initialLocation: index == navigationShell.currentIndex,
|
||||
),
|
||||
destinations: <Widget>[
|
||||
for (final AppModule module in modules)
|
||||
NavigationDestination(
|
||||
icon: Icon(module.icon),
|
||||
selectedIcon: Icon(module.selectedIcon),
|
||||
label: module.label,
|
||||
),
|
||||
],
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final AppModuleId? current = _currentModule;
|
||||
final bool hiddenByScreen =
|
||||
current != null &&
|
||||
ref.watch(
|
||||
shellBarHidingProvider.select(
|
||||
(Map<AppModuleId, int> hiding) => isShellBarHidden(hiding, current),
|
||||
),
|
||||
);
|
||||
final bool keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0;
|
||||
final bool showBar =
|
||||
modules.length >= _minTabs && !hiddenByScreen && !keyboardOpen;
|
||||
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
// 늘 같은 모양으로 감싼다 — 감싸는 위젯이 바뀌면 탭 안 화면(이동 기록)이 처음부터 다시 만들어진다
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeBottom: showBar,
|
||||
child: navigationShell,
|
||||
),
|
||||
),
|
||||
if (showBar)
|
||||
// NavigationBar 는 안에서 네 방향 SafeArea 를 쓴다 — 화면 아래에 붙으니 위쪽(상태 표시줄) 여백은 뺀다
|
||||
// (Scaffold 의 bottomNavigationBar 자리와 같다)
|
||||
MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: _ModuleTabBar(
|
||||
modules: modules,
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onSelected: (int index) => navigationShell.goBranch(
|
||||
index,
|
||||
initialLocation: index == navigationShell.currentIndex,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 아래 탭 막대 — 위에 옅은 선, 화면 낭독기 이름 "앱 메뉴"
|
||||
class _ModuleTabBar extends StatelessWidget {
|
||||
const _ModuleTabBar({
|
||||
required this.modules,
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final List<AppModule> modules;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AppExtraColors extra = Theme.of(context).extension<AppExtraColors>()!;
|
||||
return Semantics(
|
||||
container: true,
|
||||
label: kAppMenuLabel,
|
||||
explicitChildNodes: true,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: extra.borderSubtle,
|
||||
width: AppSize.borderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: NavigationBar(
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onSelected,
|
||||
destinations: <Widget>[
|
||||
for (final AppModule module in modules)
|
||||
NavigationDestination(
|
||||
icon: Icon(module.icon),
|
||||
selectedIcon: Icon(module.selectedIcon),
|
||||
label: module.label,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 아래 탭 막대를 잠깐 숨긴 화면 수 — 모듈마다 센다(앱 디자인 README "앱 틀": 여러 통 고르기 중에는 탭 막대를 숨긴다).
|
||||
///
|
||||
/// 틀(`app_shell.dart`)은 **지금 탭의 모듈**이 숨겼을 때만 막대를 빼므로, 다른 탭에서는 그대로 보인다.
|
||||
/// 화면은 직접 부르지 않고 [ShellBarHidden] 으로 감싼다(위젯 생명주기 안에서는 프로바이더를 바꿀 수 없다).
|
||||
final NotifierProvider<ShellBarHiding, Map<AppModuleId, int>>
|
||||
shellBarHidingProvider =
|
||||
NotifierProvider<ShellBarHiding, Map<AppModuleId, int>>(ShellBarHiding.new);
|
||||
|
||||
/// 모듈 [module] 의 탭에서 막대를 숨겼는가
|
||||
bool isShellBarHidden(Map<AppModuleId, int> hiding, AppModuleId module) =>
|
||||
(hiding[module] ?? 0) > 0;
|
||||
|
||||
/// 탭 막대를 숨긴 화면 수
|
||||
class ShellBarHiding extends Notifier<Map<AppModuleId, int>> {
|
||||
@override
|
||||
Map<AppModuleId, int> build() => const <AppModuleId, int>{};
|
||||
|
||||
/// 한 화면이 숨긴다
|
||||
void hide(AppModuleId module) {
|
||||
if (!ref.mounted) return;
|
||||
state = <AppModuleId, int>{...state, module: (state[module] ?? 0) + 1};
|
||||
}
|
||||
|
||||
/// 숨겼던 화면 하나가 돌려준다
|
||||
void reveal(AppModuleId module) {
|
||||
if (!ref.mounted) return;
|
||||
final int left = (state[module] ?? 0) - 1;
|
||||
final Map<AppModuleId, int> next = <AppModuleId, int>{...state};
|
||||
if (left > 0) {
|
||||
next[module] = left;
|
||||
} else {
|
||||
next.remove(module);
|
||||
}
|
||||
state = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// [hidden] 인 동안 [module] 의 탭에서 아래 탭 막대를 숨긴다 — 여러 통 고르기.
|
||||
///
|
||||
/// 이 화면이 보일 때만 숨긴다 — 위에 다른 화면이 쌓였거나(받은편지함 위의 다른 메일함 · 전체 화면) 다른 탭이면
|
||||
/// 돌려준다(TickerMode). 고르던 받은편지함 위에 다른 메일함을 열어도 그 화면에는 탭 막대가 있다.
|
||||
/// 프로바이더는 그리는 동안 바꿀 수 없어 그린 뒤에 알린다(한 프레임 늦다). 이 위젯이 사라지면 숨긴 것을 돌려준다.
|
||||
class ShellBarHidden extends ConsumerStatefulWidget {
|
||||
const ShellBarHidden({
|
||||
required this.module,
|
||||
required this.hidden,
|
||||
required this.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppModuleId module;
|
||||
final bool hidden;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<ShellBarHidden> createState() => _ShellBarHiddenState();
|
||||
}
|
||||
|
||||
class _ShellBarHiddenState extends ConsumerState<ShellBarHidden> {
|
||||
/// 지금 숨겨 둔 모듈 — 숨기지 않았으면 null
|
||||
AppModuleId? _hiddenModule;
|
||||
ShellBarHiding? _hiding;
|
||||
|
||||
/// 이 화면이 보이는가 — 가려졌으면 숨기지 않는다
|
||||
bool _visible = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scheduleSync();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final bool visible = TickerMode.valuesOf(context).enabled;
|
||||
if (visible == _visible) return;
|
||||
_visible = visible;
|
||||
_scheduleSync();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ShellBarHidden oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.hidden != widget.hidden ||
|
||||
oldWidget.module != widget.module) {
|
||||
_scheduleSync();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleSync() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((Duration _) => _sync());
|
||||
}
|
||||
|
||||
void _sync() {
|
||||
if (!mounted) return;
|
||||
final AppModuleId? wanted = widget.hidden && _visible
|
||||
? widget.module
|
||||
: null;
|
||||
if (wanted == _hiddenModule) return;
|
||||
final ShellBarHiding hiding =
|
||||
_hiding ?? ref.read(shellBarHidingProvider.notifier);
|
||||
_hiding = hiding;
|
||||
final AppModuleId? previous = _hiddenModule;
|
||||
if (previous != null) hiding.reveal(previous);
|
||||
if (wanted != null) hiding.hide(wanted);
|
||||
_hiddenModule = wanted;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final AppModuleId? module = _hiddenModule;
|
||||
final ShellBarHiding? hiding = _hiding;
|
||||
// 위젯을 지우는 동안에는 프로바이더를 바꿀 수 없다 — 이 프레임이 끝난 뒤에 돌려준다
|
||||
if (module != null && hiding != null) {
|
||||
scheduleMicrotask(() => hiding.reveal(module));
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
@@ -24,6 +24,12 @@ abstract final class AppColors {
|
||||
/// `--color-brand-tint` — 여러 통 처리 도구 줄 · 새 메일 알림 줄 바탕
|
||||
static const Color brandTint = Color(0xFFF3F8F8);
|
||||
|
||||
/// `--color-brand-tint-border` — 도구 줄(바탕 [brandTint])의 아래 선
|
||||
static const Color brandTintBorder = Color(0xFFE3EEEF);
|
||||
|
||||
/// `--color-row-checked` — 고른 행 (여러 통 처리)
|
||||
static const Color rowChecked = Color(0xFFE8F4F5);
|
||||
|
||||
/// `--color-on-brand`
|
||||
static const Color onBrand = Color(0xFFFFFFFF);
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ abstract final class AppRadius {
|
||||
|
||||
/// `--radius-xl` — 대화상자
|
||||
static const double xl = 12;
|
||||
|
||||
// ── 앱 보드에서 정한 모서리 (`App1MailList` · `App1Shell` — 2026-11-05 확정) ──
|
||||
/// [메일 쓰기] 단추 · 메일함 서랍의 오른쪽 모서리 · 확인 창 · 아래에서 올라오는 시트
|
||||
static const double xxl = 16;
|
||||
}
|
||||
|
||||
/// 크기
|
||||
@@ -79,4 +83,41 @@ abstract final class AppSize {
|
||||
|
||||
/// 넓은 화면(태블릿 · 가로)에서 양식 열의 최대 폭 — 휴대폰(390)에서는 화면 폭을 다 쓴다
|
||||
static const double formMaxWidth = 480;
|
||||
|
||||
// ── 앱 보드에서 정한 크기 (`App1MailList` · `App1Shell` — 2026-11-05 확정) ──
|
||||
/// 위 막대 높이 — 목록의 머리 · 여러 통 고르기 막대
|
||||
static const double appBarHeight = 64;
|
||||
|
||||
/// 아래 탭 막대 높이
|
||||
static const double navigationBarHeight = 72;
|
||||
|
||||
/// 큰 아이콘 — 위 막대의 서랍 열기 · 찾기 · 선택 해제
|
||||
static const double iconLarge = 24;
|
||||
|
||||
/// 도구 아이콘 — 고르기 막대 · 아래 탭 막대 · [메일 쓰기]
|
||||
static const double iconToolbar = 22;
|
||||
|
||||
/// 작은 표시 아이콘 — 목록 줄의 첨부 · 중요
|
||||
static const double iconTiny = 16;
|
||||
|
||||
/// 목록 줄의 동그란 사진(머리글자)
|
||||
static const double avatar = 40;
|
||||
|
||||
/// 메일함 서랍의 폭 — 좁은 휴대폰에서는 화면 폭에서 [drawerMinGap] 을 남긴다
|
||||
static const double drawerWidth = 304;
|
||||
|
||||
/// 메일함 서랍 오른쪽에 남기는 가림막 폭 (Material 서랍 규칙)
|
||||
static const double drawerMinGap = 56;
|
||||
|
||||
/// 빈 메일함 · 받지 못함의 아이콘 원
|
||||
static const double stateIcon = 72;
|
||||
|
||||
/// 빈 메일함 · 받지 못함 원 안의 아이콘
|
||||
static const double stateIconGlyph = 30;
|
||||
|
||||
/// 알림 줄(되돌리기)의 최소 높이
|
||||
static const double toastMinHeight = 52;
|
||||
|
||||
/// [메일 쓰기] 단추 높이 (Material 넓은 단추 그대로) — 목록 아래 여백이 이만큼 + 40 이다
|
||||
static const double fabHeight = 56;
|
||||
}
|
||||
|
||||
@@ -97,10 +97,45 @@ abstract final class AppTheme {
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
),
|
||||
navigationBarTheme: _navigationBarTheme(scheme, textTheme),
|
||||
extensions: const <AppExtraColors>[AppExtraColors.light],
|
||||
);
|
||||
}
|
||||
|
||||
/// 아래 탭 막대 — 앱 보드 `App1Shell` · `App1MailList`(높이 72 · 고른 탭은 청록 바탕 알약 안의 아이콘 · 이름 12.5 → 테마 12).
|
||||
/// 알약 크기는 Material 3 그대로(64×32 — 보드는 56×32)다. 위 선은 틀(`app_shell.dart`)이 그린다
|
||||
static NavigationBarThemeData _navigationBarTheme(
|
||||
ColorScheme scheme,
|
||||
TextTheme textTheme,
|
||||
) {
|
||||
return NavigationBarThemeData(
|
||||
height: AppSize.navigationBarHeight,
|
||||
backgroundColor: scheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
indicatorColor: scheme.primaryContainer,
|
||||
indicatorShape: const StadiumBorder(),
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith<TextStyle?>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
final bool selected = states.contains(WidgetState.selected);
|
||||
return textTheme.labelSmall?.copyWith(
|
||||
fontWeight: selected ? AppTypography.bold : AppTypography.medium,
|
||||
color: selected ? scheme.onPrimaryContainer : scheme.onSurfaceVariant,
|
||||
);
|
||||
}),
|
||||
iconTheme: WidgetStateProperty.resolveWith<IconThemeData?>(
|
||||
(Set<WidgetState> states) => IconThemeData(
|
||||
size: AppSize.iconToolbar,
|
||||
color: states.contains(WidgetState.selected)
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static OutlineInputBorder _inputBorder(
|
||||
Color color, {
|
||||
double width = AppSize.borderWidth,
|
||||
@@ -169,6 +204,8 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
|
||||
required this.warningBorder,
|
||||
required this.successText,
|
||||
required this.star,
|
||||
required this.rowChecked,
|
||||
required this.brandTintBorder,
|
||||
});
|
||||
|
||||
static const AppExtraColors light = AppExtraColors(
|
||||
@@ -184,6 +221,8 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
|
||||
warningBorder: AppColors.warningBorder,
|
||||
successText: AppColors.successText,
|
||||
star: AppColors.star,
|
||||
rowChecked: AppColors.rowChecked,
|
||||
brandTintBorder: AppColors.brandTintBorder,
|
||||
);
|
||||
|
||||
/// 시각 · 개수 · 보조 설명
|
||||
@@ -214,6 +253,12 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
|
||||
/// 중요 표시
|
||||
final Color star;
|
||||
|
||||
/// 고른 행 (여러 통 처리)
|
||||
final Color rowChecked;
|
||||
|
||||
/// 여러 통 고르기 막대(바탕 `secondaryContainer`)의 아래 선
|
||||
final Color brandTintBorder;
|
||||
|
||||
@override
|
||||
AppExtraColors copyWith({
|
||||
Color? textMuted,
|
||||
@@ -228,6 +273,8 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
|
||||
Color? warningBorder,
|
||||
Color? successText,
|
||||
Color? star,
|
||||
Color? rowChecked,
|
||||
Color? brandTintBorder,
|
||||
}) {
|
||||
return AppExtraColors(
|
||||
textMuted: textMuted ?? this.textMuted,
|
||||
@@ -242,6 +289,8 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
|
||||
warningBorder: warningBorder ?? this.warningBorder,
|
||||
successText: successText ?? this.successText,
|
||||
star: star ?? this.star,
|
||||
rowChecked: rowChecked ?? this.rowChecked,
|
||||
brandTintBorder: brandTintBorder ?? this.brandTintBorder,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -261,6 +310,8 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
|
||||
warningBorder: Color.lerp(warningBorder, other.warningBorder, t)!,
|
||||
successText: Color.lerp(successText, other.successText, t)!,
|
||||
star: Color.lerp(star, other.star, t)!,
|
||||
rowChecked: Color.lerp(rowChecked, other.rowChecked, t)!,
|
||||
brandTintBorder: Color.lerp(brandTintBorder, other.brandTintBorder, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 화면 낭독기(TalkBack 등)로 화면을 다루는 중인가 — 기기의 접근성 설정 `accessibleNavigation`.
|
||||
///
|
||||
/// 단추가 있는 알림([되돌리기])은 이 동안 저절로 닫지 않는다 — Flutter SnackBar · 웹 알림의 붙잡기(hold)와 같은 결이다.
|
||||
/// 화면 밖(노티파이어)에서 읽으려고 둔다. 시험은 `tester.platformDispatcher.accessibilityFeaturesTestValue` 로 바꾸거나 이것을 덮어쓴다.
|
||||
final NotifierProvider<AccessibleNavigation, bool>
|
||||
accessibleNavigationProvider = NotifierProvider<AccessibleNavigation, bool>(
|
||||
AccessibleNavigation.new,
|
||||
);
|
||||
|
||||
/// 기기의 접근성 설정을 듣는다
|
||||
class AccessibleNavigation extends Notifier<bool> with WidgetsBindingObserver {
|
||||
@override
|
||||
bool build() {
|
||||
final WidgetsBinding binding = WidgetsBinding.instance;
|
||||
binding.addObserver(this);
|
||||
ref.onDispose(() => binding.removeObserver(this));
|
||||
return _read(binding);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAccessibilityFeatures() {
|
||||
if (!ref.mounted) return;
|
||||
state = _read(WidgetsBinding.instance);
|
||||
}
|
||||
|
||||
static bool _read(WidgetsBinding binding) =>
|
||||
binding.platformDispatcher.accessibilityFeatures.accessibleNavigation;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 앱이 앞에 보이는가 — 안드로이드의 앱 생명주기가 `resumed` 일 때만 참이다.
|
||||
///
|
||||
/// 화면이 보이는 동안만 하는 일(새 메일 1분 확인 — 웹의 `visibilityState === 'visible'` 자리)이 듣는다.
|
||||
/// 시험은 `tester.binding.handleAppLifecycleStateChanged` 로 바꾸거나 이 프로바이더를 덮어쓴다.
|
||||
final NotifierProvider<AppForeground, bool> appForegroundProvider =
|
||||
NotifierProvider<AppForeground, bool>(AppForeground.new);
|
||||
|
||||
/// 앱 생명주기를 듣는다
|
||||
class AppForeground extends Notifier<bool> {
|
||||
@override
|
||||
bool build() {
|
||||
final AppLifecycleListener listener = AppLifecycleListener(
|
||||
onStateChange: (AppLifecycleState lifecycle) =>
|
||||
state = lifecycle == AppLifecycleState.resumed,
|
||||
);
|
||||
ref.onDispose(listener.dispose);
|
||||
final AppLifecycleState? current = WidgetsBinding.instance.lifecycleState;
|
||||
// 아직 생명주기를 받기 전(앱을 막 띄움 · 시험)은 앞에 보이는 것으로 본다
|
||||
return current == null || current == AppLifecycleState.resumed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/// 화면에 보이는 숫자 · 시각 표기 — 웹 `shared/utils/format.ts` 의 ko-KR 표기와 같은 글을 손으로 만든다(순수 함수).
|
||||
///
|
||||
/// `intl` 의 날짜 형식은 로캘 자료를 먼저 읽어야 해서(앱은 MaterialApp 이 읽는다) 순수 함수 시험에서 쓰기 번거롭다 —
|
||||
/// 웹 `Intl.DateTimeFormat('ko-KR')` 이 내는 글 그대로를 여기서 만든다. 시각은 **기기 시간대**로 바꾼 값(`toLocal()`)을 받는다.
|
||||
library;
|
||||
|
||||
/// 개수 — 1234 → "1,234" (웹 `formatCount` 와 같다)
|
||||
String formatCount(int value) {
|
||||
final String digits = value.abs().toString();
|
||||
final StringBuffer out = StringBuffer(value < 0 ? '-' : '');
|
||||
for (int index = 0; index < digits.length; index++) {
|
||||
if (index > 0 && (digits.length - index) % _digitGroup == 0) {
|
||||
out.write(',');
|
||||
}
|
||||
out.write(digits[index]);
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
const int _digitGroup = 3;
|
||||
const int _hoursHalfDay = 12;
|
||||
|
||||
/// 하루 안의 시각 — "오전 10:42" · "오후 3:05" · "오전 12:30" (웹 `{ hour: 'numeric', minute: '2-digit' }` 와 같다)
|
||||
String koreanTimeOfDay(DateTime local) {
|
||||
final String half = local.hour < _hoursHalfDay ? '오전' : '오후';
|
||||
final int hour = local.hour % _hoursHalfDay == 0
|
||||
? _hoursHalfDay
|
||||
: local.hour % _hoursHalfDay;
|
||||
final String minute = local.minute.toString().padLeft(2, '0');
|
||||
return '$half $hour:$minute';
|
||||
}
|
||||
|
||||
/// 달 · 날 — "11월 3일" (웹 `{ month: 'long', day: 'numeric' }`)
|
||||
String koreanMonthDay(DateTime local) => '${local.month}월 ${local.day}일';
|
||||
|
||||
/// 연도까지 숫자로 — "2025. 10. 24." (웹 `{ year · month · day: 'numeric' }`)
|
||||
String koreanNumericDate(DateTime local) =>
|
||||
'${local.year}. ${local.month}. ${local.day}.';
|
||||
|
||||
/// 같은 달력 날인가 (둘 다 같은 시간대로 바꾼 값)
|
||||
bool isSameCalendarDay(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
|
||||
/// 하루 전 달력 날 — 달 · 해의 경계를 넘는다(1월 1일 → 12월 31일)
|
||||
DateTime previousCalendarDay(DateTime local) =>
|
||||
DateTime(local.year, local.month, local.day - 1);
|
||||
@@ -1,4 +1,5 @@
|
||||
/// 한국어 글의 줄 나눔 — 웹 `word-break: keep-all` 에 해당한다(앱 보드 `App1Login` 의 안내 글).
|
||||
/// 한국어 글 다듬기 — 줄 나눔(웹 `word-break: keep-all` — 앱 보드 `App1Login` 의 안내 글)과
|
||||
/// 이름 뒤의 조사(받침에 맞춰 "이/가" · "으로/로" — 웹 `shared/utils/korean.ts` 와 같다).
|
||||
library;
|
||||
|
||||
/// WORD JOINER — 보이지 않고, 그 앞뒤에서 줄을 나누지 못하게 한다(유니코드 줄 나눔 규칙 WJ)
|
||||
@@ -26,7 +27,42 @@ String keepAllWordBreak(String text) {
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
bool _isHangulSyllable(int rune) => rune >= 0xAC00 && rune <= 0xD7A3;
|
||||
bool _isHangulSyllable(int rune) => rune >= _hangulFirst && rune <= _hangulLast;
|
||||
|
||||
const int _hangulFirst = 0xAC00;
|
||||
const int _hangulLast = 0xD7A3;
|
||||
|
||||
/// 한글 음절 하나의 받침 경우의 수(받침 없음 포함)
|
||||
const int _jongseongCount = 28;
|
||||
const int _noJongseong = 0;
|
||||
|
||||
/// ㄹ 받침 — 방향 조사가 "로"다(서울로 · 메일로)
|
||||
const int _jongseongRieul = 8;
|
||||
|
||||
/// 마지막 글자의 받침 번호 — 한글 음절로 끝나지 않으면(영문 · 숫자 · 빈 글) null
|
||||
int? _lastJongseong(String word) {
|
||||
final String trimmed = word.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
final int last = trimmed.runes.last;
|
||||
if (!_isHangulSyllable(last)) return null;
|
||||
return (last - _hangulFirst) % _jongseongCount;
|
||||
}
|
||||
|
||||
/// 주격 조사 "이/가" — 받침이 있으면 "이", 없으면 "가"(휴지통이 · 받은편지함이 · 프로젝트가).
|
||||
/// 한글로 끝나지 않는 이름(영문 · 숫자)은 소리를 알 수 없어 "이(가)" 다 (웹 `korean.ts` 의 `subjectParticle` 과 같다).
|
||||
String subjectParticle(String word) {
|
||||
final int? jongseong = _lastJongseong(word);
|
||||
if (jongseong == null) return '이(가)';
|
||||
return jongseong == _noJongseong ? '가' : '이';
|
||||
}
|
||||
|
||||
/// 방향 조사 "으로/로" — 받침이 있으면 "으로", 없거나 ㄹ 받침이면 "로"(휴지통으로 · 보낸편지함으로 · 프로젝트로).
|
||||
/// 한글로 끝나지 않는 이름은 "(으)로" 다 (웹 `korean.ts` 의 `directionParticle` 과 같다).
|
||||
String directionParticle(String word) {
|
||||
final int? jongseong = _lastJongseong(word);
|
||||
if (jongseong == null) return '(으)로';
|
||||
return jongseong == _noJongseong || jongseong == _jongseongRieul ? '로' : '으로';
|
||||
}
|
||||
|
||||
/// 띄어쓰기 · 제어 문자를 뺀 ASCII 글자 — 영문 · 숫자 · 기호
|
||||
bool _isAsciiGraphic(int rune) => rune >= 0x21 && rune <= 0x7E;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 메일 주소 하나 — 계약 2절 `Address { name: string | null; email: string }`.
|
||||
///
|
||||
/// 모델은 손으로 쓴다(flutter 스킬 "모델 생성기"). 모양이 틀리면 [tryParse] 가 null 이다 — 저장소가 `SYS_001` 로 던진다.
|
||||
@immutable
|
||||
class MailAddress {
|
||||
const MailAddress({required this.email, this.name});
|
||||
|
||||
/// 보이는 이름 — 없으면 null
|
||||
final String? name;
|
||||
final String email;
|
||||
|
||||
/// 목록에 보이는 이름 — 이름이 없거나 비었으면 주소 (웹 `displayName`)
|
||||
String get displayName {
|
||||
final String? trimmed = name?.trim();
|
||||
return trimmed == null || trimmed.isEmpty ? email : trimmed;
|
||||
}
|
||||
|
||||
static MailAddress? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? email = json['email'];
|
||||
final Object? name = json['name'];
|
||||
if (email is! String || (name != null && name is! String)) return null;
|
||||
return MailAddress(email: email, name: name as String?);
|
||||
}
|
||||
|
||||
/// 주소 배열 — 하나라도 모양이 틀리면 null
|
||||
static List<MailAddress>? tryParseList(Object? json) {
|
||||
if (json is! List<Object?>) return null;
|
||||
final List<MailAddress> addresses = <MailAddress>[];
|
||||
for (final Object? item in json) {
|
||||
final MailAddress? address = tryParse(item);
|
||||
if (address == null) return null;
|
||||
addresses.add(address);
|
||||
}
|
||||
return List<MailAddress>.unmodifiable(addresses);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MailAddress && other.email == email && other.name == name;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(email, name);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 메일 한 통 · 여러 통에 바꿀 것 — `PATCH /api/messages{/id}` 의 본문(계약 2절). 하나 이상 넣는다
|
||||
@immutable
|
||||
class MessageChanges {
|
||||
const MessageChanges({
|
||||
this.isRead,
|
||||
this.isFlagged,
|
||||
this.mailboxId,
|
||||
this.isJunk,
|
||||
});
|
||||
|
||||
final bool? isRead;
|
||||
final bool? isFlagged;
|
||||
|
||||
/// 이동 — 그 메일함 하나에만 속하게
|
||||
final String? mailboxId;
|
||||
|
||||
/// 스팸 표시 (4단계 "스팸 · 원문")
|
||||
final bool? isJunk;
|
||||
|
||||
/// 보낼 본문 — 정한 칸만 싣는다
|
||||
Map<String, Object?> toJson() => <String, Object?>{
|
||||
'isRead': ?isRead,
|
||||
'isFlagged': ?isFlagged,
|
||||
'mailboxId': ?mailboxId,
|
||||
'isJunk': ?isJunk,
|
||||
};
|
||||
}
|
||||
|
||||
/// `PATCH /api/messages` 의 결과 — 계약 2절 `MessagesPatched`
|
||||
@immutable
|
||||
class MessagesPatched {
|
||||
const MessagesPatched({required this.updatedIds, required this.failedIds});
|
||||
|
||||
final List<String> updatedIds;
|
||||
|
||||
/// 없음 · 그사이 지워짐 · 엔진이 거절함 · 붙잡힌 메일의 이동 — 나머지는 그대로 처리했다
|
||||
final List<String> failedIds;
|
||||
|
||||
static MessagesPatched? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final List<String>? updated = _idsOf(json['updatedIds']);
|
||||
final List<String>? failed = _idsOf(json['failedIds']);
|
||||
if (updated == null || failed == null) return null;
|
||||
return MessagesPatched(updatedIds: updated, failedIds: failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/message-deletions` 의 결과 — 계약 2절 `MessageDeletionResult`
|
||||
@immutable
|
||||
class MessageDeletions {
|
||||
const MessageDeletions({required this.deletedIds, required this.skippedIds});
|
||||
|
||||
/// 영구 삭제했다 · 휴지통에서만 뺐다
|
||||
final List<String> deletedIds;
|
||||
|
||||
/// 휴지통에 없음 · 없음 · 엔진이 거절 · 붙잡힌 메일 — 지우지 않았다
|
||||
final List<String> skippedIds;
|
||||
|
||||
static MessageDeletions? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final List<String>? deleted = _idsOf(json['deletedIds']);
|
||||
final List<String>? skipped = _idsOf(json['skippedIds']);
|
||||
if (deleted == null || skipped == null) return null;
|
||||
return MessageDeletions(deletedIds: deleted, skippedIds: skipped);
|
||||
}
|
||||
}
|
||||
|
||||
List<String>? _idsOf(Object? value) {
|
||||
if (value is! List<Object?>) return null;
|
||||
if (value.any((Object? id) => id is! String)) return null;
|
||||
return List<String>.unmodifiable(value.cast<String>());
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 목록 응답의 `pageInfo` — 응답 포맷 규칙 "목록 응답 (페이지네이션)"
|
||||
@immutable
|
||||
class MailPageInfo {
|
||||
const MailPageInfo({required this.hasNext, this.nextCursor, this.total});
|
||||
|
||||
final bool hasNext;
|
||||
|
||||
/// 다음 쪽 커서 — 해석하지 않고 그대로 다시 보낸다(계약 2절). 마지막 쪽이면 null
|
||||
final String? nextCursor;
|
||||
|
||||
/// 전체 수 — 계산 비용이 크면 null
|
||||
final int? total;
|
||||
|
||||
/// 다음 쪽이 있는가 — `hasNext` 이고 커서가 있을 때만
|
||||
bool get hasMore => hasNext && (nextCursor?.isNotEmpty ?? false);
|
||||
|
||||
MailPageInfo copyWith({int? total}) => MailPageInfo(
|
||||
hasNext: hasNext,
|
||||
nextCursor: nextCursor,
|
||||
total: total ?? this.total,
|
||||
);
|
||||
|
||||
static MailPageInfo? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? hasNext = json['hasNext'];
|
||||
final Object? nextCursor = json['nextCursor'];
|
||||
final Object? total = json['total'];
|
||||
if (hasNext is! bool ||
|
||||
(nextCursor != null && nextCursor is! String) ||
|
||||
(total != null && total is! num)) {
|
||||
return null;
|
||||
}
|
||||
return MailPageInfo(
|
||||
hasNext: hasNext,
|
||||
nextCursor: nextCursor as String?,
|
||||
total: (total as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MailPageInfo &&
|
||||
other.hasNext == hasNext &&
|
||||
other.nextCursor == nextCursor &&
|
||||
other.total == total;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(hasNext, nextCursor, total);
|
||||
}
|
||||
|
||||
/// 목록 한 쪽 — `{ items, pageInfo }`
|
||||
@immutable
|
||||
class MailPage<T> {
|
||||
const MailPage({
|
||||
required this.items,
|
||||
required this.pageInfo,
|
||||
this.serverTime,
|
||||
});
|
||||
|
||||
final List<T> items;
|
||||
final MailPageInfo pageInfo;
|
||||
|
||||
/// 응답의 `Date` 머리(서버 시각) — 없으면 null. 붙잡아 둔 발송이 풀릴 때를 서버 시계로 재는 데 쓴다
|
||||
final DateTime? serverTime;
|
||||
|
||||
/// 서버 시각을 붙인 쪽
|
||||
MailPage<T> withServerTime(DateTime? serverTime) =>
|
||||
MailPage<T>(items: items, pageInfo: pageInfo, serverTime: serverTime);
|
||||
|
||||
/// 모양이 틀리면(항목 하나라도) null
|
||||
static MailPage<T>? tryParse<T>(
|
||||
Object? json,
|
||||
T? Function(Object? item) parseItem,
|
||||
) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? rawItems = json['items'];
|
||||
final MailPageInfo? pageInfo = MailPageInfo.tryParse(json['pageInfo']);
|
||||
if (rawItems is! List<Object?> || pageInfo == null) return null;
|
||||
final List<T> items = <T>[];
|
||||
for (final Object? raw in rawItems) {
|
||||
final T? item = parseItem(raw);
|
||||
if (item == null) return null;
|
||||
items.add(item);
|
||||
}
|
||||
return MailPage<T>(items: List<T>.unmodifiable(items), pageInfo: pageInfo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 메일함 역할 — 계약 2절 `Mailbox.role` (역할이 있으면 서버가 한국어 표준 이름을 준다)
|
||||
enum MailboxRole {
|
||||
inbox,
|
||||
drafts,
|
||||
sent,
|
||||
junk,
|
||||
trash,
|
||||
archive;
|
||||
|
||||
/// 응답 칸을 읽는다 — null · 모르는 이름은 역할 없음(사용자가 만든 메일함)으로 본다
|
||||
static MailboxRole? parse(Object? value) {
|
||||
for (final MailboxRole role in MailboxRole.values) {
|
||||
if (role.name == value) return role;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 메일함 하나 — 계약 2절 `Mailbox`
|
||||
@immutable
|
||||
class Mailbox {
|
||||
const Mailbox({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.role,
|
||||
required this.parentId,
|
||||
required this.sortOrder,
|
||||
required this.totalCount,
|
||||
required this.unreadCount,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
/// 역할이 있으면 한국어 표준 이름(받은편지함 등), 없으면 서버의 이름
|
||||
final String name;
|
||||
|
||||
/// 역할 — null 이면 사용자가 만든 메일함("내 메일함")
|
||||
final MailboxRole? role;
|
||||
final String? parentId;
|
||||
final int sortOrder;
|
||||
final int totalCount;
|
||||
final int unreadCount;
|
||||
|
||||
Mailbox copyWith({int? unreadCount}) => Mailbox(
|
||||
id: id,
|
||||
name: name,
|
||||
role: role,
|
||||
parentId: parentId,
|
||||
sortOrder: sortOrder,
|
||||
totalCount: totalCount,
|
||||
unreadCount: unreadCount ?? this.unreadCount,
|
||||
);
|
||||
|
||||
/// 모양이 틀리면 null
|
||||
static Mailbox? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? id = json['id'];
|
||||
final Object? name = json['name'];
|
||||
final Object? parentId = json['parentId'];
|
||||
final Object? sortOrder = json['sortOrder'];
|
||||
final Object? totalCount = json['totalCount'];
|
||||
final Object? unreadCount = json['unreadCount'];
|
||||
if (id is! String ||
|
||||
id.isEmpty ||
|
||||
name is! String ||
|
||||
(parentId != null && parentId is! String) ||
|
||||
sortOrder is! num ||
|
||||
totalCount is! num ||
|
||||
unreadCount is! num) {
|
||||
return null;
|
||||
}
|
||||
return Mailbox(
|
||||
id: id,
|
||||
name: name,
|
||||
role: MailboxRole.parse(json['role']),
|
||||
parentId: parentId as String?,
|
||||
sortOrder: sortOrder.toInt(),
|
||||
totalCount: totalCount.toInt(),
|
||||
unreadCount: unreadCount.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Mailbox &&
|
||||
other.id == id &&
|
||||
other.name == name &&
|
||||
other.role == role &&
|
||||
other.parentId == parentId &&
|
||||
other.sortOrder == sortOrder &&
|
||||
other.totalCount == totalCount &&
|
||||
other.unreadCount == unreadCount;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(id, name, role, parentId, sortOrder, totalCount, unreadCount);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 붙잡아 둔 내 발송의 종류 — 계약 2절 "보내기 취소 · 예약 발송"(9단계)
|
||||
enum PendingSendKind {
|
||||
/// [보내기]를 누른 뒤 보내기 취소를 기다리는 중
|
||||
undo,
|
||||
|
||||
/// 예약 발송
|
||||
scheduled,
|
||||
}
|
||||
|
||||
/// 엔진이 붙잡아 둔 내 발송 — 아직 보내지 않았다(보내는 중 · 예약)
|
||||
@immutable
|
||||
class PendingSend {
|
||||
const PendingSend({required this.sendAt, required this.kind});
|
||||
|
||||
/// 엔진이 보낼 시각 (UTC)
|
||||
final DateTime sendAt;
|
||||
final PendingSendKind kind;
|
||||
|
||||
/// 응답의 값을 읽는다 — 칸이 없거나(9단계 앞 서버) 모양이 틀리면 없는 것(null)으로 본다(웹 `pendingSendOf` 와 같다)
|
||||
static PendingSend? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? sendAt = json['sendAt'];
|
||||
final Object? kind = json['kind'];
|
||||
final DateTime? at = sendAt is String ? DateTime.tryParse(sendAt) : null;
|
||||
final PendingSendKind? parsedKind = switch (kind) {
|
||||
'undo' => PendingSendKind.undo,
|
||||
'scheduled' => PendingSendKind.scheduled,
|
||||
_ => null,
|
||||
};
|
||||
if (at == null || parsedKind == null) return null;
|
||||
return PendingSend(sendAt: at.toUtc(), kind: parsedKind);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is PendingSend && other.sendAt == sendAt && other.kind == kind;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(sendAt, kind);
|
||||
}
|
||||
|
||||
/// 목록의 메일 한 통 — 계약 2절 `MessageSummary`
|
||||
@immutable
|
||||
class MessageSummary {
|
||||
const MessageSummary({
|
||||
required this.id,
|
||||
required this.threadId,
|
||||
required this.mailboxIds,
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.subject,
|
||||
required this.preview,
|
||||
required this.receivedAt,
|
||||
required this.isRead,
|
||||
required this.isFlagged,
|
||||
required this.hasAttachment,
|
||||
required this.size,
|
||||
this.pendingSend,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String threadId;
|
||||
final List<String> mailboxIds;
|
||||
final MailAddress? from;
|
||||
final List<MailAddress> to;
|
||||
|
||||
/// 제목이 없으면 빈 글
|
||||
final String subject;
|
||||
final String preview;
|
||||
|
||||
/// 받은 시각 (UTC)
|
||||
final DateTime receivedAt;
|
||||
final bool isRead;
|
||||
final bool isFlagged;
|
||||
final bool hasAttachment;
|
||||
|
||||
/// 바이트
|
||||
final int size;
|
||||
|
||||
/// 아직 보내지 않은 내 메일이면 그 정보 — 그 밖에는 null
|
||||
final PendingSend? pendingSend;
|
||||
|
||||
/// 아직 보내지 않은 메일(보내는 중 · 예약)인가 — 취소해야만 보낸편지함을 떠난다.
|
||||
/// 옮기거나 지워도 엔진은 그 시각에 보내므로 목록에서 고를 수 없다(계약 2절 "붙잡힌 메일은 취소로만 보낸편지함을 떠난다")
|
||||
bool get isHeld => pendingSend != null;
|
||||
|
||||
MessageSummary copyWith({
|
||||
bool? isRead,
|
||||
bool? isFlagged,
|
||||
List<String>? mailboxIds,
|
||||
}) => MessageSummary(
|
||||
id: id,
|
||||
threadId: threadId,
|
||||
mailboxIds: mailboxIds ?? this.mailboxIds,
|
||||
from: from,
|
||||
to: to,
|
||||
subject: subject,
|
||||
preview: preview,
|
||||
receivedAt: receivedAt,
|
||||
isRead: isRead ?? this.isRead,
|
||||
isFlagged: isFlagged ?? this.isFlagged,
|
||||
hasAttachment: hasAttachment,
|
||||
size: size,
|
||||
pendingSend: pendingSend,
|
||||
);
|
||||
|
||||
/// 모양이 틀리면 null — `pendingSend` 만은 틀려도 없는 것으로 읽는다
|
||||
static MessageSummary? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? id = json['id'];
|
||||
final Object? threadId = json['threadId'];
|
||||
final Object? mailboxIds = json['mailboxIds'];
|
||||
final Object? fromJson = json['from'];
|
||||
final Object? subject = json['subject'];
|
||||
final Object? preview = json['preview'];
|
||||
final Object? receivedAt = json['receivedAt'];
|
||||
final Object? isRead = json['isRead'];
|
||||
final Object? isFlagged = json['isFlagged'];
|
||||
final Object? hasAttachment = json['hasAttachment'];
|
||||
final Object? size = json['size'];
|
||||
|
||||
final MailAddress? from = fromJson == null
|
||||
? null
|
||||
: MailAddress.tryParse(fromJson);
|
||||
final List<MailAddress>? to = MailAddress.tryParseList(json['to']);
|
||||
final DateTime? received = receivedAt is String
|
||||
? DateTime.tryParse(receivedAt)
|
||||
: null;
|
||||
if (id is! String ||
|
||||
id.isEmpty ||
|
||||
threadId is! String ||
|
||||
mailboxIds is! List<Object?> ||
|
||||
mailboxIds.any((Object? value) => value is! String) ||
|
||||
(fromJson != null && from == null) ||
|
||||
to == null ||
|
||||
subject is! String ||
|
||||
preview is! String ||
|
||||
received == null ||
|
||||
isRead is! bool ||
|
||||
isFlagged is! bool ||
|
||||
hasAttachment is! bool ||
|
||||
size is! num) {
|
||||
return null;
|
||||
}
|
||||
return MessageSummary(
|
||||
id: id,
|
||||
threadId: threadId,
|
||||
mailboxIds: List<String>.unmodifiable(mailboxIds.cast<String>()),
|
||||
from: from,
|
||||
to: to,
|
||||
subject: subject,
|
||||
preview: preview,
|
||||
receivedAt: received.toUtc(),
|
||||
isRead: isRead,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: hasAttachment,
|
||||
size: size.toInt(),
|
||||
pendingSend: PendingSend.tryParse(json['pendingSend']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MessageSummary &&
|
||||
other.id == id &&
|
||||
other.threadId == threadId &&
|
||||
listEquals(other.mailboxIds, mailboxIds) &&
|
||||
other.from == from &&
|
||||
listEquals(other.to, to) &&
|
||||
other.subject == subject &&
|
||||
other.preview == preview &&
|
||||
other.receivedAt == receivedAt &&
|
||||
other.isRead == isRead &&
|
||||
other.isFlagged == isFlagged &&
|
||||
other.hasAttachment == hasAttachment &&
|
||||
other.size == size &&
|
||||
other.pendingSend == pendingSend;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
id,
|
||||
threadId,
|
||||
Object.hashAll(mailboxIds),
|
||||
from,
|
||||
Object.hashAll(to),
|
||||
subject,
|
||||
preview,
|
||||
receivedAt,
|
||||
isRead,
|
||||
isFlagged,
|
||||
hasAttachment,
|
||||
size,
|
||||
pendingSend,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:arasystem_app/core/api/api_error_mapper.dart';
|
||||
import 'package:arasystem_app/core/api/request_flags.dart';
|
||||
import 'package:arasystem_app/core/api/server_time.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// 메일 API 경로 — baseUrl(`…/api`) 뒤에 붙는다(계약 2절). 경로는 대소문자를 가린다
|
||||
abstract final class MailApiPaths {
|
||||
static const String mailboxes = '/mailboxes';
|
||||
static const String messages = '/messages';
|
||||
|
||||
/// 메일 한 통 — id 는 경로 칸으로 인코딩한다
|
||||
static String message(String id) => '$messages/${Uri.encodeComponent(id)}';
|
||||
|
||||
/// 휴지통에서 영구 삭제 (여러 통)
|
||||
static const String messageDeletions = '/message-deletions';
|
||||
|
||||
/// 새 메일 확인 — 불투명한 상태 글
|
||||
static const String mailState = '/mail-state';
|
||||
}
|
||||
|
||||
/// 목록 한 쪽의 기본 수 — 계약 기본값(20)과 같다
|
||||
const int kMailPageSize = 20;
|
||||
|
||||
/// 여러 통 요청 하나의 id 한도 — 넘으면 이만큼씩 차례로 보낸다(계약 2절 "여러 통 한꺼번에")
|
||||
const int kMailBulkMaxIds = 100;
|
||||
|
||||
/// 메일 API — 계약 `docs/mail/api-contract.md` 2절. 웹과 같은 경로를 Bearer 로 부른다.
|
||||
///
|
||||
/// 응답 언래핑 · 에러 코드 · 알림은 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다.
|
||||
/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 `SYS_001`.
|
||||
class MailRepository {
|
||||
const MailRepository(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// 메일함 목록 — 알림은 띄우지 않는다(목록 · 서랍이 받지 못함을 직접 그린다)
|
||||
Future<List<Mailbox>> listMailboxes() async {
|
||||
final Response<Object?> response = await _dio.get<Object?>(
|
||||
MailApiPaths.mailboxes,
|
||||
options: ApiOptions.silent(),
|
||||
);
|
||||
final MailPage<Mailbox>? page = MailPage.tryParse<Mailbox>(
|
||||
response.data,
|
||||
Mailbox.tryParse,
|
||||
);
|
||||
if (page == null) throw _malformed(response);
|
||||
return page.items;
|
||||
}
|
||||
|
||||
/// 메일함 하나의 목록 — 받은 시각 내림차순. [cursor] 는 해석하지 않고 그대로 보낸다.
|
||||
/// [silent] 면 실패를 알리지 않는다(처음 여는 목록은 화면이 받지 못함을 직접 그린다 · 새 메일 맞추기)
|
||||
Future<MailPage<MessageSummary>> listMessages({
|
||||
required String mailboxId,
|
||||
String? cursor,
|
||||
int limit = kMailPageSize,
|
||||
bool silent = false,
|
||||
}) async {
|
||||
final Response<Object?> response = await _dio.get<Object?>(
|
||||
MailApiPaths.messages,
|
||||
queryParameters: <String, Object?>{
|
||||
'mailboxId': mailboxId,
|
||||
'limit': limit,
|
||||
'cursor': ?cursor,
|
||||
},
|
||||
options: silent ? ApiOptions.silent() : null,
|
||||
);
|
||||
final MailPage<MessageSummary>? page = MailPage.tryParse<MessageSummary>(
|
||||
response.data,
|
||||
MessageSummary.tryParse,
|
||||
);
|
||||
if (page == null) throw _malformed(response);
|
||||
return page.withServerTime(serverTimeOf(response));
|
||||
}
|
||||
|
||||
/// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다)
|
||||
Future<MessageSummary> updateMessage(
|
||||
String id,
|
||||
MessageChanges changes,
|
||||
) async {
|
||||
final Response<Object?> response = await _dio.patch<Object?>(
|
||||
MailApiPaths.message(id),
|
||||
data: changes.toJson(),
|
||||
);
|
||||
final MessageSummary? summary = MessageSummary.tryParse(response.data);
|
||||
if (summary == null) throw _malformed(response);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/// 여러 통 바꾸기 — [ids] 는 100개까지(나눠 보내는 일은 부르는 쪽이 한다)
|
||||
Future<MessagesPatched> updateMessages(
|
||||
List<String> ids,
|
||||
MessageChanges changes,
|
||||
) async {
|
||||
final Response<Object?> response = await _dio.patch<Object?>(
|
||||
MailApiPaths.messages,
|
||||
data: <String, Object?>{'ids': ids, ...changes.toJson()},
|
||||
);
|
||||
final MessagesPatched? result = MessagesPatched.tryParse(response.data);
|
||||
if (result == null) throw _malformed(response);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 휴지통에서 영구 삭제 — 확인을 받은 뒤에만 부른다. [ids] 는 100개까지
|
||||
Future<MessageDeletions> deleteMessages(List<String> ids) async {
|
||||
final Response<Object?> response = await _dio.post<Object?>(
|
||||
MailApiPaths.messageDeletions,
|
||||
data: <String, Object?>{'ids': ids},
|
||||
);
|
||||
final MessageDeletions? result = MessageDeletions.tryParse(response.data);
|
||||
if (result == null) throw _malformed(response);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 새 메일 확인 — 상태 글(해석하지 않고 지난 값과 같은지만 본다). 실패는 알리지 않는다(다음 차례에 다시 묻는다)
|
||||
Future<String> mailState() async {
|
||||
final Response<Object?> response = await _dio.get<Object?>(
|
||||
MailApiPaths.mailState,
|
||||
options: ApiOptions.silent(),
|
||||
);
|
||||
final Object? data = response.data;
|
||||
final Object? state = data is Map<String, Object?> ? data['state'] : null;
|
||||
if (state is! String) throw _malformed(response);
|
||||
return state;
|
||||
}
|
||||
|
||||
DioException _malformed(Response<Object?> response) =>
|
||||
apiFailure(response.requestOptions, kFallbackCode);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/// 여러 통 처리 보조 — 나눠 보내기 · 되돌리기 묶음 (웹 `shared/utils/mail-bulk.ts` 와 같은 규칙, 순수 함수 · 문구 없음)
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// [size] 개씩 나눈다 — 요청 하나의 ids 는 100개까지다
|
||||
List<List<T>> chunked<T>(List<T> items, int size) {
|
||||
final int step = math.max(1, size);
|
||||
return <List<T>>[
|
||||
for (int start = 0; start < items.length; start += step)
|
||||
items.sublist(start, math.min(start + step, items.length)),
|
||||
];
|
||||
}
|
||||
|
||||
/// 여러 통 처리의 결과
|
||||
@immutable
|
||||
class BulkResult {
|
||||
const BulkResult({
|
||||
this.doneIds = const <String>[],
|
||||
this.skippedIds = const <String>[],
|
||||
this.unsentIds = const <String>[],
|
||||
});
|
||||
|
||||
/// 처리한 메일
|
||||
final List<String> doneIds;
|
||||
|
||||
/// 그사이 옮겨졌거나 지워져 그대로 둔 메일 — 서버의 failedIds · skippedIds
|
||||
final List<String> skippedIds;
|
||||
|
||||
/// 요청이 실패해 보내지 못한 메일 — 알림은 인터셉터가 띄웠다
|
||||
final List<String> unsentIds;
|
||||
|
||||
/// 그대로 둔 메일 · 보내지 못한 메일이 있다 — 목록을 서버 기준으로 다시 맞춘다
|
||||
bool get hasLeftovers => skippedIds.isNotEmpty || unsentIds.isNotEmpty;
|
||||
}
|
||||
|
||||
/// 한 묶음을 보낸 결과 — 처리한 메일 · 그대로 둔 메일
|
||||
typedef BulkChunkSender =
|
||||
Future<({List<String> done, List<String> skipped})> Function(
|
||||
List<String> chunkIds,
|
||||
);
|
||||
|
||||
/// [ids] 를 [size] 통씩 차례로 보낸다 — 요청이 실패하면(알림은 인터셉터) 그 묶음과 남은 묶음은 보내지 않은 것으로 두고 멈춘다
|
||||
/// (같은 오류가 이어질 것이고, 503 은 일부만 바뀌었을 수 있어 부르는 쪽이 목록을 다시 맞춘다)
|
||||
Future<BulkResult> sendInChunks(
|
||||
List<String> ids,
|
||||
int size,
|
||||
BulkChunkSender send,
|
||||
) async {
|
||||
final List<String> done = <String>[];
|
||||
final List<String> skipped = <String>[];
|
||||
final List<String> unsent = <String>[];
|
||||
final List<List<String>> chunks = chunked<String>(ids, size);
|
||||
for (int index = 0; index < chunks.length; index++) {
|
||||
try {
|
||||
final ({List<String> done, List<String> skipped}) result = await send(
|
||||
chunks[index],
|
||||
);
|
||||
done.addAll(result.done);
|
||||
skipped.addAll(result.skipped);
|
||||
} on DioException {
|
||||
unsent.addAll(chunks.sublist(index).expand((List<String> c) => c));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return BulkResult(doneIds: done, skippedIds: skipped, unsentIds: unsent);
|
||||
}
|
||||
|
||||
/// 되돌리기 한 묶음 — 같은 메일함으로 돌아갈 메일
|
||||
@immutable
|
||||
class BulkOrigin {
|
||||
const BulkOrigin(this.mailboxId, this.ids);
|
||||
|
||||
final String mailboxId;
|
||||
final List<String> ids;
|
||||
}
|
||||
|
||||
/// 옮긴 메일을 원래 메일함별로 묶는다(옮긴 차례대로). 원래 메일함을 모르는 메일은 뺀다
|
||||
List<BulkOrigin> groupByOrigin(List<String> ids, Map<String, String> originOf) {
|
||||
final Map<String, List<String>> groups = <String, List<String>>{};
|
||||
for (final String id in ids) {
|
||||
final String? origin = originOf[id];
|
||||
if (origin == null) continue;
|
||||
(groups[origin] ??= <String>[]).add(id);
|
||||
}
|
||||
return <BulkOrigin>[
|
||||
for (final MapEntry<String, List<String>> entry in groups.entries)
|
||||
BulkOrigin(entry.key, List<String>.unmodifiable(entry.value)),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/// 새 메일 확인으로 보이는 목록을 다시 받아 맞출 때의 병합 — 순수 함수 (웹 `shared/utils/mail-list-merge.ts` 와 같다).
|
||||
///
|
||||
/// - 지금까지 불러온 만큼(최대 100통)을 다시 받아 그 범위는 서버 기준으로 바꾼다(새 메일 · 다른 창의 읽음 · 이동 · 삭제).
|
||||
/// - 다시 받은 범위 밖의 옛 메일은 그대로 둔다 — 100통보다 많이 불러왔거나, 새 메일이 와서 범위 밖으로 밀려난 메일
|
||||
/// (밀려난 메일을 버리면 보던 줄이 사라지고 스크롤이 튄다). 겹치는 메일이 없으면(새 메일이 범위보다 많이 옴) 다시 받은 것만 쓴다.
|
||||
/// - 다시 받은 쪽이 메일함의 끝까지였으면(`hasNext` 거짓) 범위 밖이 없다 — 받지 못한 옛 메일은 다른 곳에서 지워진 것이라 남기지 않는다
|
||||
/// (웹은 이때도 남긴다 — 앱에서 정함 2026-11-06).
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
|
||||
/// 병합 결과
|
||||
typedef MailListMerge = ({List<MessageSummary> items, MailPageInfo pageInfo});
|
||||
|
||||
/// 지금 목록([current] · [currentPageInfo])과 다시 받은 첫 범위([fetched] · [fetchedPageInfo])를 합친다
|
||||
MailListMerge mergeRefreshedList({
|
||||
required List<MessageSummary> current,
|
||||
required MailPageInfo? currentPageInfo,
|
||||
required List<MessageSummary> fetched,
|
||||
required MailPageInfo fetchedPageInfo,
|
||||
}) {
|
||||
final Set<String> fetchedIds = <String>{
|
||||
for (final MessageSummary message in fetched) message.id,
|
||||
};
|
||||
|
||||
// 다시 받은 범위와 겹치는 마지막 옛 메일 — 그 뒤의 옛 메일은 다시 받지 않았다
|
||||
int lastOverlap = -1;
|
||||
for (int index = 0; index < current.length; index++) {
|
||||
if (fetchedIds.contains(current[index].id)) lastOverlap = index;
|
||||
}
|
||||
final List<MessageSummary> tail =
|
||||
lastOverlap == -1 || !fetchedPageInfo.hasNext
|
||||
? <MessageSummary>[]
|
||||
: current
|
||||
.sublist(lastOverlap + 1)
|
||||
.where((MessageSummary message) => !fetchedIds.contains(message.id))
|
||||
.toList();
|
||||
|
||||
// 남겨 둔 옛 메일이 있으면 다음 쪽은 그 뒤부터 이어야 한다 — 옛 커서를 쓰고 전체 수는 새 값
|
||||
final MailPageInfo pageInfo = tail.isNotEmpty && currentPageInfo != null
|
||||
? MailPageInfo(
|
||||
hasNext: currentPageInfo.hasNext,
|
||||
nextCursor: currentPageInfo.nextCursor,
|
||||
total: fetchedPageInfo.total,
|
||||
)
|
||||
: fetchedPageInfo;
|
||||
|
||||
return (
|
||||
items: List<MessageSummary>.unmodifiable(<MessageSummary>[
|
||||
...fetched,
|
||||
...tail,
|
||||
]),
|
||||
pageInfo: pageInfo,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/// 메일함 차례 · 묶음 · 옮길 곳 — 순수 함수 (웹 `shared/utils/mailbox.ts` 를 옮겼다).
|
||||
///
|
||||
/// 이름 차례만은 웹과 어긋날 수 있다 — 웹은 `localeCompare(…, 'ko')`(ICU 한국어 정렬)이고, 앱은 대소문자를 가리지 않고
|
||||
/// 글자 값으로 견준다. 영문 · 한글이 섞인 이름("ARA 프로젝트" · "가나다" · "erp")에서 차례가 다를 수 있다(2026-11-06 리뷰에서 정함).
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 메일함 정렬 차례 — 계약 2절 "메일함 정렬(확정 디자인 기준)". 역할 없는 메일함은 이 뒤에 sortOrder · 이름 순
|
||||
const List<MailboxRole> kMailboxRoleOrder = <MailboxRole>[
|
||||
MailboxRole.inbox,
|
||||
MailboxRole.sent,
|
||||
MailboxRole.drafts,
|
||||
MailboxRole.junk,
|
||||
MailboxRole.trash,
|
||||
MailboxRole.archive,
|
||||
];
|
||||
|
||||
/// 서랍에 그릴 한 줄 — [depth] 는 하위 메일함 들여쓰기 단계
|
||||
@immutable
|
||||
class MailboxNode {
|
||||
const MailboxNode(this.mailbox, this.depth);
|
||||
|
||||
final Mailbox mailbox;
|
||||
final int depth;
|
||||
}
|
||||
|
||||
int _roleRank(MailboxRole? role) {
|
||||
if (role == null) return kMailboxRoleOrder.length;
|
||||
final int index = kMailboxRoleOrder.indexOf(role);
|
||||
return index == -1 ? kMailboxRoleOrder.length : index;
|
||||
}
|
||||
|
||||
/// 역할 차례 → sortOrder → 이름(대소문자를 가리지 않는다 — 같으면 원래 글자로 한 번 더 견줘 차례를 늘 같게)
|
||||
int compareMailboxes(Mailbox a, Mailbox b) {
|
||||
final int byRole = _roleRank(a.role) - _roleRank(b.role);
|
||||
if (byRole != 0) return byRole;
|
||||
if (a.sortOrder != b.sortOrder) return a.sortOrder - b.sortOrder;
|
||||
final int byName = a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
return byName != 0 ? byName : a.name.compareTo(b.name);
|
||||
}
|
||||
|
||||
/// 부모 · 자식 관계를 지키며 정렬해 한 줄로 편다.
|
||||
/// 부모를 찾을 수 없거나 서로를 가리키는(순환) 메일함은 맨 위로 올려 빠뜨리지 않는다
|
||||
List<MailboxNode> orderMailboxes(List<Mailbox> mailboxes) {
|
||||
final Set<String> ids = <String>{
|
||||
for (final Mailbox mailbox in mailboxes) mailbox.id,
|
||||
};
|
||||
final Map<String?, List<Mailbox>> childrenOf = <String?, List<Mailbox>>{};
|
||||
for (final Mailbox mailbox in mailboxes) {
|
||||
final String? parentId = mailbox.parentId;
|
||||
final String? key = parentId != null && ids.contains(parentId)
|
||||
? parentId
|
||||
: null;
|
||||
(childrenOf[key] ??= <Mailbox>[]).add(mailbox);
|
||||
}
|
||||
|
||||
final List<MailboxNode> result = <MailboxNode>[];
|
||||
final Set<String> visited = <String>{};
|
||||
void visit(String? parentId, int depth) {
|
||||
final List<Mailbox> children = <Mailbox>[...?childrenOf[parentId]]
|
||||
..sort(compareMailboxes);
|
||||
for (final Mailbox mailbox in children) {
|
||||
if (!visited.add(mailbox.id)) continue;
|
||||
result.add(MailboxNode(mailbox, depth));
|
||||
visit(mailbox.id, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
visit(null, 0);
|
||||
// 순환(a→b→a)으로 어느 뿌리에도 닿지 않은 메일함
|
||||
final List<Mailbox> orphans =
|
||||
mailboxes
|
||||
.where((Mailbox mailbox) => !visited.contains(mailbox.id))
|
||||
.toList()
|
||||
..sort(compareMailboxes);
|
||||
for (final Mailbox mailbox in orphans) {
|
||||
if (!visited.add(mailbox.id)) continue;
|
||||
result.add(MailboxNode(mailbox, 0));
|
||||
visit(mailbox.id, 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 서랍의 두 묶음 — 기본 메일함(역할이 있는 뿌리와 그 아래)과 "내 메일함"(역할 없는 뿌리와 그 아래)
|
||||
({List<MailboxNode> system, List<MailboxNode> mine}) splitMailboxGroups(
|
||||
List<MailboxNode> nodes,
|
||||
) {
|
||||
final List<MailboxNode> system = <MailboxNode>[];
|
||||
final List<MailboxNode> mine = <MailboxNode>[];
|
||||
bool rootHasRole = false;
|
||||
for (final MailboxNode node in nodes) {
|
||||
if (node.depth == 0) rootHasRole = node.mailbox.role != null;
|
||||
(rootHasRole ? system : mine).add(node);
|
||||
}
|
||||
return (system: system, mine: mine);
|
||||
}
|
||||
|
||||
/// 역할의 메일함 — 없으면 null
|
||||
Mailbox? findMailboxByRole(List<Mailbox> mailboxes, MailboxRole role) {
|
||||
for (final Mailbox mailbox in mailboxes) {
|
||||
if (mailbox.role == role) return mailbox;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 주소 `/mail` 이 여는 메일함 — 받은편지함, 없으면 차례의 첫 메일함
|
||||
Mailbox? defaultMailboxOf(List<Mailbox> mailboxes) {
|
||||
final Mailbox? inbox = findMailboxByRole(mailboxes, MailboxRole.inbox);
|
||||
if (inbox != null) return inbox;
|
||||
final List<MailboxNode> ordered = orderMailboxes(mailboxes);
|
||||
return ordered.isEmpty ? null : ordered.first.mailbox;
|
||||
}
|
||||
|
||||
/// 옮길 수 있는 메일함 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함(서랍 차례). 휴지통으로는 [삭제]로 옮긴다
|
||||
/// (웹 `useBulkMailActions` 의 `moveTargets` 와 같다)
|
||||
List<MailboxNode> moveTargetsOf(
|
||||
List<Mailbox> mailboxes,
|
||||
String? currentMailboxId,
|
||||
) {
|
||||
return orderMailboxes(mailboxes)
|
||||
.where(
|
||||
(MailboxNode node) =>
|
||||
node.mailbox.id != currentMailboxId &&
|
||||
node.mailbox.role != MailboxRole.drafts &&
|
||||
node.mailbox.role != MailboxRole.trash,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 위 막대 아래에 "안 읽은 메일 N통"을 보이는 메일함 — 보낸편지함 · 임시보관함 · 휴지통은 안 읽음이 뜻이 없어 뺀다
|
||||
bool showsUnreadCount(MailboxRole? role) =>
|
||||
role != MailboxRole.sent &&
|
||||
role != MailboxRole.drafts &&
|
||||
role != MailboxRole.trash;
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:arasystem_app/core/modules/module_routes.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart';
|
||||
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -10,7 +11,7 @@ abstract final class MailRoutes {
|
||||
/// 첫 화면 — 받은편지함 목록 (탭 안)
|
||||
static final String home = findAppModule(AppModuleId.mail).rootPath;
|
||||
|
||||
/// 다른 메일함의 목록 (탭 안 — 탭을 바꿔도 이 기록이 남는다)
|
||||
/// 다른 메일함의 목록 (탭 안 — 받은편지함 위에 쌓인다. 뒤로 가기는 받은편지함, 탭을 바꿔도 이 기록이 남는다)
|
||||
static String mailbox(String mailboxId) =>
|
||||
'$home/mailboxes/${Uri.encodeComponent(mailboxId)}';
|
||||
|
||||
@@ -20,11 +21,20 @@ abstract final class MailRoutes {
|
||||
|
||||
/// 메일 쓰기 (전체 화면 — 탭 막대 없이)
|
||||
static String get compose => '$home/compose';
|
||||
|
||||
/// 임시 저장본 이어 쓰기 — 임시보관함의 줄을 누르면(웹과 같다 — 주소 `?draft=<id>`)
|
||||
static String composeDraft(String draftId) => Uri(
|
||||
path: compose,
|
||||
queryParameters: <String, String>{draftQuery: draftId},
|
||||
).toString();
|
||||
|
||||
/// 이어 쓸 임시 저장본 id 의 쿼리 이름 (웹 `/mail/compose?draft=` 와 같다)
|
||||
static const String draftQuery = 'draft';
|
||||
}
|
||||
|
||||
/// 메일 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
|
||||
///
|
||||
/// 화면은 모두 자리 화면이다. 캔버스(2026-11-05 확정 — 목록 · 읽기 · 쓰기)대로 화면을 만들면
|
||||
/// 목록은 앱 보드 `App1MailList` 화면이다. 읽기 · 쓰기는 아직 자리 화면이다 — 보드(`App1Reader` · `App1Compose`)대로 만들면
|
||||
/// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 둔다).
|
||||
final ModuleRoutes mailModuleRoutes = ModuleRoutes(
|
||||
id: AppModuleId.mail,
|
||||
@@ -33,20 +43,12 @@ final ModuleRoutes mailModuleRoutes = ModuleRoutes(
|
||||
buildRoutes: (ModuleRouteContext context) => <RouteBase>[
|
||||
GoRoute(
|
||||
path: MailRoutes.home,
|
||||
builder: (BuildContext _, GoRouterState _) => PlaceholderPage(
|
||||
title: MailTexts.home,
|
||||
links: <PlaceholderLink>[
|
||||
PlaceholderLink(
|
||||
label: MailTexts.compose,
|
||||
location: MailRoutes.compose,
|
||||
),
|
||||
],
|
||||
),
|
||||
builder: (BuildContext _, GoRouterState _) => const MailListPage(),
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: 'mailboxes/:mailboxId',
|
||||
builder: (BuildContext _, GoRouterState _) =>
|
||||
const PlaceholderPage(title: MailTexts.mailbox),
|
||||
builder: (BuildContext _, GoRouterState state) =>
|
||||
MailListPage(mailboxId: state.pathParameters['mailboxId']),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'messages/:messageId',
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/// 목록 줄 하나의 표시 — 순수 함수 (웹 `shared/utils/mail-list-row.ts` · `format.ts` 의 분할 보기 규칙과 같다).
|
||||
///
|
||||
/// - 임시보관함: 보낸 사람 자리에 받는 사람(없으면 흐린 "(받는 사람 없음)"), 읽음 여부를 보이지 않는다(누르면 쓰기 화면에서 이어 쓴다).
|
||||
/// - 보낸편지함: "받는 사람: 박준호 외 2명".
|
||||
/// - 아직 보내지 않은 내 메일(9단계 `pendingSend`)은 시각 자리가 "보내는 중" · "예약 · 11월 7일 오전 9:00"(청록)이고 고를 수 없다.
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/utils/korean_format.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 목록 줄 하나에 보일 것
|
||||
@immutable
|
||||
class MailRowView {
|
||||
const MailRowView({
|
||||
required this.who,
|
||||
required this.whoPlaceholder,
|
||||
required this.initial,
|
||||
required this.subject,
|
||||
required this.subjectPlaceholder,
|
||||
required this.preview,
|
||||
required this.time,
|
||||
required this.timePending,
|
||||
required this.unread,
|
||||
required this.flagged,
|
||||
required this.hasAttachment,
|
||||
required this.held,
|
||||
});
|
||||
|
||||
/// 보낸 사람 자리 — 보낸편지함 · 임시보관함은 받는 사람
|
||||
final String who;
|
||||
|
||||
/// [who] 가 빈 값을 알리는 글이다 — 흐리게
|
||||
final bool whoPlaceholder;
|
||||
|
||||
/// 동그란 사진의 머리글자 — 사람이 없으면 null(사람 아이콘)
|
||||
final String? initial;
|
||||
final String subject;
|
||||
|
||||
/// 임시 저장본의 빈 제목 — 흐리게
|
||||
final bool subjectPlaceholder;
|
||||
final String preview;
|
||||
|
||||
/// 받은 시각 · 붙잡아 둔 발송의 표시
|
||||
final String time;
|
||||
|
||||
/// [time] 이 아직 보내지 않은 메일의 표시다 — 청록 글
|
||||
final bool timePending;
|
||||
final bool unread;
|
||||
final bool flagged;
|
||||
final bool hasAttachment;
|
||||
|
||||
/// 아직 보내지 않은 메일 — 고를 수 없다
|
||||
final bool held;
|
||||
|
||||
/// 동그란 사진(고르기 단추)의 화면 낭독기 이름 — "고르기: 제목"
|
||||
String get selectLabel => MailListTexts.selectOne(subject);
|
||||
|
||||
/// 줄의 화면 낭독기 글 — 안 읽음 · 보낸 사람 · 시각 · 제목 · 첨부 · 중요 · 미리 보기 차례
|
||||
String get semanticsLabel => <String>[
|
||||
if (unread) MailListTexts.unreadPrefix,
|
||||
who,
|
||||
time,
|
||||
subject,
|
||||
if (hasAttachment) MailListTexts.attachment,
|
||||
if (flagged) MailListTexts.flagged,
|
||||
if (preview.isNotEmpty) preview,
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
/// [message] 를 [role] 메일함의 줄로 보인다 — [now] 는 지금(시각 표기의 "오늘 · 어제"를 가른다)
|
||||
MailRowView mailRowViewOf(
|
||||
MessageSummary message, {
|
||||
required MailboxRole? role,
|
||||
required DateTime now,
|
||||
}) {
|
||||
final bool draft = role == MailboxRole.drafts;
|
||||
final ({String text, bool placeholder, String? initial}) who = _whoOf(
|
||||
message,
|
||||
role,
|
||||
);
|
||||
final PendingSend? pending = message.pendingSend;
|
||||
return MailRowView(
|
||||
who: who.text,
|
||||
whoPlaceholder: who.placeholder,
|
||||
initial: who.initial,
|
||||
subject: message.subject.isEmpty
|
||||
? MailListTexts.noSubject
|
||||
: message.subject,
|
||||
subjectPlaceholder: draft && message.subject.isEmpty,
|
||||
preview: message.preview,
|
||||
time: pending == null
|
||||
? formatMailListTime(message.receivedAt, now)
|
||||
: pendingSendLabel(pending),
|
||||
timePending: pending != null,
|
||||
unread: !draft && !message.isRead,
|
||||
flagged: message.isFlagged,
|
||||
hasAttachment: message.hasAttachment,
|
||||
held: message.isHeld,
|
||||
);
|
||||
}
|
||||
|
||||
({String text, bool placeholder, String? initial}) _whoOf(
|
||||
MessageSummary message,
|
||||
MailboxRole? role,
|
||||
) {
|
||||
final List<MailAddress> to = message.to;
|
||||
final MailAddress? firstRecipient = to.isEmpty ? null : to.first;
|
||||
switch (role) {
|
||||
case MailboxRole.drafts:
|
||||
final String names = to
|
||||
.map((MailAddress address) => address.displayName)
|
||||
.where((String name) => name.isNotEmpty)
|
||||
.join(', ');
|
||||
if (names.isEmpty || firstRecipient == null) {
|
||||
return (
|
||||
text: MailListTexts.draftNoRecipients,
|
||||
placeholder: true,
|
||||
initial: null,
|
||||
);
|
||||
}
|
||||
return (
|
||||
text: names,
|
||||
placeholder: false,
|
||||
initial: initialOf(firstRecipient.name, fallback: firstRecipient.email),
|
||||
);
|
||||
case MailboxRole.sent:
|
||||
if (firstRecipient == null) {
|
||||
return (
|
||||
text: MailListTexts.noRecipients,
|
||||
placeholder: false,
|
||||
initial: null,
|
||||
);
|
||||
}
|
||||
return (
|
||||
text:
|
||||
'${MailListTexts.recipientPrefix}${MailListTexts.recipientsSummary(firstRecipient.displayName, to.length - 1)}',
|
||||
placeholder: false,
|
||||
initial: initialOf(firstRecipient.name, fallback: firstRecipient.email),
|
||||
);
|
||||
case MailboxRole.inbox:
|
||||
case MailboxRole.junk:
|
||||
case MailboxRole.trash:
|
||||
case MailboxRole.archive:
|
||||
case null:
|
||||
final MailAddress? from = message.from;
|
||||
if (from == null) {
|
||||
return (
|
||||
text: MailListTexts.noSender,
|
||||
placeholder: false,
|
||||
initial: null,
|
||||
);
|
||||
}
|
||||
return (
|
||||
text: from.displayName,
|
||||
placeholder: false,
|
||||
initial: initialOf(from.name, fallback: from.email),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 이름 앞의 "(주)" · "[공지]" 같은 묶음 — 머리글자에서 뺀다
|
||||
final RegExp _leadingBracket = RegExp(r'^\s*[(\[][^)\]]*[)\]]\s*');
|
||||
|
||||
/// 동그란 사진의 머리글자 — "(주)한빛솔루션" → "한", 이름이 없으면 주소의 첫 글자(웹 `initialOf` 와 같다). 둘 다 비면 null
|
||||
String? initialOf(String? name, {String fallback = ''}) {
|
||||
final String cleaned = (name ?? '').replaceFirst(_leadingBracket, '').trim();
|
||||
final String source = cleaned.isNotEmpty ? cleaned : fallback.trim();
|
||||
if (source.isEmpty) return null;
|
||||
return String.fromCharCode(source.runes.first).toUpperCase();
|
||||
}
|
||||
|
||||
/// 목록의 받은 시각 — 오늘 "오전 10:42" · 어제 "어제" · 올해 "11월 3일" · 그 전 "2025. 10. 24." (웹 `formatMailListTime`).
|
||||
/// 기기 시간대로 본다
|
||||
String formatMailListTime(DateTime at, DateTime now) {
|
||||
final DateTime local = at.toLocal();
|
||||
final DateTime today = now.toLocal();
|
||||
if (isSameCalendarDay(local, today)) return koreanTimeOfDay(local);
|
||||
if (isSameCalendarDay(local, previousCalendarDay(today))) {
|
||||
return MailListTexts.yesterday;
|
||||
}
|
||||
if (local.year == today.year) return koreanMonthDay(local);
|
||||
return koreanNumericDate(local);
|
||||
}
|
||||
|
||||
/// 붙잡아 둔 발송의 표시 — 예약 "예약 · 11월 7일 오전 9:00"(요일 없이 — 7일 안이라 연도도 없다), 보내기 취소를 기다리는 동안 "보내는 중"
|
||||
String pendingSendLabel(PendingSend pending) {
|
||||
if (pending.kind == PendingSendKind.undo) return MailListTexts.sending;
|
||||
final DateTime local = pending.sendAt.toLocal();
|
||||
return MailListTexts.scheduled(
|
||||
'${koreanMonthDay(local)} ${koreanTimeOfDay(local)}',
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,184 @@
|
||||
/// 메일 모듈의 화면 이름 — 지금은 자리 화면이 쓴다.
|
||||
/// 메일 모듈의 화면 글 — 원본은 앱 보드 `App1MailList`(`docs/design/webmail/App1MailList.dc.html`)다.
|
||||
///
|
||||
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다(캔버스 확정본 기준).
|
||||
/// 보드에 없는 상태(여러 통 처리의 알림 · 영구 삭제 확인 · 받지 못함 · 붙잡힌 메일 등)의 글은 웹 상수
|
||||
/// (`frontend/src/shared/constants/mail.ts` · `mail-send-later.ts`)를 그대로 옮겼다 — 웹과 앱을 함께 쓰는 직원에게 같은 말.
|
||||
/// 서버가 거절한 까닭의 문구는 여기 두지 않는다 — 에러 코드 사전(`ErrorLexicon`)에서만 온다.
|
||||
/// 말투는 디자인 README 「화면 문구 — 사용자에게 말하듯」.
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/utils/korean_format.dart';
|
||||
import 'package:arasystem_app/core/utils/korean_text.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
|
||||
/// 메일 모듈의 화면 이름 — 아직 자리 화면인 읽기 · 쓰기가 쓴다
|
||||
abstract final class MailTexts {
|
||||
/// 메일 첫 화면 — 받은편지함 목록
|
||||
static const String home = '메일';
|
||||
|
||||
/// 다른 메일함의 목록 (탭 안)
|
||||
static const String mailbox = '메일함';
|
||||
|
||||
/// 메일 읽기 (전체 화면)
|
||||
static const String message = '메일 읽기';
|
||||
|
||||
/// 메일 쓰기 (전체 화면)
|
||||
static const String compose = '메일 쓰기';
|
||||
}
|
||||
|
||||
/// 목록 화면 (보드 `App1MailList` 「받은편지함」 · 「당겨서 새로고침」 · 「빈 메일함 (휴지통)」)
|
||||
abstract final class MailListTexts {
|
||||
// ── 위 막대 ──
|
||||
static const String openDrawer = '메일함 열기';
|
||||
static const String search = '메일 찾기';
|
||||
|
||||
/// [찾기]를 눌렀을 때 — 검색 화면은 다음 차례다(보드에 없다)
|
||||
static const String searchNotReady = '메일 찾기는 아직 준비 중입니다. 지금은 웹메일에서 찾아 주세요.';
|
||||
|
||||
/// 메일함 이름을 받기 전의 제목 — 받은편지함 주소(`/mail`)는 [inbox]
|
||||
static const String mailboxFallback = '메일함';
|
||||
|
||||
/// 받은편지함 — 메일함 목록을 받기 전에도 첫 화면 제목은 이것이다(서버의 표준 이름과 같다)
|
||||
static const String inbox = '받은편지함';
|
||||
|
||||
/// 제목 아래 — "안 읽은 메일 3통" · "안 읽은 메일이 없습니다"
|
||||
static String unread(int count) =>
|
||||
count > 0 ? '안 읽은 메일 ${formatCount(count)}통' : '안 읽은 메일이 없습니다';
|
||||
|
||||
/// 목록의 화면 낭독기 이름 — "받은편지함 메일"
|
||||
static String listLabel(String mailboxName) => '$mailboxName 메일';
|
||||
|
||||
// ── 줄 ──
|
||||
/// 안 읽은 줄의 앞 — 화면 낭독기만 읽는다(뱃지 없이 굵은 글자로만 가른다)
|
||||
static const String unreadPrefix = '안 읽음';
|
||||
static const String attachment = '첨부 있음';
|
||||
static const String flagged = '중요';
|
||||
|
||||
/// 동그란 사진(고르기) — "고르기: 제목"
|
||||
static String selectOne(String subject) => '고르기: $subject';
|
||||
static const String noSubject = '(제목 없음)';
|
||||
static const String noSender = '(보낸 사람 없음)';
|
||||
|
||||
/// 보낸편지함 — 받는 사람이 없을 때
|
||||
static const String noRecipients = '받는 사람 없음';
|
||||
|
||||
/// 임시보관함 — 받는 사람이 없을 때(흐린 글)
|
||||
static const String draftNoRecipients = '(받는 사람 없음)';
|
||||
|
||||
/// 보낸편지함 줄의 앞 — "받는 사람: 박준호 외 2명"
|
||||
static const String recipientPrefix = '받는 사람: ';
|
||||
static String recipientsSummary(String first, int others) =>
|
||||
others > 0 ? '$first 외 ${formatCount(others)}명' : first;
|
||||
static const String yesterday = '어제';
|
||||
|
||||
/// 아직 보내지 않은 내 메일 — 시각 자리
|
||||
static const String sending = '보내는 중';
|
||||
static String scheduled(String time) => '예약 · $time';
|
||||
|
||||
/// 아직 보내지 않은 메일을 고르려 할 때 (웹 `heldNotSelectable`)
|
||||
static const String heldNotSelectable =
|
||||
'보내기를 기다리는 메일은 고를 수 없습니다. 메일을 열어 취소하거나 삭제해 주세요.';
|
||||
|
||||
// ── 새로고침 · 불러오기 ──
|
||||
/// 당겨서 새로고침 — 화면 낭독기
|
||||
static const String refreshing = '새 메일을 확인하는 중';
|
||||
|
||||
/// 처음 불러오는 동안 — 화면 낭독기
|
||||
static const String loading = '메일 목록 불러오는 중';
|
||||
|
||||
/// 다음 쪽을 받는 동안 — 화면 낭독기
|
||||
static const String loadingMore = '메일을 더 불러오는 중';
|
||||
|
||||
// ── 받지 못함 ──
|
||||
static const String retry = '다시 시도';
|
||||
|
||||
/// 없는 메일함(주소에 남은 옛 id) — 다시 해도 같아 빠져나갈 단추를 준다
|
||||
static const String toInbox = '받은편지함으로';
|
||||
static String traceCode(String code) => '문의 코드 $code';
|
||||
|
||||
// ── 빈 메일함 ──
|
||||
/// "휴지통이 비어 있습니다"
|
||||
static String emptyTitle(String mailboxName) =>
|
||||
'$mailboxName${subjectParticle(mailboxName)} 비어 있습니다';
|
||||
|
||||
/// 메일함마다 빈 메일함 안내 — 키 null 은 사용자가 만든 메일함
|
||||
static String emptyHint(MailboxRole? role) => switch (role) {
|
||||
MailboxRole.inbox => '새 메일이 오면 여기에 표시됩니다.',
|
||||
MailboxRole.drafts => '임시 저장한 메일이 여기에 표시됩니다.',
|
||||
MailboxRole.sent => '보낸 메일이 여기에 표시됩니다.',
|
||||
MailboxRole.junk => '스팸으로 분류된 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.',
|
||||
MailboxRole.trash => '삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.',
|
||||
MailboxRole.archive => '보관한 메일이 여기에 표시됩니다.',
|
||||
null => '이 메일함으로 옮긴 메일이 여기에 표시됩니다.',
|
||||
};
|
||||
|
||||
/// 휴지통 · 스팸메일함 목록 위의 30일 줄 (웹 13단계 — 메일이 없으면 빈 안내에 붙인다)
|
||||
static String? retentionNotice(MailboxRole? role) => switch (role) {
|
||||
MailboxRole.trash => '휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다.',
|
||||
MailboxRole.junk => '스팸메일함에 들어간 메일은 30일 뒤 자동으로 지워집니다.',
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// ── [메일 쓰기] ──
|
||||
static const String compose = '메일 쓰기';
|
||||
}
|
||||
|
||||
/// 메일함 서랍 (보드 `App1MailList` 「메일함 서랍」)
|
||||
abstract final class MailDrawerTexts {
|
||||
static const String title = '메일함';
|
||||
static const String myMailboxes = '내 메일함';
|
||||
|
||||
/// 서랍 밖(가림막)을 눌러 닫을 때 — 화면 낭독기 (보드에 없다)
|
||||
static const String close = '메일함 닫기';
|
||||
|
||||
/// 안 읽은 수 — 화면 낭독기는 "안 읽음 3"으로 읽는다(웹과 같다)
|
||||
static String unreadCount(int count) => '안 읽음 ${formatCount(count)}';
|
||||
}
|
||||
|
||||
/// 여러 통 고르기 (보드 `App1MailList` 「길게 눌러 고르기」 · 웹 3단계 "여러 통 처리")
|
||||
abstract final class MailBulkTexts {
|
||||
/// 고르기 막대 — 화면 낭독기
|
||||
static const String toolbar = '고른 메일';
|
||||
static String selected(int count) => '${formatCount(count)}통 선택됨';
|
||||
static const String clear = '선택 해제';
|
||||
static const String markRead = '읽음 표시';
|
||||
|
||||
/// 고른 것이 모두 읽음이면 [markRead] 대신
|
||||
static const String markUnread = '안 읽음 표시';
|
||||
static const String flag = '중요 표시';
|
||||
|
||||
/// 고른 것이 모두 중요면 [flag] 대신
|
||||
static const String unflag = '중요 해제';
|
||||
static const String move = '이동';
|
||||
|
||||
/// 이동 시트의 제목
|
||||
static const String moveSheetTitle = '옮길 메일함';
|
||||
static const String delete = '삭제';
|
||||
|
||||
/// 휴지통에서는 [delete] 대신
|
||||
static const String purge = '영구 삭제';
|
||||
static const String undo = '되돌리기';
|
||||
|
||||
/// "메일 3통을 휴지통으로 옮겼습니다." — 메일함 이름의 받침에 맞춰 '으로/로'
|
||||
static String moved(
|
||||
int done,
|
||||
String mailboxName, {
|
||||
int skipped = 0,
|
||||
}) => _withSkipped(
|
||||
'메일 ${formatCount(done)}통을 $mailboxName${directionParticle(mailboxName)} 옮겼습니다.',
|
||||
done,
|
||||
skipped,
|
||||
);
|
||||
|
||||
static String purged(int done, {int skipped = 0}) =>
|
||||
_withSkipped('메일 ${formatCount(done)}통을 영구 삭제했습니다.', done, skipped);
|
||||
|
||||
static String restored(int done, {int skipped = 0}) =>
|
||||
_withSkipped('메일 ${formatCount(done)}통을 되돌렸습니다.', done, skipped);
|
||||
|
||||
/// 일부를 처리하지 못했으면 뒤에 덧붙인다 — 하나도 못 했으면 그 문장만
|
||||
static String _withSkipped(String text, int done, int skipped) {
|
||||
if (skipped <= 0) return text;
|
||||
final String suffix = '${formatCount(skipped)}통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.';
|
||||
return done > 0 ? '$text $suffix' : suffix;
|
||||
}
|
||||
|
||||
// ── 영구 삭제 확인 (휴지통에서만 — 처음 초점은 [취소]) ──
|
||||
static String purgeTitle(int count) => '메일 ${formatCount(count)}통을 영구 삭제할까요?';
|
||||
static const String purgeMessage = '휴지통에서 지운 메일은 되돌릴 수 없습니다.';
|
||||
static const String purgeConfirm = '영구 삭제';
|
||||
static const String purgeCancel = '취소';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/api/api_exception.dart';
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:arasystem_app/core/router/shell_bar.dart';
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart';
|
||||
import 'package:arasystem_app/features/mail/mail_routes.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_state.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_app_bar.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/mailbox_drawer.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart';
|
||||
import 'package:arasystem_app/shared/widgets/confirm_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// 메일 목록 — 앱 보드 `App1MailList` (앱 디자인 README 「메일 목록」).
|
||||
///
|
||||
/// - 위 막대: 서랍 열기 · 메일함 이름(아래에 안 읽은 수) · 찾기(아직 준비 중 — 알림만). 서랍은 탭 막대 위까지 덮는다.
|
||||
/// - 줄을 누르면 읽기 화면(전체 화면 — 임시보관함은 쓰기 화면에서 이어 쓴다). 읽음 처리는 읽기 화면이 한다.
|
||||
/// - 사진을 누르거나 줄을 길게 누르면 고르기 — 위 막대가 고르기 막대가 되고 탭 막대 · [메일 쓰기]를 숨긴다. ✕ · 뒤로 가기로 나온다.
|
||||
/// - 당겨서 새로고침, 끝 근처에서 다음 쪽. 화면이 보이는 동안 1분마다 새 메일을 확인한다(목록 컨트롤러 · 새 메일 확인).
|
||||
/// - 화면이 보이는지(다른 탭 · 전체 화면 · 위에 쌓인 메일함에 가렸는지)는 TickerMode 로 안다.
|
||||
class MailListPage extends ConsumerStatefulWidget {
|
||||
const MailListPage({this.mailboxId, super.key});
|
||||
|
||||
/// 주소의 메일함 id — null 이면 받은편지함(주소 `/mail`)
|
||||
final String? mailboxId;
|
||||
|
||||
@override
|
||||
ConsumerState<MailListPage> createState() => _MailListPageState();
|
||||
}
|
||||
|
||||
class _MailListPageState extends ConsumerState<MailListPage> {
|
||||
/// 컨트롤러에 마지막으로 알린 보임 — 그린 뒤에 알린다(그리는 동안에는 프로바이더를 바꿀 수 없다)
|
||||
bool? _reportedVisible;
|
||||
bool _wantVisible = false;
|
||||
|
||||
MailListController get _controller =>
|
||||
ref.read(mailListProvider(widget.mailboxId).notifier);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MailListPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.mailboxId != widget.mailboxId) _reportedVisible = null;
|
||||
}
|
||||
|
||||
void _scheduleVisibilityReport() {
|
||||
if (_reportedVisible == _wantVisible) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((Duration _) {
|
||||
if (!mounted || _reportedVisible == _wantVisible) return;
|
||||
_reportedVisible = _wantVisible;
|
||||
_controller.setVisible(_wantVisible);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 하는 일 ──
|
||||
|
||||
Future<void> _openDrawer(String? currentMailboxId) async {
|
||||
final Mailbox? chosen = await showMailboxDrawer(
|
||||
context,
|
||||
currentMailboxId: currentMailboxId,
|
||||
);
|
||||
if (chosen == null || !mounted || chosen.id == currentMailboxId) return;
|
||||
context.go(
|
||||
chosen.role == MailboxRole.inbox
|
||||
? MailRoutes.home
|
||||
: MailRoutes.mailbox(chosen.id),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSearchNotice() {
|
||||
ref.read(mailToastProvider.notifier).notify(MailListTexts.searchNotReady);
|
||||
}
|
||||
|
||||
void _toggle(MessageSummary message) {
|
||||
if (_controller.toggleSelection(message.id)) return;
|
||||
if (message.isHeld) {
|
||||
ref
|
||||
.read(mailToastProvider.notifier)
|
||||
.notify(MailListTexts.heldNotSelectable);
|
||||
}
|
||||
}
|
||||
|
||||
void _open(MessageSummary message, MailboxRole? role) {
|
||||
unawaited(
|
||||
context.push<void>(
|
||||
role == MailboxRole.drafts
|
||||
? MailRoutes.composeDraft(message.id)
|
||||
: MailRoutes.message(message.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _move(List<Mailbox> mailboxes, String? currentMailboxId) async {
|
||||
final List<MailboxNode> targets = moveTargetsOf(
|
||||
mailboxes,
|
||||
currentMailboxId,
|
||||
);
|
||||
if (targets.isEmpty) return;
|
||||
final Mailbox? chosen = await showMailboxPickerSheet(
|
||||
context,
|
||||
targets: targets,
|
||||
);
|
||||
if (chosen == null || !mounted) return;
|
||||
await _controller.moveSelected(chosen);
|
||||
}
|
||||
|
||||
Future<void> _delete({required bool inTrash, required int count}) async {
|
||||
if (!inTrash) {
|
||||
await _controller.moveSelectedToTrash();
|
||||
return;
|
||||
}
|
||||
final bool confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: MailBulkTexts.purgeTitle(count),
|
||||
message: MailBulkTexts.purgeMessage,
|
||||
confirmLabel: MailBulkTexts.purgeConfirm,
|
||||
cancelLabel: MailBulkTexts.purgeCancel,
|
||||
danger: true,
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
await _controller.purgeSelected();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final MailListState list = ref.watch(mailListProvider(widget.mailboxId));
|
||||
final MailboxesState mailboxes = ref.watch(mailboxesProvider);
|
||||
final Mailbox? mailbox = mailboxes.byId(list.mailboxId);
|
||||
final bool inboxRoute = widget.mailboxId == null;
|
||||
final MailboxRole? role =
|
||||
mailbox?.role ?? (inboxRoute ? MailboxRole.inbox : null);
|
||||
final String title =
|
||||
mailbox?.name ??
|
||||
(inboxRoute ? MailListTexts.inbox : MailListTexts.mailboxFallback);
|
||||
final bool selecting = list.selecting;
|
||||
final bool inTrash = role == MailboxRole.trash;
|
||||
// 주소의 메일함을 메일함 목록과 맞춘 뒤 그린다 — 역할을 알기 전에는 역할에 기대는 것
|
||||
// (고르기 막대의 삭제 · 임시보관함 줄 열기 · 보낸편지함 줄 모양 · 30일 줄)을 그리지 않는다
|
||||
final bool mailboxesReady = mailboxes.status == MailboxesStatus.ready;
|
||||
final bool unknownMailbox =
|
||||
mailboxesReady && !inboxRoute && mailbox == null;
|
||||
|
||||
_wantVisible = TickerMode.valuesOf(context).enabled;
|
||||
_scheduleVisibilityReport();
|
||||
|
||||
final PreferredSizeWidget appBar = selecting
|
||||
? MailSelectionBar(
|
||||
count: list.selectedCount,
|
||||
allRead: list.allSelectedRead,
|
||||
allFlagged: list.allSelectedFlagged,
|
||||
inTrash: inTrash,
|
||||
busy: list.busy,
|
||||
onClear: _controller.clearSelection,
|
||||
onToggleRead: () => unawaited(_controller.toggleSelectedRead()),
|
||||
onToggleFlag: () => unawaited(_controller.toggleSelectedFlag()),
|
||||
onMove: () => unawaited(_move(mailboxes.items, list.mailboxId)),
|
||||
onDelete: () =>
|
||||
unawaited(_delete(inTrash: inTrash, count: list.selectedCount)),
|
||||
)
|
||||
: MailListAppBar(
|
||||
title: title,
|
||||
subtitle: mailbox != null && showsUnreadCount(role)
|
||||
? MailListTexts.unread(mailbox.unreadCount)
|
||||
: null,
|
||||
onOpenDrawer: () => unawaited(_openDrawer(list.mailboxId)),
|
||||
onSearch: _showSearchNotice,
|
||||
);
|
||||
|
||||
return PopScope(
|
||||
canPop: !selecting,
|
||||
onPopInvokedWithResult: (bool didPop, Object? result) {
|
||||
if (!didPop) _controller.clearSelection();
|
||||
},
|
||||
child: ShellBarHidden(
|
||||
module: AppModuleId.mail,
|
||||
hidden: selecting,
|
||||
child: Scaffold(
|
||||
appBar: appBar,
|
||||
body: _MailListBody(
|
||||
list: list,
|
||||
role: role,
|
||||
title: title,
|
||||
mailboxesReady: mailboxesReady,
|
||||
mailboxesFailure: mailboxes.failure,
|
||||
unknownMailbox: unknownMailbox,
|
||||
onRefresh: _controller.refresh,
|
||||
onRetry: () => unawaited(_controller.retry()),
|
||||
onRetryMailboxes: () =>
|
||||
unawaited(ref.read(mailboxesProvider.notifier).reload()),
|
||||
onLeave: inboxRoute ? null : () => context.go(MailRoutes.home),
|
||||
onLoadMore: () => unawaited(_controller.loadMore()),
|
||||
onRowTap: (MessageSummary message) =>
|
||||
selecting ? _toggle(message) : _open(message, role),
|
||||
onRowSelect: _toggle,
|
||||
),
|
||||
floatingActionButton: MailComposeArea(
|
||||
key: const ValueKey<String>('mail-compose-area'),
|
||||
showCompose: !selecting,
|
||||
safeInsets: MediaQuery.paddingOf(context),
|
||||
onCompose: () => unawaited(context.push<void>(MailRoutes.compose)),
|
||||
),
|
||||
floatingActionButtonLocation: const MailComposeAreaLocation(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 목록 자리 — 불러오는 중 · 받지 못함 · 빈 메일함 · 목록. 어느 상태에서도 당겨서 새로고침이 된다.
|
||||
/// 메일함 목록(역할)을 받기 전에는 목록을 그리지 않고 뼈대를, 받지 못했으면 그 실패를, 주소의 메일함이 없으면 `RES_001` 을 보인다
|
||||
class _MailListBody extends StatelessWidget {
|
||||
const _MailListBody({
|
||||
required this.list,
|
||||
required this.role,
|
||||
required this.title,
|
||||
required this.mailboxesReady,
|
||||
required this.mailboxesFailure,
|
||||
required this.unknownMailbox,
|
||||
required this.onRefresh,
|
||||
required this.onRetry,
|
||||
required this.onRetryMailboxes,
|
||||
required this.onLeave,
|
||||
required this.onLoadMore,
|
||||
required this.onRowTap,
|
||||
required this.onRowSelect,
|
||||
});
|
||||
|
||||
final MailListState list;
|
||||
final MailboxRole? role;
|
||||
final String title;
|
||||
final bool mailboxesReady;
|
||||
final ApiException? mailboxesFailure;
|
||||
final bool unknownMailbox;
|
||||
final Future<void> Function() onRefresh;
|
||||
final VoidCallback onRetry;
|
||||
final VoidCallback onRetryMailboxes;
|
||||
final VoidCallback? onLeave;
|
||||
final VoidCallback onLoadMore;
|
||||
final ValueChanged<MessageSummary> onRowTap;
|
||||
final ValueChanged<MessageSummary> onRowSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ColorScheme scheme = Theme.of(context).colorScheme;
|
||||
final ApiException? boxesFailure = mailboxesFailure;
|
||||
const Widget skeleton = _SkeletonList();
|
||||
final Widget content = switch (list.status) {
|
||||
_ when boxesFailure != null => _FillViewport(
|
||||
child: MailListError(error: boxesFailure, onRetry: onRetryMailboxes),
|
||||
),
|
||||
_ when unknownMailbox => _FillViewport(
|
||||
child: MailListError(
|
||||
error: ApiException.fromCode('RES_001'),
|
||||
onRetry: onRetry,
|
||||
onLeave: onLeave,
|
||||
),
|
||||
),
|
||||
_ when !mailboxesReady => skeleton,
|
||||
MailListStatus.loading => skeleton,
|
||||
MailListStatus.failed => _FillViewport(
|
||||
child: MailListError(
|
||||
error: list.error!,
|
||||
onRetry: onRetry,
|
||||
onLeave: onLeave,
|
||||
),
|
||||
),
|
||||
MailListStatus.ready when list.items.isEmpty => _FillViewport(
|
||||
child: MailListEmpty(mailboxName: title, role: role),
|
||||
),
|
||||
MailListStatus.ready => _MessageList(
|
||||
list: list,
|
||||
role: role,
|
||||
title: title,
|
||||
onLoadMore: onLoadMore,
|
||||
onRowTap: onRowTap,
|
||||
onRowSelect: onRowSelect,
|
||||
),
|
||||
};
|
||||
return RefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
color: scheme.primary,
|
||||
backgroundColor: scheme.surface,
|
||||
semanticsLabel: MailListTexts.refreshing,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 처음 불러오는 동안 — 뼈대 줄(당겨서 새로고침이 되게 스크롤 안에)
|
||||
class _SkeletonList extends StatelessWidget {
|
||||
const _SkeletonList();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const <Widget>[MailListSkeleton()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 빈 메일함 · 받지 못함 — 화면 높이를 채우고도 당겨서 새로고침이 되게 스크롤 안에 둔다
|
||||
class _FillViewport extends StatelessWidget {
|
||||
const _FillViewport({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: <Widget>[
|
||||
SliverFillRemaining(hasScrollBody: false, child: Center(child: child)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageList extends ConsumerWidget {
|
||||
const _MessageList({
|
||||
required this.list,
|
||||
required this.role,
|
||||
required this.title,
|
||||
required this.onLoadMore,
|
||||
required this.onRowTap,
|
||||
required this.onRowSelect,
|
||||
});
|
||||
|
||||
final MailListState list;
|
||||
final MailboxRole? role;
|
||||
final String title;
|
||||
final VoidCallback onLoadMore;
|
||||
final ValueChanged<MessageSummary> onRowTap;
|
||||
final ValueChanged<MessageSummary> onRowSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// 시각 표기의 "오늘 · 어제"를 가르는 지금 — 시험은 손 시계로 덮어쓴다
|
||||
final DateTime now = ref.read(clockProvider)();
|
||||
final String? notice = MailListTexts.retentionNotice(role);
|
||||
final int noticeCount = notice == null ? 0 : 1;
|
||||
final bool footer = list.hasMore || list.loadingMore || list.loadMoreFailed;
|
||||
final List<MessageSummary> items = list.items;
|
||||
return Semantics(
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
label: MailListTexts.listLabel(title),
|
||||
child: ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
// 마지막 줄이 [메일 쓰기]에 가리지 않게 (보드의 목록 아래 여백 96)
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: AppSize.fabHeight + AppSpacing.s40,
|
||||
),
|
||||
itemCount: noticeCount + items.length + (footer ? 1 : 0),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
if (notice != null && index == 0) return MailRetentionNotice(notice);
|
||||
final int row = index - noticeCount;
|
||||
if (row >= items.length) {
|
||||
return MailListFooter(
|
||||
loading: list.loadingMore,
|
||||
failed: list.loadMoreFailed,
|
||||
onReached: onLoadMore,
|
||||
onRetry: onLoadMore,
|
||||
);
|
||||
}
|
||||
final MessageSummary message = items[row];
|
||||
return MessageRow(
|
||||
key: ValueKey<String>(message.id),
|
||||
view: mailRowViewOf(message, role: role, now: now),
|
||||
selected: list.selectedIds.contains(message.id),
|
||||
onTap: () => onRowTap(message),
|
||||
onLongPress: () => onRowSelect(message),
|
||||
onAvatarTap: () => onRowSelect(message),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
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/core/storage/token_store.dart';
|
||||
import 'package:arasystem_app/core/storage/token_store_provider.dart';
|
||||
import 'package:arasystem_app/core/utils/clock.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_bulk.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_list_merge.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_state.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
|
||||
/// 새 메일 확인으로 맞출 때 다시 받는 최대 수 — 불러온 만큼, 그보다 많이 불러왔으면 이만큼만(웹 `MAIL_LIST_SYNC_MAX`)
|
||||
const int kMailListSyncMax = 100;
|
||||
|
||||
/// 보낼 시각 뒤 이만큼 기다렸다 목록을 다시 본다 — 엔진이 발송을 풀어도 새 메일 확인의 상태는 바뀌지 않는다
|
||||
/// (계약 2절 "보내기 취소 · 예약 발송" — 웹 `PENDING_RELEASE_GRACE_MS`)
|
||||
const Duration kHeldReleaseGrace = Duration(seconds: 2);
|
||||
|
||||
/// 다시 본 뒤에도 시각이 지난 채 붙잡힌 것으로 보이면 이 간격으로 다시 본다(웹 `PENDING_RELEASE_RECHECK_MS`)
|
||||
const Duration kHeldReleaseRecheck = Duration(seconds: 5);
|
||||
|
||||
/// 그렇게 더 보는 횟수 — 넘으면 그 발송은 그만 본다(다음 새 메일 확인 · 새로고침이 맞춘다)
|
||||
const int kHeldReleaseMaxRechecks = 3;
|
||||
|
||||
/// 메일함 하나의 목록 — 주소의 메일함 id 마다 하나(null 은 받은편지함 — 주소 `/mail`).
|
||||
/// 목록 화면이 있는 동안만 둔다(autoDispose) — 메일함을 바꾸면 앞 목록은 사라지고 그 응답은 버린다.
|
||||
final NotifierProviderFamily<MailListController, MailListState, String?>
|
||||
mailListProvider = NotifierProvider.autoDispose
|
||||
.family<MailListController, MailListState, String?>(MailListController.new);
|
||||
|
||||
/// 읽음 · 중요 표시의 바꿀 값
|
||||
typedef _FlagChange = ({bool? isRead, bool? isFlagged});
|
||||
|
||||
/// 보내는 중인 표시 하나 — 값과 그것을 보낸 요청(같은 칸을 잇달아 바꾸면 마지막 요청이 주인이다)
|
||||
typedef _PendingFlag = ({bool value, int owner});
|
||||
|
||||
/// 메일함 하나의 목록 — 계약 2절 `GET /api/messages` · "여러 통 한꺼번에" · "새 메일 확인" (웹 `stores/mail/list.ts` · `selection.ts` · `bulk.ts` 와 같은 규칙)
|
||||
///
|
||||
/// 늦게 온 응답 막기:
|
||||
/// - 세대([_listSeq]): 처음부터 다시 받을 때마다(다시 시도 · 당겨서 새로고침 · 세션이 바뀜) 오른다 — 그 전에 보낸 목록 요청의 응답은 버린다.
|
||||
/// - 이 목록이 바꾼 횟수([_localGeneration]): 읽음 · 중요 표시 · 빼기 · 이어 받기마다 — 그 전에 떠난 맞추기의 응답은 옛 상태라 버리고 한 번 더 맞춘다.
|
||||
/// - 맞추기는 한 번에 하나다([_syncRun]) — 도는 동안 또 부르면 끝난 뒤 한 번 더 맞춘다(늦게 온 옛 응답이 새 응답을 덮지 않게).
|
||||
/// - 보내는 중인 읽음 · 중요 표시([_pendingRead] · [_pendingFlagged])는 그사이 받은 목록 위에 덮어쓴다(옛 값으로 되돌아 보이지 않게).
|
||||
/// 칸마다 마지막으로 보낸 요청이 주인이다 — 앞 요청이 끝나도 뒤 요청의 표시는 남는다.
|
||||
/// - 비동기 일은 시작할 때의 [Ref] 를 쥐고, 끝난 뒤 그 Ref 가 살아 있을 때만 이 목록을 바꾼다(다시 만들어졌거나 사라졌으면 버린다).
|
||||
/// 세션이 바뀌었으면(토큰 보관의 세션 세대) 개수 · 알림 · [되돌리기]도 하지 않는다 — 끝난 세션의 결과를 새 세션에 보이지 않게.
|
||||
///
|
||||
/// 여러 통 처리:
|
||||
/// - 고를 수 있는 것은 불러온 메일까지, 아직 보내지 않은 메일(붙잡힌 발송)은 고를 수 없다.
|
||||
/// 목록이 가려지면(다른 메일함 · 다른 탭 · 전체 화면) 고른 것을 푼다(계약 2절 "메일함을 바꾸면 선택을 푼다").
|
||||
/// - 읽음 · 중요 표시는 화면을 먼저 바꾸고 고르기를 끝낸다(앱 보드) — 서버가 바꾸지 못한 메일(`failedIds`) · 보내지 못한 메일만 되돌리고,
|
||||
/// 통째로 실패했으면 고른 것도 되살린다(다시 해 볼 수 있게).
|
||||
/// - 이동 · 삭제는 응답의 id 만 뺀다. 하나라도 처리했으면 고르기를 끝내고, 통째로 실패했으면 고른 것을 그대로 둔다.
|
||||
/// - 휴지통 밖의 [삭제]는 휴지통으로 옮기기(+ [되돌리기]), 휴지통에서는 확인 뒤 영구 삭제(`POST /api/message-deletions`) — 웹처럼 `DELETE` 는 쓰지 않는다.
|
||||
/// - 100통씩 차례로 보낸다.
|
||||
class MailListController extends Notifier<MailListState> {
|
||||
MailListController(this.route);
|
||||
|
||||
/// 주소의 메일함 id — null 이면 받은편지함
|
||||
final String? route;
|
||||
|
||||
int _listSeq = 0;
|
||||
int _localGeneration = 0;
|
||||
final Map<String, _PendingFlag> _pendingRead = <String, _PendingFlag>{};
|
||||
final Map<String, _PendingFlag> _pendingFlagged = <String, _PendingFlag>{};
|
||||
|
||||
/// 읽음 · 중요 표시 요청마다 하나씩 — 보내는 중인 표시의 주인
|
||||
int _flagRequestSeq = 0;
|
||||
|
||||
/// 목록 화면이 보인다 — 다른 탭 · 전체 화면 · 위에 쌓인 메일함 아래에 가렸으면 거짓
|
||||
bool _visible = false;
|
||||
|
||||
/// 가려진 동안 메일이 바뀌었다 — 다시 보이면 맞춘다
|
||||
bool _dirty = false;
|
||||
MailStateWatcher? _watcher;
|
||||
|
||||
/// 도는 맞추기 — 끝날 때까지 새 맞추기는 이것에 묶는다
|
||||
Future<void>? _syncRun;
|
||||
|
||||
/// 맞추는 동안 또 맞춰 달라고 했다
|
||||
bool _syncAgain = false;
|
||||
|
||||
/// 서버 시계 − 기기 시계 — 받은 목록 응답의 `Date` 로 잰다(붙잡아 둔 발송이 풀릴 때를 서버 시계로 잰다)
|
||||
Duration _serverOffset = Duration.zero;
|
||||
Timer? _heldTimer;
|
||||
final Map<String, int> _heldRechecks = <String, int>{};
|
||||
|
||||
MailRepository get _repository => ref.read(mailRepositoryProvider);
|
||||
|
||||
Clock get _clock => ref.read(clockProvider);
|
||||
|
||||
@override
|
||||
MailListState build() {
|
||||
final String? account = ref.watch(mailAccountProvider);
|
||||
_listSeq++;
|
||||
_localGeneration++;
|
||||
_pendingRead.clear();
|
||||
_pendingFlagged.clear();
|
||||
_dirty = false;
|
||||
_syncRun = null;
|
||||
_syncAgain = false;
|
||||
_serverOffset = Duration.zero;
|
||||
_heldRechecks.clear();
|
||||
_cancelHeldTimer();
|
||||
ref.onDispose(_onDispose);
|
||||
// 세션이 없으면(로그아웃하며 화면이 사라지는 중) 묻지 않는다 — 세션 없는 요청이 갱신을 부르지 않게
|
||||
if (account == null) return MailListState(mailboxId: route);
|
||||
ref.listen<MailChange>(mailStateWatcherProvider, (
|
||||
MailChange? previous,
|
||||
MailChange next,
|
||||
) {
|
||||
// 처음부터 다시 만들 때의 0 은 바뀜이 아니다
|
||||
if (next.seq > (previous?.seq ?? 0)) _onMailChange(next);
|
||||
});
|
||||
// 세션이 바뀌는 동안 아무도 듣지 않아 새 메일 확인이 새로 만들어졌을 수 있다 — 보이는 목록이면 새 것에 다시 알린다
|
||||
// (build 안에서는 다른 프로바이더를 바꿀 수 없다)
|
||||
if (_visible) {
|
||||
final Ref built = ref;
|
||||
scheduleMicrotask(() {
|
||||
if (built.mounted && _visible) _tellWatcher();
|
||||
});
|
||||
}
|
||||
|
||||
final String? mailboxId =
|
||||
route ??
|
||||
ref.watch(
|
||||
mailboxesProvider.select((MailboxesState s) => s.defaultMailboxId),
|
||||
);
|
||||
if (mailboxId == null) {
|
||||
final ApiException? failure = ref.watch(
|
||||
mailboxesProvider.select((MailboxesState s) => s.failure),
|
||||
);
|
||||
return failure == null
|
||||
? const MailListState()
|
||||
: const MailListState().failed(failure);
|
||||
}
|
||||
unawaited(_openFirst(mailboxId, _listSeq, silent: true));
|
||||
return MailListState(mailboxId: mailboxId);
|
||||
}
|
||||
|
||||
void _onDispose() {
|
||||
_cancelHeldTimer();
|
||||
final MailStateWatcher? watcher = _watcher;
|
||||
if (watcher == null) return;
|
||||
// 생명주기 안에서는 다른 프로바이더를 바꿀 수 없다 — 끝난 뒤에, 정말 사라졌을 때만 새 메일 확인에서 뺀다
|
||||
// (다시 만들어진 것이면 Ref 가 새로 살아 있고 화면도 그대로다)
|
||||
scheduleMicrotask(() {
|
||||
if (!ref.mounted) watcher.setViewing(this, viewing: false);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 화면 ─────────────────────────────────────────
|
||||
|
||||
/// 목록 화면이 보이게 · 안 보이게 됐다 — 보이는 동안만 새 메일을 확인하고, 가려진 동안 바뀌었으면 다시 보일 때 맞춘다.
|
||||
/// 가려지면(다른 메일함 · 다른 탭 · 전체 화면) 고른 것을 푼다
|
||||
void setVisible(bool visible) {
|
||||
if (!ref.mounted) return;
|
||||
final bool was = _visible;
|
||||
_visible = visible;
|
||||
_tellWatcher();
|
||||
if (!visible) clearSelection();
|
||||
if (visible && !was && _dirty) {
|
||||
_dirty = false;
|
||||
unawaited(_sync());
|
||||
}
|
||||
}
|
||||
|
||||
/// 새 메일 확인에 이 목록이 보이는지 알린다 — 새 메일 확인이 새로 만들어졌으면 옛것에서 빼고 새 것에
|
||||
void _tellWatcher() {
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final MailStateWatcher? previous = _watcher;
|
||||
if (previous != null && !identical(previous, watcher)) {
|
||||
previous.setViewing(this, viewing: false);
|
||||
}
|
||||
_watcher = watcher;
|
||||
watcher.setViewing(this, viewing: _visible);
|
||||
}
|
||||
|
||||
/// [다시 시도] — 첫 쪽을 다시 받는다. 받은편지함 주소에서 메일함 목록을 받지 못했으면 그것부터
|
||||
Future<void> retry() async {
|
||||
if (!ref.mounted) return;
|
||||
final String? mailboxId = state.mailboxId;
|
||||
if (mailboxId == null) {
|
||||
await ref.read(mailboxesProvider.notifier).reload();
|
||||
return;
|
||||
}
|
||||
_listSeq++;
|
||||
_localGeneration++;
|
||||
state = MailListState(mailboxId: mailboxId);
|
||||
await _openFirst(mailboxId, _listSeq, silent: true);
|
||||
}
|
||||
|
||||
/// 당겨서 새로고침 — 메일함 개수와 첫 쪽을 다시 받는다(보이는 목록은 둔 채). 처음 받지 못했던 목록이면 [retry]
|
||||
Future<void> refresh() async {
|
||||
if (!ref.mounted) return;
|
||||
ref.read(mailboxesProvider.notifier).syncCounts();
|
||||
final String? mailboxId = state.mailboxId;
|
||||
if (mailboxId == null || state.status != MailListStatus.ready) {
|
||||
await retry();
|
||||
return;
|
||||
}
|
||||
_listSeq++;
|
||||
_dirty = false;
|
||||
state = state.copyWith(refreshing: true);
|
||||
await _openFirst(mailboxId, _listSeq, silent: false);
|
||||
}
|
||||
|
||||
/// 첫 쪽을 받는다 — 처음 여는 목록은 알림 없이(화면이 받지 못함을 그린다), 새로고침은 인터셉터가 알린다
|
||||
Future<void> _openFirst(
|
||||
String mailboxId,
|
||||
int seq, {
|
||||
required bool silent,
|
||||
}) async {
|
||||
final Ref ref = this.ref;
|
||||
final bool keepVisible = !silent;
|
||||
try {
|
||||
final MailPage<MessageSummary> page = await _repository.listMessages(
|
||||
mailboxId: mailboxId,
|
||||
silent: silent,
|
||||
);
|
||||
if (!ref.mounted || seq != _listSeq) return;
|
||||
_noteServerTime(page);
|
||||
_localGeneration++;
|
||||
state = state
|
||||
.withItems(_withPendingFlags(page.items), pageInfo: page.pageInfo)
|
||||
.copyWith(
|
||||
refreshing: false,
|
||||
loadingMore: false,
|
||||
loadMoreFailed: false,
|
||||
);
|
||||
_afterItemsChanged();
|
||||
} on DioException catch (error) {
|
||||
if (!ref.mounted || seq != _listSeq) return;
|
||||
if (keepVisible) {
|
||||
// 보이던 목록은 그대로 둔다 — 알림은 인터셉터가 띄웠다. 새로고침이 세대를 올려 그 전에 떠난 이어 받기는 이미 버렸다
|
||||
state = state.copyWith(refreshing: false, loadingMore: false);
|
||||
return;
|
||||
}
|
||||
state = state.failed(toApiException(error));
|
||||
}
|
||||
}
|
||||
|
||||
/// 다음 쪽 — 끝 근처에 닿으면 화면이 부른다(끝의 [다시 시도]도). `pageInfo.nextCursor` 를 해석하지 않고 그대로 보낸다
|
||||
Future<void> loadMore() async {
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted) return;
|
||||
final MailListState current = state;
|
||||
final String? cursor = current.pageInfo?.nextCursor;
|
||||
final String? mailboxId = current.mailboxId;
|
||||
if (!current.hasMore ||
|
||||
cursor == null ||
|
||||
mailboxId == null ||
|
||||
current.loadingMore ||
|
||||
current.refreshing ||
|
||||
current.status != MailListStatus.ready) {
|
||||
return;
|
||||
}
|
||||
final int seq = _listSeq;
|
||||
state = current.copyWith(loadingMore: true, loadMoreFailed: false);
|
||||
try {
|
||||
final MailPage<MessageSummary> page = await _repository.listMessages(
|
||||
mailboxId: mailboxId,
|
||||
cursor: cursor,
|
||||
);
|
||||
if (!ref.mounted || seq != _listSeq) return;
|
||||
_noteServerTime(page);
|
||||
// 그사이 새 메일이 와서 경계가 밀렸을 수 있다 — 이미 있는 메일은 건너뛴다
|
||||
final Set<String> known = <String>{
|
||||
for (final MessageSummary message in state.items) message.id,
|
||||
};
|
||||
final List<MessageSummary> added = <MessageSummary>[
|
||||
for (final MessageSummary message in _withPendingFlags(page.items))
|
||||
if (!known.contains(message.id)) message,
|
||||
];
|
||||
// 새로 붙은 것 없이 같은 커서가 돌아왔다 — 끝 표시가 같은 커서로 끝없이 부르지 않게 [다시 시도]로 둔다
|
||||
// (모두 겹친 쪽이라도 커서가 나아갔으면 끝 표시가 다음 쪽을 이어 부른다)
|
||||
final bool stalled = added.isEmpty && page.pageInfo.nextCursor == cursor;
|
||||
// 이어 받은 뒤의 맞추기 응답은 이 쪽을 모른다 — 그 전에 떠난 맞추기는 버리고 한 번 더 맞춘다
|
||||
_localGeneration++;
|
||||
state = state
|
||||
.withItems(<MessageSummary>[
|
||||
...state.items,
|
||||
...added,
|
||||
], pageInfo: page.pageInfo)
|
||||
.copyWith(loadingMore: false, loadMoreFailed: stalled);
|
||||
_afterItemsChanged();
|
||||
} on DioException {
|
||||
if (!ref.mounted || seq != _listSeq) return;
|
||||
// 알림은 인터셉터가 띄웠다 — 끝에 [다시 시도]를 둔다(저절로 다시 하지 않는다)
|
||||
state = state.copyWith(loadingMore: false, loadMoreFailed: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _noteServerTime(MailPage<MessageSummary> page) {
|
||||
final DateTime? serverTime = page.serverTime;
|
||||
if (serverTime != null) _serverOffset = serverTime.difference(_clock());
|
||||
}
|
||||
|
||||
// ── 새 메일 확인으로 맞추기 ───────────────────────────
|
||||
|
||||
void _onMailChange(MailChange change) {
|
||||
if (identical(change.source, this)) return;
|
||||
if (_visible) {
|
||||
unawaited(_sync());
|
||||
} else {
|
||||
_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 보이는 목록을 지금까지 불러온 만큼(최대 100통) 다시 받아 맞춘다 — 고른 메일 · 스크롤 위치는 그대로(계약 2절 "새 메일 확인").
|
||||
/// 한 번에 하나 — 도는 동안 또 부르면 끝난 뒤 한 번 더 맞추고, 돌려주는 Future 는 그것까지 끝나야 끝난다.
|
||||
/// 실패는 알리지 않는다(다음 확인에서 다시 맞춘다)
|
||||
Future<void> _sync() {
|
||||
final Future<void>? running = _syncRun;
|
||||
if (running != null) {
|
||||
_syncAgain = true;
|
||||
return running;
|
||||
}
|
||||
final Future<void> run = _runSyncs(ref);
|
||||
_syncRun = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
Future<void> _runSyncs(Ref ref) async {
|
||||
try {
|
||||
bool retriedForLocalChange = false;
|
||||
do {
|
||||
_syncAgain = false;
|
||||
final bool staleByLocalChange = await _syncOnce(ref);
|
||||
// 묻는 사이 이 목록이 바뀌었으면 그 응답은 버리고 한 번 더(한 번만)
|
||||
if (staleByLocalChange && !retriedForLocalChange) {
|
||||
retriedForLocalChange = true;
|
||||
_syncAgain = true;
|
||||
}
|
||||
} while (_syncAgain && ref.mounted);
|
||||
} finally {
|
||||
// 그사이 다시 만들어졌으면 새 build 가 이미 비웠다
|
||||
if (ref.mounted) _syncRun = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 맞추기 한 번 — 묻는 사이 이 목록이 바뀌어 응답을 버렸으면 참
|
||||
Future<bool> _syncOnce(Ref ref) async {
|
||||
if (!ref.mounted) return false;
|
||||
final MailListState current = state;
|
||||
final String? mailboxId = current.mailboxId;
|
||||
if (current.status != MailListStatus.ready || mailboxId == null) {
|
||||
return false;
|
||||
}
|
||||
final int seq = _listSeq;
|
||||
final int generation = _localGeneration;
|
||||
final int limit = math.min(
|
||||
kMailListSyncMax,
|
||||
math.max(kMailPageSize, current.items.length),
|
||||
);
|
||||
try {
|
||||
final MailPage<MessageSummary> page = await _repository.listMessages(
|
||||
mailboxId: mailboxId,
|
||||
limit: limit,
|
||||
silent: true,
|
||||
);
|
||||
if (!ref.mounted ||
|
||||
seq != _listSeq ||
|
||||
state.status != MailListStatus.ready) {
|
||||
return false;
|
||||
}
|
||||
if (generation != _localGeneration) return true;
|
||||
_noteServerTime(page);
|
||||
final MailListMerge merged = mergeRefreshedList(
|
||||
current: state.items,
|
||||
currentPageInfo: state.pageInfo,
|
||||
fetched: _withPendingFlags(page.items),
|
||||
fetchedPageInfo: page.pageInfo,
|
||||
);
|
||||
state = state.withItems(merged.items, pageInfo: merged.pageInfo);
|
||||
_afterItemsChanged();
|
||||
} on DioException {
|
||||
// 알리지 않는다 — 다음 확인에서 다시 맞춘다
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 목록이 바뀐 뒤 — 아직 보내지 않은 메일이 풀릴 때를 다시 잡는다
|
||||
void _afterItemsChanged() => _scheduleHeldCheck();
|
||||
|
||||
// ── 아직 보내지 않은 메일이 풀릴 때 ─────────────────────
|
||||
|
||||
String _heldKey(MessageSummary message, PendingSend pending) =>
|
||||
'${message.id}\n${pending.sendAt.toIso8601String()}';
|
||||
|
||||
/// 서버 시계로 지금 — 받은 응답의 `Date` 로 잰 차이를 기기 시계에 더한다(없으면 기기 시계)
|
||||
DateTime _serverNow() => _clock().add(_serverOffset);
|
||||
|
||||
/// 가장 이른 보낼 시각 + 2초에 목록을 다시 본다 — 시각이 지난 채 남으면 5초마다 3번 더(웹 `usePendingSendRefresh`).
|
||||
/// 보낼 시각은 서버 시계로 잰다(기기 시계가 틀려도 제때 본다)
|
||||
void _scheduleHeldCheck() {
|
||||
_cancelHeldTimer();
|
||||
if (!ref.mounted || state.status != MailListStatus.ready) return;
|
||||
final DateTime now = _serverNow();
|
||||
DateTime? next;
|
||||
for (final MessageSummary message in state.items) {
|
||||
final PendingSend? pending = message.pendingSend;
|
||||
if (pending == null) continue;
|
||||
final DateTime due = pending.sendAt.add(kHeldReleaseGrace);
|
||||
final DateTime at;
|
||||
if (due.isAfter(now)) {
|
||||
at = due;
|
||||
} else {
|
||||
final int rechecks = _heldRechecks[_heldKey(message, pending)] ?? 0;
|
||||
if (rechecks > kHeldReleaseMaxRechecks) continue;
|
||||
at = now.add(kHeldReleaseRecheck);
|
||||
}
|
||||
if (next == null || at.isBefore(next)) next = at;
|
||||
}
|
||||
if (next == null) return;
|
||||
_heldTimer = Timer(next.difference(now), _onHeldDue);
|
||||
}
|
||||
|
||||
void _onHeldDue() {
|
||||
_heldTimer = null;
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted) return;
|
||||
final DateTime now = _serverNow();
|
||||
for (final MessageSummary message in state.items) {
|
||||
final PendingSend? pending = message.pendingSend;
|
||||
if (pending == null ||
|
||||
pending.sendAt.add(kHeldReleaseGrace).isAfter(now)) {
|
||||
continue;
|
||||
}
|
||||
final String key = _heldKey(message, pending);
|
||||
_heldRechecks[key] = (_heldRechecks[key] ?? 0) + 1;
|
||||
}
|
||||
if (!_visible) {
|
||||
_dirty = true;
|
||||
return;
|
||||
}
|
||||
// 맞추기가 실패해도(연결 끊김) 다음 차례를 다시 잡는다 — 횟수 한도는 그대로
|
||||
unawaited(
|
||||
_sync().whenComplete(() {
|
||||
if (ref.mounted) _scheduleHeldCheck();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancelHeldTimer() {
|
||||
_heldTimer?.cancel();
|
||||
_heldTimer = null;
|
||||
}
|
||||
|
||||
// ── 고르기 ─────────────────────────────────────────
|
||||
|
||||
/// 하나 고르기 · 풀기 — 동그란 사진 · 길게 누르기 · 고르는 중의 줄 누르기.
|
||||
/// 고를 수 없는 메일(아직 보내지 않은 메일 · 목록에 없음)이면 아무것도 하지 않고 거짓
|
||||
bool toggleSelection(String id) {
|
||||
if (!ref.mounted) return false;
|
||||
final MessageSummary? message = _find(id);
|
||||
if (message == null || message.isHeld) return false;
|
||||
final Set<String> next = <String>{...state.selectedIds};
|
||||
if (!next.remove(id)) next.add(id);
|
||||
state = state.copyWith(selectedIds: Set<String>.unmodifiable(next));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 고르기를 끝낸다 — ✕ · 뒤로 가기 · 목록이 가려짐
|
||||
void clearSelection() {
|
||||
if (!ref.mounted || state.selectedIds.isEmpty) return;
|
||||
state = state.copyWith(selectedIds: const <String>{});
|
||||
}
|
||||
|
||||
MessageSummary? _find(String id) {
|
||||
for (final MessageSummary message in state.items) {
|
||||
if (message.id == id) return message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 여러 통 처리 ─────────────────────────────────────
|
||||
|
||||
/// [읽음 표시] — 고른 것이 모두 읽음이면 안 읽음으로
|
||||
Future<void> toggleSelectedRead() async {
|
||||
if (!ref.mounted) return;
|
||||
await _patchSelectedFlags((
|
||||
isRead: !state.allSelectedRead,
|
||||
isFlagged: null,
|
||||
));
|
||||
}
|
||||
|
||||
/// [중요 표시] — 고른 것이 모두 중요면 해제
|
||||
Future<void> toggleSelectedFlag() async {
|
||||
if (!ref.mounted) return;
|
||||
await _patchSelectedFlags((
|
||||
isRead: null,
|
||||
isFlagged: !state.allSelectedFlagged,
|
||||
));
|
||||
}
|
||||
|
||||
/// 고른 메일의 읽음 · 중요 표시 — 화면을 먼저 바꾸고 고르기를 끝낸다.
|
||||
/// 서버가 바꾸지 못한 메일 · 보내지 못한 메일만 바꾼 칸을 되돌리고(그사이 보내는 중인 다른 표시는 다시 입힌다),
|
||||
/// 통째로 실패했으면 고른 것도 되살린다. 알림은 띄우지 않는다(목록이 바로 바뀐다 — 요청 실패는 인터셉터가 알린다)
|
||||
Future<void> _patchSelectedFlags(_FlagChange change) async {
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted) return;
|
||||
final List<MessageSummary> targets = state.selectedMessages;
|
||||
if (targets.isEmpty) return;
|
||||
final int seq = _listSeq;
|
||||
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
final List<String> ids = <String>[
|
||||
for (final MessageSummary message in targets) message.id,
|
||||
];
|
||||
final Map<String, MessageSummary> before = <String, MessageSummary>{
|
||||
for (final MessageSummary message in targets) message.id: message,
|
||||
};
|
||||
|
||||
_adjustUnread(mailboxes, targets, change.isRead, direction: 1);
|
||||
_localGeneration++;
|
||||
state = state
|
||||
.withItems(_applyFlags(state.items, ids.toSet(), change))
|
||||
.copyWith(selectedIds: const <String>{});
|
||||
final int owner = ++_flagRequestSeq;
|
||||
_beginPending(ids, change, owner);
|
||||
|
||||
final MailRepository repository = _repository;
|
||||
final BulkResult result = await sendInChunks(ids, kMailBulkMaxIds, (
|
||||
List<String> chunk,
|
||||
) async {
|
||||
final MessagesPatched patched = await repository.updateMessages(
|
||||
chunk,
|
||||
MessageChanges(isRead: change.isRead, isFlagged: change.isFlagged),
|
||||
);
|
||||
return (done: patched.updatedIds, skipped: patched.failedIds);
|
||||
});
|
||||
|
||||
final bool alive = ref.mounted;
|
||||
final bool sameSession = tokens.sessionGeneration == session;
|
||||
final Set<String> revertIds = <String>{
|
||||
...result.skippedIds,
|
||||
...result.unsentIds,
|
||||
};
|
||||
final List<MessageSummary> reverted = <MessageSummary>[
|
||||
for (final String id in revertIds) ?before[id],
|
||||
];
|
||||
final bool fresh = alive && seq == _listSeq;
|
||||
if (alive) {
|
||||
_endPending(ids, change, owner);
|
||||
_localGeneration++;
|
||||
}
|
||||
if (fresh) {
|
||||
if (reverted.isNotEmpty) {
|
||||
state = state.withItems(
|
||||
_withPendingFlags(<MessageSummary>[
|
||||
for (final MessageSummary message in state.items)
|
||||
if (revertIds.contains(message.id) && before[message.id] != null)
|
||||
message.copyWith(
|
||||
isRead: change.isRead == null
|
||||
? null
|
||||
: before[message.id]!.isRead,
|
||||
isFlagged: change.isFlagged == null
|
||||
? null
|
||||
: before[message.id]!.isFlagged,
|
||||
)
|
||||
else
|
||||
message,
|
||||
]),
|
||||
);
|
||||
}
|
||||
// 통째로 실패했다 — 다시 해 볼 수 있게 고른 것을 되살린다(목록에서 빠졌거나 고를 수 없게 된 메일은 뺀다)
|
||||
if (result.doneIds.isEmpty && result.skippedIds.isEmpty) {
|
||||
final List<MessageSummary> items = state.items;
|
||||
state = state
|
||||
.copyWith(
|
||||
selectedIds: Set<String>.unmodifiable(<String>{
|
||||
...state.selectedIds,
|
||||
...ids,
|
||||
}),
|
||||
)
|
||||
.withItems(items);
|
||||
}
|
||||
} else if (alive) {
|
||||
// 그사이 처음부터 다시 받았다 — 그 목록은 이 일이 서버에 닿기 전의 값일 수 있어 서버 기준으로 다시 맞춘다
|
||||
unawaited(_sync());
|
||||
}
|
||||
// 끝난 세션의 결과는 새 세션에 보이지 않는다
|
||||
if (!sameSession) return;
|
||||
if (reverted.isNotEmpty) {
|
||||
_adjustUnread(mailboxes, reverted, change.isRead, direction: -1);
|
||||
}
|
||||
// 바꾼 것이 있으면 다른 목록 · 메일함 개수에 알린다(메일함 개수는 그 소식으로 맞춘다)
|
||||
if (result.doneIds.isNotEmpty) {
|
||||
watcher.markChanged(source: fresh ? this : null);
|
||||
} else if (change.isRead != null) {
|
||||
mailboxes.syncCounts();
|
||||
}
|
||||
}
|
||||
|
||||
/// 안 읽은 개수를 먼저 바꾼다 — [direction] 1 은 바꿀 때, -1 은 되돌릴 때
|
||||
void _adjustUnread(
|
||||
MailboxesNotifier mailboxes,
|
||||
List<MessageSummary> messages,
|
||||
bool? isRead, {
|
||||
required int direction,
|
||||
}) {
|
||||
if (isRead == null) return;
|
||||
for (final MessageSummary message in messages) {
|
||||
if (message.isRead == isRead) continue;
|
||||
mailboxes.adjustUnread(message.mailboxIds, (isRead ? -1 : 1) * direction);
|
||||
}
|
||||
}
|
||||
|
||||
List<MessageSummary> _applyFlags(
|
||||
List<MessageSummary> items,
|
||||
Set<String> ids,
|
||||
_FlagChange change,
|
||||
) => <MessageSummary>[
|
||||
for (final MessageSummary message in items)
|
||||
ids.contains(message.id)
|
||||
? message.copyWith(isRead: change.isRead, isFlagged: change.isFlagged)
|
||||
: message,
|
||||
];
|
||||
|
||||
void _beginPending(List<String> ids, _FlagChange change, int owner) {
|
||||
final bool? isRead = change.isRead;
|
||||
final bool? isFlagged = change.isFlagged;
|
||||
for (final String id in ids) {
|
||||
if (isRead != null) _pendingRead[id] = (value: isRead, owner: owner);
|
||||
if (isFlagged != null) {
|
||||
_pendingFlagged[id] = (value: isFlagged, owner: owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 요청 [owner] 가 끝났다 — 그 요청이 아직 주인인 칸만 내린다(뒤에 보낸 요청의 표시는 남긴다)
|
||||
void _endPending(List<String> ids, _FlagChange change, int owner) {
|
||||
for (final String id in ids) {
|
||||
if (change.isRead != null && _pendingRead[id]?.owner == owner) {
|
||||
_pendingRead.remove(id);
|
||||
}
|
||||
if (change.isFlagged != null && _pendingFlagged[id]?.owner == owner) {
|
||||
_pendingFlagged.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 보내는 중인 읽음 · 중요 표시를 받은 목록 위에 덮어쓴다
|
||||
List<MessageSummary> _withPendingFlags(List<MessageSummary> items) {
|
||||
if (_pendingRead.isEmpty && _pendingFlagged.isEmpty) return items;
|
||||
return <MessageSummary>[
|
||||
for (final MessageSummary message in items)
|
||||
if ((_pendingRead[message.id], _pendingFlagged[message.id]) case (
|
||||
final _PendingFlag? read,
|
||||
final _PendingFlag? flagged,
|
||||
) when read != null || flagged != null)
|
||||
message.copyWith(isRead: read?.value, isFlagged: flagged?.value)
|
||||
else
|
||||
message,
|
||||
];
|
||||
}
|
||||
|
||||
/// [삭제] (휴지통 밖) — 휴지통으로 옮긴다. 휴지통을 모르면 메일함 목록을 받아 보고, 그래도 모르면 알린다(옮기지 않는다)
|
||||
Future<void> moveSelectedToTrash() async {
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted || state.busy || state.selectedMessages.isEmpty) return;
|
||||
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
|
||||
Mailbox? trash = ref.read(mailboxesProvider).byRole(MailboxRole.trash);
|
||||
if (trash == null) {
|
||||
await mailboxes.reload();
|
||||
if (!ref.mounted) return;
|
||||
trash = ref.read(mailboxesProvider).byRole(MailboxRole.trash);
|
||||
}
|
||||
if (trash == null) {
|
||||
ref
|
||||
.read(errorNotifierProvider)
|
||||
.show(
|
||||
ref.read(mailboxesProvider).failure ??
|
||||
ApiException.fromCode('BIZ_001'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await moveSelected(trash);
|
||||
}
|
||||
|
||||
/// 고른 메일을 [target] 으로 옮긴다 — 응답의 `updatedIds` 만 목록에서 빼고 알림에 [되돌리기]를 단다.
|
||||
/// 그대로 둔 메일 · 보내지 못한 메일이 있으면(503 은 일부만 바뀌었을 수 있다) 목록을 서버 기준으로 다시 맞춘다
|
||||
Future<void> moveSelected(Mailbox target) async {
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted || state.busy) return;
|
||||
final List<MessageSummary> targets = state.selectedMessages;
|
||||
if (targets.isEmpty) return;
|
||||
final int seq = _listSeq;
|
||||
final MailRepository repository = _repository;
|
||||
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final MailToastNotifier toast = ref.read(mailToastProvider.notifier);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
final String? scope = state.mailboxId;
|
||||
final Map<String, String> origins = <String, String>{
|
||||
for (final MessageSummary message in targets)
|
||||
if (_originOf(message, scope, target.id) case final String origin
|
||||
when origin != target.id)
|
||||
message.id: origin,
|
||||
};
|
||||
|
||||
state = state.copyWith(busy: true);
|
||||
final BulkResult result = await sendInChunks(
|
||||
<String>[for (final MessageSummary message in targets) message.id],
|
||||
kMailBulkMaxIds,
|
||||
(List<String> chunk) async {
|
||||
final MessagesPatched patched = await repository.updateMessages(
|
||||
chunk,
|
||||
MessageChanges(mailboxId: target.id),
|
||||
);
|
||||
return (done: patched.updatedIds, skipped: patched.failedIds);
|
||||
},
|
||||
);
|
||||
|
||||
final bool alive = ref.mounted;
|
||||
final bool fresh = alive && seq == _listSeq;
|
||||
_settleRemoval(result, alive: alive, fresh: fresh);
|
||||
// 끝난 세션의 결과는 새 세션에 알리지 않는다 — [되돌리기]도 없다
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
// 옮긴 것이 있으면 다른 목록 · 메일함 개수에 알린다(메일함 개수는 그 소식으로 맞춘다)
|
||||
if (result.doneIds.isNotEmpty) {
|
||||
watcher.markChanged(source: fresh ? this : null);
|
||||
} else {
|
||||
mailboxes.syncCounts();
|
||||
}
|
||||
|
||||
final int done = result.doneIds.length;
|
||||
final int skipped = result.skippedIds.length;
|
||||
if (done == 0 && skipped == 0) return;
|
||||
final String text = MailBulkTexts.moved(
|
||||
done,
|
||||
target.name,
|
||||
skipped: skipped,
|
||||
);
|
||||
final List<BulkOrigin> groups = groupByOrigin(result.doneIds, origins);
|
||||
if (groups.isEmpty) {
|
||||
toast.notify(text);
|
||||
return;
|
||||
}
|
||||
toast.offer(
|
||||
text,
|
||||
actionLabel: MailBulkTexts.undo,
|
||||
onAction: () => _restoreMoved(
|
||||
groups,
|
||||
repository: repository,
|
||||
mailboxes: mailboxes,
|
||||
watcher: watcher,
|
||||
toast: toast,
|
||||
tokens: tokens,
|
||||
session: session,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 되돌리기에 쓸 원래 메일함 — 이 목록의 메일함, 그 메일이 거기 없으면 옮길 곳이 아닌 첫 메일함
|
||||
String? _originOf(MessageSummary message, String? scope, String targetId) {
|
||||
if (scope != null && message.mailboxIds.contains(scope)) return scope;
|
||||
for (final String id in message.mailboxIds) {
|
||||
if (id != targetId) return id;
|
||||
}
|
||||
return message.mailboxIds.isEmpty ? null : message.mailboxIds.first;
|
||||
}
|
||||
|
||||
/// 휴지통에서 영구 삭제 — 확인 창에서 [영구 삭제]를 누른 뒤에만 부른다. 지운 메일(`deletedIds`)만 목록에서 뺀다
|
||||
Future<void> purgeSelected() async {
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted || state.busy) return;
|
||||
final List<MessageSummary> targets = state.selectedMessages;
|
||||
if (targets.isEmpty) return;
|
||||
final int seq = _listSeq;
|
||||
final MailRepository repository = _repository;
|
||||
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final MailToastNotifier toast = ref.read(mailToastProvider.notifier);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
|
||||
state = state.copyWith(busy: true);
|
||||
final BulkResult result = await sendInChunks(
|
||||
<String>[for (final MessageSummary message in targets) message.id],
|
||||
kMailBulkMaxIds,
|
||||
(List<String> chunk) async {
|
||||
final MessageDeletions deleted = await repository.deleteMessages(chunk);
|
||||
return (done: deleted.deletedIds, skipped: deleted.skippedIds);
|
||||
},
|
||||
);
|
||||
|
||||
final bool alive = ref.mounted;
|
||||
final bool fresh = alive && seq == _listSeq;
|
||||
_settleRemoval(result, alive: alive, fresh: fresh);
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
if (result.doneIds.isNotEmpty) {
|
||||
watcher.markChanged(source: fresh ? this : null);
|
||||
} else {
|
||||
mailboxes.syncCounts();
|
||||
}
|
||||
final int done = result.doneIds.length;
|
||||
final int skipped = result.skippedIds.length;
|
||||
if (done > 0 || skipped > 0) {
|
||||
toast.notify(MailBulkTexts.purged(done, skipped: skipped));
|
||||
}
|
||||
}
|
||||
|
||||
/// 옮기기 · 영구 삭제가 끝났다 — 이 목록이 그대로면 처리한 메일만 빼고(하나라도 했으면 고르기를 끝낸다),
|
||||
/// 그대로 둔 메일이 있거나 그사이 처음부터 다시 받았으면 서버 기준으로 다시 맞춘다
|
||||
void _settleRemoval(
|
||||
BulkResult result, {
|
||||
required bool alive,
|
||||
required bool fresh,
|
||||
}) {
|
||||
if (!alive) return;
|
||||
if (!fresh) {
|
||||
state = state.copyWith(busy: false);
|
||||
unawaited(_sync());
|
||||
return;
|
||||
}
|
||||
_removeFromList(result.doneIds.toSet());
|
||||
state = state.copyWith(
|
||||
busy: false,
|
||||
selectedIds: result.doneIds.isEmpty ? null : const <String>{},
|
||||
);
|
||||
if (result.hasLeftovers) unawaited(_sync());
|
||||
}
|
||||
|
||||
/// 목록에서 뺀다 — 전체 수도 그만큼 줄인다
|
||||
void _removeFromList(Set<String> ids) {
|
||||
if (ids.isEmpty) return;
|
||||
final List<MessageSummary> kept = <MessageSummary>[
|
||||
for (final MessageSummary message in state.items)
|
||||
if (!ids.contains(message.id)) message,
|
||||
];
|
||||
final int removed = state.items.length - kept.length;
|
||||
final MailPageInfo? info = state.pageInfo;
|
||||
final int? total = info?.total;
|
||||
_localGeneration++;
|
||||
state = state.withItems(
|
||||
kept,
|
||||
pageInfo: info != null && total != null && removed > 0
|
||||
? info.copyWith(total: math.max(0, total - removed))
|
||||
: info,
|
||||
);
|
||||
_afterItemsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// [되돌리기] — 옮긴 메일을 원래 메일함별로 다시 옮긴 뒤 개수와 목록을 서버 기준으로 맞춘다.
|
||||
///
|
||||
/// 알림이 목록보다 오래 남을 수 있어(메일함을 바꿈) 목록 없이 돈다. 만든 세션에서만 움직인다 —
|
||||
/// 로그아웃 · 세션 끝 · 다른 직원 로그인 뒤에는 아무것도 하지 않는다(메일 id 는 계정마다 따로 매겨져 겹칠 수 있다).
|
||||
Future<void> _restoreMoved(
|
||||
List<BulkOrigin> origins, {
|
||||
required MailRepository repository,
|
||||
required MailboxesNotifier mailboxes,
|
||||
required MailStateWatcher watcher,
|
||||
required MailToastNotifier toast,
|
||||
required TokenStore tokens,
|
||||
required int session,
|
||||
}) async {
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
final List<String> done = <String>[];
|
||||
final List<String> skipped = <String>[];
|
||||
for (final BulkOrigin group in origins) {
|
||||
final BulkResult result = await sendInChunks(group.ids, kMailBulkMaxIds, (
|
||||
List<String> chunk,
|
||||
) async {
|
||||
final MessagesPatched patched = await repository.updateMessages(
|
||||
chunk,
|
||||
MessageChanges(mailboxId: group.mailboxId),
|
||||
);
|
||||
return (done: patched.updatedIds, skipped: patched.failedIds);
|
||||
});
|
||||
done.addAll(result.doneIds);
|
||||
skipped.addAll(result.skippedIds);
|
||||
if (result.unsentIds.isNotEmpty) break;
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
}
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
// 모든 목록 · 메일함 개수가 서버 기준으로 맞춘다
|
||||
if (done.isNotEmpty) {
|
||||
watcher.markChanged();
|
||||
} else {
|
||||
mailboxes.syncCounts();
|
||||
}
|
||||
if (done.isNotEmpty || skipped.isNotEmpty) {
|
||||
toast.notify(MailBulkTexts.restored(done.length, skipped: skipped.length));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'package:arasystem_app/core/api/api_exception.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 목록의 상태
|
||||
enum MailListStatus {
|
||||
/// 첫 쪽을 받는 중
|
||||
loading,
|
||||
|
||||
/// 받았다(비었을 수도 있다)
|
||||
ready,
|
||||
|
||||
/// 첫 쪽을 받지 못했다 — 받지 못함 + [다시 시도]
|
||||
failed,
|
||||
}
|
||||
|
||||
/// 메일함 하나의 목록 · 고른 메일
|
||||
@immutable
|
||||
class MailListState {
|
||||
const MailListState({
|
||||
this.status = MailListStatus.loading,
|
||||
this.mailboxId,
|
||||
this.items = const <MessageSummary>[],
|
||||
this.pageInfo,
|
||||
this.error,
|
||||
this.refreshing = false,
|
||||
this.loadingMore = false,
|
||||
this.loadMoreFailed = false,
|
||||
this.selectedIds = const <String>{},
|
||||
this.busy = false,
|
||||
});
|
||||
|
||||
final MailListStatus status;
|
||||
|
||||
/// 보는 메일함 — 받은편지함 주소(`/mail`)는 메일함 목록을 받기 전까지 null
|
||||
final String? mailboxId;
|
||||
final List<MessageSummary> items;
|
||||
final MailPageInfo? pageInfo;
|
||||
|
||||
/// 첫 쪽을 받지 못한 까닭 (코드 · 추적 ID)
|
||||
final ApiException? error;
|
||||
|
||||
/// 당겨서 새로고침하는 중
|
||||
final bool refreshing;
|
||||
|
||||
/// 다음 쪽을 받는 중
|
||||
final bool loadingMore;
|
||||
|
||||
/// 다음 쪽을 받지 못했다 — 끝에 [다시 시도]
|
||||
final bool loadMoreFailed;
|
||||
|
||||
/// 고른 메일 id — 아직 보내지 않은 메일(붙잡힌 발송)은 들지 않는다
|
||||
final Set<String> selectedIds;
|
||||
|
||||
/// 옮기기 · 지우기를 하는 중 — 끝날 때까지 다시 누르지 못한다(읽음 · 중요 표시는 막지 않는다)
|
||||
final bool busy;
|
||||
|
||||
/// 다음 쪽이 있다
|
||||
bool get hasMore => pageInfo?.hasMore ?? false;
|
||||
|
||||
/// 여러 통 고르기 중
|
||||
bool get selecting => selectedIds.isNotEmpty;
|
||||
|
||||
/// 고른 메일 — 목록 차례 그대로
|
||||
List<MessageSummary> get selectedMessages => <MessageSummary>[
|
||||
for (final MessageSummary message in items)
|
||||
if (selectedIds.contains(message.id) && !message.isHeld) message,
|
||||
];
|
||||
|
||||
int get selectedCount => selectedMessages.length;
|
||||
|
||||
/// 고른 메일이 모두 읽음 — [읽음 표시]가 [안 읽음 표시]가 된다
|
||||
bool get allSelectedRead {
|
||||
final List<MessageSummary> selected = selectedMessages;
|
||||
return selected.isNotEmpty &&
|
||||
selected.every((MessageSummary message) => message.isRead);
|
||||
}
|
||||
|
||||
/// 고른 메일이 모두 중요 — [중요 표시]가 [중요 해제]가 된다
|
||||
bool get allSelectedFlagged {
|
||||
final List<MessageSummary> selected = selectedMessages;
|
||||
return selected.isNotEmpty &&
|
||||
selected.every((MessageSummary message) => message.isFlagged);
|
||||
}
|
||||
|
||||
MailListState copyWith({
|
||||
MailListStatus? status,
|
||||
String? mailboxId,
|
||||
bool? refreshing,
|
||||
bool? loadingMore,
|
||||
bool? loadMoreFailed,
|
||||
Set<String>? selectedIds,
|
||||
bool? busy,
|
||||
}) => MailListState(
|
||||
status: status ?? this.status,
|
||||
mailboxId: mailboxId ?? this.mailboxId,
|
||||
items: items,
|
||||
pageInfo: pageInfo,
|
||||
error: error,
|
||||
refreshing: refreshing ?? this.refreshing,
|
||||
loadingMore: loadingMore ?? this.loadingMore,
|
||||
loadMoreFailed: loadMoreFailed ?? this.loadMoreFailed,
|
||||
selectedIds: selectedIds ?? this.selectedIds,
|
||||
busy: busy ?? this.busy,
|
||||
);
|
||||
|
||||
/// 받은 목록으로 바꾼다 — 목록에서 빠졌거나 고를 수 없게 된 메일은 고른 것에서도 뺀다
|
||||
MailListState withItems(
|
||||
List<MessageSummary> items, {
|
||||
MailPageInfo? pageInfo,
|
||||
MailListStatus status = MailListStatus.ready,
|
||||
}) {
|
||||
final Set<String> selectable = <String>{
|
||||
for (final MessageSummary message in items)
|
||||
if (!message.isHeld) message.id,
|
||||
};
|
||||
final Set<String> kept = selectedIds.where(selectable.contains).toSet();
|
||||
return MailListState(
|
||||
status: status,
|
||||
mailboxId: mailboxId,
|
||||
items: List<MessageSummary>.unmodifiable(items),
|
||||
pageInfo: pageInfo ?? this.pageInfo,
|
||||
refreshing: refreshing,
|
||||
loadingMore: loadingMore,
|
||||
loadMoreFailed: loadMoreFailed,
|
||||
selectedIds: kept.length == selectedIds.length
|
||||
? selectedIds
|
||||
: Set<String>.unmodifiable(kept),
|
||||
busy: busy,
|
||||
);
|
||||
}
|
||||
|
||||
/// 첫 쪽을 받지 못했다
|
||||
MailListState failed(ApiException error) => MailListState(
|
||||
status: MailListStatus.failed,
|
||||
mailboxId: mailboxId,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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/mail/data/repositories/mail_repository.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 메일 저장소 — 인터셉터(언래핑 · Bearer · 갱신 · 알림)가 걸린 `dioProvider` 의 Dio 를 쓴다
|
||||
final Provider<MailRepository> mailRepositoryProvider =
|
||||
Provider<MailRepository>(
|
||||
(Ref ref) => MailRepository(ref.watch(dioProvider)),
|
||||
);
|
||||
|
||||
/// 메일을 보는 직원 — 로그인했고 제한이 없을 때 그 주소, 그 밖에는 null.
|
||||
///
|
||||
/// 메일 모듈의 상태(메일함 · 목록 · 알림 · 새 메일 확인)는 이것을 듣고 바뀌면 처음부터 다시 만든다 —
|
||||
/// 로그아웃 · 세션 끝 · 다른 직원 로그인 뒤에 앞 직원의 메일이 남지 않게. 로그아웃은 늘 null 을 거치므로 같은 직원이 다시 들어와도 새로 시작한다.
|
||||
final Provider<String?> mailAccountProvider = 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,167 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/core/utils/app_foreground.dart';
|
||||
import 'package:arasystem_app/core/utils/clock.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 새 메일 확인 간격 — 화면이 보이는 동안 이만큼마다 바뀐 것이 있는지 묻는다(웹 `MAIL_STATE_POLL_INTERVAL_MS` 와 같다)
|
||||
const Duration kMailStatePollInterval = Duration(seconds: 60);
|
||||
|
||||
/// 앱이 다시 앞으로 오면 마지막으로 물은 지 이만큼 지났을 때 바로 한 번 묻는다(웹 `MAIL_STATE_RECHECK_GAP_MS`)
|
||||
const Duration kMailStateRecheckGap = Duration(seconds: 10);
|
||||
|
||||
/// 메일이 바뀌었다는 소식 — 목록 · 메일함 개수가 듣는다
|
||||
@immutable
|
||||
class MailChange {
|
||||
const MailChange(this.seq, {this.source});
|
||||
|
||||
/// 소식마다 하나씩 오른다 — 처음부터 다시 만들면 0
|
||||
final int seq;
|
||||
|
||||
/// 이 앱의 목록이 스스로 바꾼 것이면 그 목록(그 목록은 이미 맞췄다) — 서버에서 알아챈 바뀜 · 되돌리기는 null
|
||||
final Object? source;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MailChange &&
|
||||
other.seq == seq &&
|
||||
identical(other.source, source);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(seq, identityHashCode(source));
|
||||
}
|
||||
|
||||
/// 새 메일 확인 — 메일 목록이 보이는 동안만 돈다. 세션이 바뀌면 기준을 버린다.
|
||||
final NotifierProvider<MailStateWatcher, MailChange> mailStateWatcherProvider =
|
||||
NotifierProvider.autoDispose<MailStateWatcher, MailChange>(
|
||||
MailStateWatcher.new,
|
||||
);
|
||||
|
||||
/// 새 메일 확인 (계약 2절 "새 메일 확인" · 웹 3단계 "새 메일" — `useMailStatePolling` 과 같은 결)
|
||||
///
|
||||
/// - 메일 목록 화면이 보이고([setViewing]) 앱이 앞에 있는 동안만 1분마다 `GET /api/mail-state` 를 묻는다. 한 번에 하나씩.
|
||||
/// - 다시 보이게 되면(앱이 앞으로 옴 · 다른 탭이나 전체 화면에서 돌아옴) 마지막으로 물은 지 10초가 지났으면 바로 한 번, 아니면 원래 차례에.
|
||||
/// - 처음 받은 값은 기준으로만 쓴다. 값이 바뀌면 [MailChange] 를 하나 올린다 — 목록은 불러온 만큼 다시 받아 맞추고, 메일함은 개수를 맞춘다.
|
||||
/// - 실패(`MAIL_001` · 연결 끊김)는 알리지 않고 다음 차례에 다시 묻는다. 401 은 인증 인터셉터의 세션 흐름을 그대로 탄다.
|
||||
/// - 이 앱의 목록이 메일을 옮기거나 되돌리면 [markChanged] 로 다른 목록에 알린다(그 목록이 보일 때 맞춘다).
|
||||
/// - 푸시(새 메일 알림)는 다음 차례다.
|
||||
class MailStateWatcher extends Notifier<MailChange> {
|
||||
final Set<Object> _viewers = <Object>{};
|
||||
Timer? _timer;
|
||||
bool _foreground = true;
|
||||
bool _signedIn = false;
|
||||
bool _asking = false;
|
||||
DateTime? _lastAskedAt;
|
||||
String? _lastState;
|
||||
|
||||
Clock get _clock => ref.read(clockProvider);
|
||||
|
||||
@override
|
||||
MailChange build() {
|
||||
_signedIn = ref.watch(mailAccountProvider) != null;
|
||||
// 세션이 바뀌면 기준 · 물은 때를 버린다(화면의 등록은 그대로 — 화면은 남아 있다)
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_asking = false;
|
||||
_lastAskedAt = null;
|
||||
_lastState = null;
|
||||
_foreground = ref.read(appForegroundProvider);
|
||||
ref.listen<bool>(appForegroundProvider, (bool? previous, bool next) {
|
||||
final bool before = _active;
|
||||
_foreground = next;
|
||||
if (before != _active) _onActiveChanged();
|
||||
});
|
||||
ref.onDispose(() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
});
|
||||
// 다시 만들어진 뒤에도 물을 때이면(보이는 목록이 그대로 있다) 새 차례를 잡는다 — build 안에서는 묻지 않는다
|
||||
final Ref built = ref;
|
||||
scheduleMicrotask(() {
|
||||
if (built.mounted && _active && _timer == null && !_asking) {
|
||||
_onActiveChanged();
|
||||
}
|
||||
});
|
||||
return const MailChange(0);
|
||||
}
|
||||
|
||||
/// 지금 물을 때인가 — 로그인했고, 보이는 목록이 있고, 앱이 앞에 있다
|
||||
bool get _active => _signedIn && _foreground && _viewers.isNotEmpty;
|
||||
|
||||
/// 목록 하나가 보이게 · 안 보이게 됐다 — [viewer] 는 그 목록
|
||||
void setViewing(Object viewer, {required bool viewing}) {
|
||||
if (!ref.mounted) return;
|
||||
final bool before = _active;
|
||||
if (viewing) {
|
||||
_viewers.add(viewer);
|
||||
} else {
|
||||
_viewers.remove(viewer);
|
||||
}
|
||||
if (before != _active) _onActiveChanged();
|
||||
}
|
||||
|
||||
/// 이 앱이 메일을 바꿨다 — 다른 목록 · 메일함 개수가 맞추게 알린다. [source] 는 이미 맞춘 목록(없으면 모든 목록)
|
||||
void markChanged({Object? source}) {
|
||||
if (!ref.mounted) return;
|
||||
state = MailChange(state.seq + 1, source: source);
|
||||
}
|
||||
|
||||
void _onActiveChanged() {
|
||||
if (!_active) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
return;
|
||||
}
|
||||
// 묻는 중이면 그 물음이 끝난 뒤 다음 차례를 잡는다
|
||||
if (_asking) return;
|
||||
final DateTime? last = _lastAskedAt;
|
||||
final Duration? elapsed = last == null ? null : _clock().difference(last);
|
||||
if (elapsed == null ||
|
||||
elapsed.isNegative ||
|
||||
elapsed >= kMailStateRecheckGap) {
|
||||
unawaited(_ask());
|
||||
} else {
|
||||
_schedule(kMailStatePollInterval - elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
void _schedule(Duration delay) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
if (!_active) return;
|
||||
_timer = Timer(delay.isNegative ? Duration.zero : delay, () {
|
||||
_timer = null;
|
||||
unawaited(_ask());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _ask() async {
|
||||
if (_asking || !_active) return;
|
||||
final Ref ref = this.ref;
|
||||
_asking = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_lastAskedAt = _clock();
|
||||
try {
|
||||
final String value = await ref.read(mailRepositoryProvider).mailState();
|
||||
if (!ref.mounted) return;
|
||||
final String? previous = _lastState;
|
||||
_lastState = value;
|
||||
if (previous != null && previous != value) {
|
||||
state = MailChange(state.seq + 1);
|
||||
}
|
||||
} on DioException {
|
||||
// 알리지 않는다 — 다음 차례에 다시 묻는다
|
||||
} finally {
|
||||
// 그사이 처음부터 다시 만들었으면(세션이 바뀜) 새 차례가 이어 간다
|
||||
if (ref.mounted) {
|
||||
_asking = false;
|
||||
_schedule(kMailStatePollInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/core/utils/accessible_navigation.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 단추 없는 알림이 닫히기까지 — 웹 `TOAST_DURATION_MS.success` 와 같다
|
||||
const Duration kMailToastDuration = Duration(seconds: 5);
|
||||
|
||||
/// 단추가 있는 알림([되돌리기])이 닫히기까지 — 단추를 찾아 누를 시간을 넉넉히(웹 `TOAST_ACTION_DURATION_MS`)
|
||||
const Duration kMailToastActionDuration = Duration(seconds: 10);
|
||||
|
||||
/// 메일 화면 아래의 알림 줄 — 단추가 있는 알림만 둔다(보드 `App1MailList` 의 "메일 2통을 휴지통으로 옮겼습니다. [되돌리기]")
|
||||
@immutable
|
||||
class MailToast {
|
||||
const MailToast({
|
||||
required this.id,
|
||||
required this.text,
|
||||
required this.actionLabel,
|
||||
});
|
||||
|
||||
/// 알림마다 다르다 — 단추 · 닫기가 그 알림에만 듣게
|
||||
final int id;
|
||||
final String text;
|
||||
final String actionLabel;
|
||||
}
|
||||
|
||||
/// 단추 없는 알림을 띄우는 곳 — 앱의 전역 알림 자리(`ScaffoldMessenger` 스낵바, 테마의 알림 모양).
|
||||
///
|
||||
/// 탭 안 화면의 Scaffold 가 받으므로 [메일 쓰기] · 단추 있는 알림 줄 위에 뜬다 — 둘이 겹치지 않고, [되돌리기]를 바꿔 끼우지 않는다.
|
||||
/// 시험은 덮어써 적어 둔다.
|
||||
final Provider<void Function(String text)> mailNoticeSinkProvider =
|
||||
Provider<void Function(String text)>((Ref ref) {
|
||||
final GlobalKey<ScaffoldMessengerState> key = ref.watch(
|
||||
scaffoldMessengerKeyProvider,
|
||||
);
|
||||
return (String text) {
|
||||
final ScaffoldMessengerState? messenger = key.currentState;
|
||||
if (messenger == null) return;
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(text), duration: kMailToastDuration),
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
/// 메일 화면의 알림 — 메일 화면이 있는 동안만 둔다. 세션이 바뀌면 닫는다(되돌리기는 만든 세션에서만 움직인다).
|
||||
final NotifierProvider<MailToastNotifier, MailToast?> mailToastProvider =
|
||||
NotifierProvider.autoDispose<MailToastNotifier, MailToast?>(
|
||||
MailToastNotifier.new,
|
||||
);
|
||||
|
||||
/// 메일 화면의 알림.
|
||||
///
|
||||
/// - 단추가 있는 알림([offer])은 화면 아래 줄 하나 — 새 단추 알림이 앞의 것을 바꾼다. 10초 뒤 닫는다.
|
||||
/// 화면 낭독기를 쓰는 동안은 저절로 닫지 않고, 끄면 그때부터 10초를 센다(Flutter SnackBar 와 같은 결).
|
||||
/// - 단추 없는 알림([notify] — "고를 수 없습니다" · 영구 삭제 결과 등)은 전역 알림 자리에 띄운다 —
|
||||
/// 떠 있는 [되돌리기]를 바꿔 끼우지 않아 되돌릴 길을 잃지 않는다(2026-11-06 리뷰에서 정함).
|
||||
/// - API 실패는 여기 두지 않는다 — 인터셉터의 전역 알림이 띄운다.
|
||||
/// - 단추는 한 번만 돈다(두 번 눌러도 — 웹 `takeAction`).
|
||||
class MailToastNotifier extends Notifier<MailToast?> {
|
||||
Timer? _timer;
|
||||
Future<void> Function()? _action;
|
||||
int _seq = 0;
|
||||
|
||||
/// 화면 낭독기를 쓰는 중 — 단추 알림을 저절로 닫지 않는다
|
||||
bool _holding = false;
|
||||
|
||||
@override
|
||||
MailToast? build() {
|
||||
ref.watch(mailAccountProvider);
|
||||
_cancelTimer();
|
||||
_action = null;
|
||||
_holding = ref.read(accessibleNavigationProvider);
|
||||
ref.listen<bool>(accessibleNavigationProvider, (bool? previous, bool next) {
|
||||
_holding = next;
|
||||
_onHoldChanged();
|
||||
});
|
||||
ref.onDispose(_cancelTimer);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 단추 없는 알림 — 전역 알림 자리에 띄운다
|
||||
void notify(String text) {
|
||||
if (!ref.mounted) return;
|
||||
ref.read(mailNoticeSinkProvider)(text);
|
||||
}
|
||||
|
||||
/// 단추가 있는 알림 — 앞의 단추 알림을 바꾼다
|
||||
void offer(
|
||||
String text, {
|
||||
required String actionLabel,
|
||||
required Future<void> Function() onAction,
|
||||
}) {
|
||||
if (!ref.mounted) return;
|
||||
final int id = ++_seq;
|
||||
_cancelTimer();
|
||||
_action = onAction;
|
||||
state = MailToast(id: id, text: text, actionLabel: actionLabel);
|
||||
if (!_holding) _startTimer(id);
|
||||
}
|
||||
|
||||
/// 닫는다 — [id] 의 알림이 아직 보일 때만
|
||||
void dismiss(int id) {
|
||||
if (!ref.mounted || state?.id != id) return;
|
||||
_cancelTimer();
|
||||
_action = null;
|
||||
state = null;
|
||||
}
|
||||
|
||||
/// 단추 — 알림을 닫고 한 번만 한다
|
||||
void runAction(int id) {
|
||||
if (!ref.mounted || state?.id != id) return;
|
||||
final Future<void> Function()? action = _action;
|
||||
_action = null;
|
||||
dismiss(id);
|
||||
if (action != null) unawaited(action());
|
||||
}
|
||||
|
||||
void _onHoldChanged() {
|
||||
final MailToast? toast = state;
|
||||
if (toast == null) return;
|
||||
if (_holding) {
|
||||
_cancelTimer();
|
||||
} else if (_timer == null) {
|
||||
_startTimer(toast.id);
|
||||
}
|
||||
}
|
||||
|
||||
void _startTimer(int id) {
|
||||
_cancelTimer();
|
||||
_timer = Timer(kMailToastActionDuration, () {
|
||||
_timer = null;
|
||||
dismiss(id);
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:arasystem_app/core/api/api_error_mapper.dart';
|
||||
import 'package:arasystem_app/core/api/api_exception.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 메일함 목록의 상태
|
||||
enum MailboxesStatus { loading, ready, failed }
|
||||
|
||||
/// 메일함 목록 — 서랍 · 목록 제목 · 안 읽은 수 · 옮길 곳이 함께 쓴다
|
||||
@immutable
|
||||
class MailboxesState {
|
||||
const MailboxesState({
|
||||
this.status = MailboxesStatus.loading,
|
||||
this.items = const <Mailbox>[],
|
||||
this.error,
|
||||
});
|
||||
|
||||
final MailboxesStatus status;
|
||||
final List<Mailbox> items;
|
||||
|
||||
/// 처음 받지 못했을 때의 실패 — 이미 받은 목록이 있으면 다시 받기 실패는 여기 두지 않는다
|
||||
final ApiException? error;
|
||||
|
||||
/// 처음부터 받지 못했다 — 받은편지함 목록도 열 수 없다
|
||||
ApiException? get failure => status == MailboxesStatus.failed ? error : null;
|
||||
|
||||
/// 주소 `/mail` 이 여는 메일함 — 받은편지함, 없으면 첫 메일함. 받기 전이면 null
|
||||
String? get defaultMailboxId =>
|
||||
status == MailboxesStatus.ready ? defaultMailboxOf(items)?.id : null;
|
||||
|
||||
/// 서랍 차례
|
||||
List<MailboxNode> get ordered => orderMailboxes(items);
|
||||
|
||||
Mailbox? byId(String? id) {
|
||||
if (id == null) return null;
|
||||
for (final Mailbox mailbox in items) {
|
||||
if (mailbox.id == id) return mailbox;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Mailbox? byRole(MailboxRole role) => findMailboxByRole(items, role);
|
||||
}
|
||||
|
||||
/// 메일함 목록 — 메일 화면이 있는 동안만 둔다(autoDispose). 세션이 바뀌면 처음부터.
|
||||
final NotifierProvider<MailboxesNotifier, MailboxesState> mailboxesProvider =
|
||||
NotifierProvider.autoDispose<MailboxesNotifier, MailboxesState>(
|
||||
MailboxesNotifier.new,
|
||||
);
|
||||
|
||||
/// 메일함 목록 — `GET /api/mailboxes` (계약 2절)
|
||||
///
|
||||
/// - 처음 받지 못하면 목록 화면이 받지 못함 + [다시 시도]를 그린다. 이미 받은 뒤의 다시 받기는 실패해도 조용히 둔다(웹과 같다).
|
||||
/// - 새 메일 확인이 바뀜을 알리면 개수를 다시 맞춘다. 읽음 표시는 개수를 먼저 바꾸고 다음 조회가 서버 기준으로 맞춘다.
|
||||
/// - 늦게 온 응답은 버린다(나중에 보낸 요청의 응답만 쓴다).
|
||||
class MailboxesNotifier extends Notifier<MailboxesState> {
|
||||
int _seq = 0;
|
||||
|
||||
@override
|
||||
MailboxesState build() {
|
||||
final String? account = ref.watch(mailAccountProvider);
|
||||
_seq++;
|
||||
// 새 메일 확인 · 다른 목록의 옮기기가 바뀜을 알리면 개수를 맞춘다 (처음부터 다시 만들 때의 0 은 바뀜이 아니다)
|
||||
ref.listen<MailChange>(mailStateWatcherProvider, (
|
||||
MailChange? previous,
|
||||
MailChange next,
|
||||
) {
|
||||
if (next.seq > (previous?.seq ?? 0)) syncCounts();
|
||||
});
|
||||
if (account == null) return const MailboxesState();
|
||||
unawaited(_load(_seq));
|
||||
return const MailboxesState();
|
||||
}
|
||||
|
||||
/// 다시 받는다 — 처음 받지 못했던 목록은 불러오는 중으로 바꾼다([다시 시도])
|
||||
Future<void> reload() {
|
||||
if (!ref.mounted) return Future<void>.value();
|
||||
_seq++;
|
||||
if (state.status != MailboxesStatus.ready) {
|
||||
state = const MailboxesState();
|
||||
}
|
||||
return _load(_seq);
|
||||
}
|
||||
|
||||
/// 보이는 화면은 그대로 둔 채 개수를 서버 기준으로 다시 맞춘다 — 옮기기 · 삭제 · 새로고침 · 새 메일 확인 뒤
|
||||
void syncCounts() {
|
||||
if (!ref.mounted) return;
|
||||
_seq++;
|
||||
unawaited(_load(_seq));
|
||||
}
|
||||
|
||||
/// 안 읽은 개수를 화면에서 먼저 바꾼다 — 서버 기준은 다음 조회가 맞춘다
|
||||
void adjustUnread(List<String> mailboxIds, int delta) {
|
||||
if (!ref.mounted || delta == 0) return;
|
||||
state = MailboxesState(
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
items: <Mailbox>[
|
||||
for (final Mailbox mailbox in state.items)
|
||||
mailboxIds.contains(mailbox.id)
|
||||
? mailbox.copyWith(
|
||||
unreadCount: (mailbox.unreadCount + delta)
|
||||
.clamp(0, math.max(0, mailbox.totalCount))
|
||||
.toInt(),
|
||||
)
|
||||
: mailbox,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _load(int seq) async {
|
||||
final Ref ref = this.ref;
|
||||
try {
|
||||
final List<Mailbox> items = await ref
|
||||
.read(mailRepositoryProvider)
|
||||
.listMailboxes();
|
||||
if (!ref.mounted || seq != _seq) return;
|
||||
state = MailboxesState(
|
||||
status: MailboxesStatus.ready,
|
||||
items: List<Mailbox>.unmodifiable(items),
|
||||
);
|
||||
} on DioException catch (error) {
|
||||
if (!ref.mounted || seq != _seq) return;
|
||||
// 이미 목록이 보이면 조용히 둔다
|
||||
if (state.status == MailboxesStatus.ready) return;
|
||||
state = MailboxesState(
|
||||
status: MailboxesStatus.failed,
|
||||
error: toApiException(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/theme/app_theme.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// [메일 쓰기] 단추의 그림자 높이 — 보드의 `0 6px 16px` 그림자
|
||||
const double _composeElevation = 6;
|
||||
|
||||
/// 알림 줄의 그림자 높이 — 보드의 `0 10px 28px` 그림자
|
||||
const double _toastElevation = 8;
|
||||
|
||||
/// 화면 아래의 [메일 쓰기] · 알림 줄 자리 — Scaffold 의 넓은 단추 자리(`floatingActionButton`)에 둔다.
|
||||
///
|
||||
/// - 보드 `App1MailList`: [메일 쓰기]는 탭 막대 위 16(아래에서 88), 알림이 뜨면 알림(아래 12 · 좌우 12) 위로 12만큼 함께 올라간다.
|
||||
/// - 넓은 단추 자리에 두는 까닭: Scaffold 는 전역 알림(API 실패 · 단추 없는 알림 스낵바)을 이 자리 **위에** 띄운다 — 단추 · 알림 줄을 가리지 않는다.
|
||||
/// - 고르는 중에는 [메일 쓰기]를 숨긴다(보드 — 탭 막대도 숨는다). 알림 줄은 남는다.
|
||||
/// - 탭 막대가 숨으면(고르는 중 · 자판) 화면 아래가 시스템 내비게이션 자리까지 내려온다 — 안전 영역(아래 · 좌우, [safeInsets])만큼 띄운다.
|
||||
/// Scaffold 는 이 자리에 안전 영역을 넘기지 않으므로(네 방향 모두 지운다) Scaffold 밖에서 읽어 넘겨받는다.
|
||||
/// 탭 막대가 있을 때는 틀이 아래 여백을 이미 뺐다(0).
|
||||
class MailComposeArea extends ConsumerWidget {
|
||||
const MailComposeArea({
|
||||
required this.showCompose,
|
||||
required this.safeInsets,
|
||||
required this.onCompose,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool showCompose;
|
||||
|
||||
/// 화면의 안전 영역(아래 · 좌우) — Scaffold 를 감싼 화면의 `MediaQuery.paddingOf`
|
||||
final EdgeInsets safeInsets;
|
||||
final VoidCallback onCompose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final MailToast? toast = ref.watch(mailToastProvider);
|
||||
if (!showCompose && toast == null) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
safeInsets.left,
|
||||
0,
|
||||
safeInsets.right,
|
||||
safeInsets.bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
if (showCompose)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: AppSpacing.s16,
|
||||
bottom: toast == null ? AppSpacing.s16 : AppSpacing.s12,
|
||||
),
|
||||
child: _ComposeButton(onPressed: onCompose),
|
||||
),
|
||||
if (toast != null)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s12,
|
||||
0,
|
||||
AppSpacing.s12,
|
||||
AppSpacing.s12,
|
||||
),
|
||||
child: MailToastCard(
|
||||
toast: toast,
|
||||
onAction: () =>
|
||||
ref.read(mailToastProvider.notifier).runAction(toast.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ComposeButton extends StatelessWidget {
|
||||
const _ComposeButton({required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
return FloatingActionButton.extended(
|
||||
// 같은 화면에 목록이 둘(받은편지함 위에 다른 메일함) 쌓일 수 있다 — 화면 사이를 나는 그림(Hero)은 쓰지 않는다
|
||||
heroTag: null,
|
||||
onPressed: onPressed,
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
elevation: _composeElevation,
|
||||
highlightElevation: _composeElevation,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.xxl),
|
||||
),
|
||||
extendedPadding: const EdgeInsetsDirectional.only(
|
||||
start: AppSpacing.s16,
|
||||
end: AppSpacing.s20,
|
||||
),
|
||||
extendedIconLabelSpacing: AppSpacing.s10,
|
||||
extendedTextStyle: theme.textTheme.labelLarge,
|
||||
icon: const Icon(Icons.edit_outlined, size: AppSize.iconToolbar),
|
||||
label: const Text(MailListTexts.compose),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 알림 줄 — 짙은 청록 바탕 · 흰 글 · 오른쪽 [되돌리기] (보드 `App1MailList` · 앱 테마의 알림 색과 같다)
|
||||
class MailToastCard extends StatelessWidget {
|
||||
const MailToastCard({required this.toast, required this.onAction, super.key});
|
||||
|
||||
final MailToast toast;
|
||||
final VoidCallback onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
return Semantics(
|
||||
container: true,
|
||||
liveRegion: true,
|
||||
child: Material(
|
||||
color: scheme.inverseSurface,
|
||||
elevation: _toastElevation,
|
||||
shadowColor: scheme.shadow,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: AppSize.toastMinHeight),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s2,
|
||||
AppSpacing.s4,
|
||||
AppSpacing.s2,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: AppSpacing.s10,
|
||||
),
|
||||
child: Text(
|
||||
toast.text,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: extra.onBrandLink,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.s12,
|
||||
),
|
||||
),
|
||||
onPressed: onAction,
|
||||
child: Text(toast.actionLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [메일 쓰기] · 알림 줄 자리를 화면 오른쪽 아래에 붙인다 — 여백(안전 영역 포함)은 자리 안([MailComposeArea])이 정한다.
|
||||
/// (Flutter 의 기본 자리는 단추 하나를 위한 여백 · 안전 영역 셈을 하므로 쓰지 않는다)
|
||||
///
|
||||
/// 알림 줄이 있으면 자리가 화면 폭을 다 쓰므로 왼쪽 끝에 붙는다. Scaffold 는 전역 알림을 이 자리 위에 띄운다.
|
||||
class MailComposeAreaLocation extends FloatingActionButtonLocation {
|
||||
const MailComposeAreaLocation();
|
||||
|
||||
@override
|
||||
Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) {
|
||||
final Size area = scaffoldGeometry.floatingActionButtonSize;
|
||||
final double x = math.max(
|
||||
0,
|
||||
scaffoldGeometry.scaffoldSize.width - area.width,
|
||||
);
|
||||
return Offset(x, scaffoldGeometry.contentBottom - area.height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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:flutter/material.dart';
|
||||
|
||||
/// 목록의 위 막대 — 서랍 열기 · 메일함 이름(아래에 안 읽은 수) · 찾기 (보드 `App1MailList` 「받은편지함」, 높이 64)
|
||||
class MailListAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const MailListAppBar({
|
||||
required this.title,
|
||||
required this.onOpenDrawer,
|
||||
required this.onSearch,
|
||||
this.subtitle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
/// 제목 아래 작은 글 — "안 읽은 메일 3통". 보낸편지함 · 임시보관함 · 휴지통은 없다
|
||||
final String? subtitle;
|
||||
final VoidCallback onOpenDrawer;
|
||||
final VoidCallback onSearch;
|
||||
|
||||
@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? sub = subtitle;
|
||||
return AppBar(
|
||||
toolbarHeight: AppSize.appBarHeight,
|
||||
leadingWidth: AppSize.touchMin + AppSpacing.s8,
|
||||
titleSpacing: AppSpacing.s4,
|
||||
shape: Border(
|
||||
bottom: BorderSide(
|
||||
color: extra.borderSubtle,
|
||||
width: AppSize.borderWidth,
|
||||
),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.menu, size: AppSize.iconLarge),
|
||||
tooltip: MailListTexts.openDrawer,
|
||||
color: extra.textStrong,
|
||||
onPressed: onOpenDrawer,
|
||||
),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Semantics(
|
||||
header: true,
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: AppTypography.bold,
|
||||
height: AppTypography.lineHeightTight,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (sub != null)
|
||||
Text(
|
||||
sub,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: AppTypography.regular,
|
||||
color: extra.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actionsPadding: const EdgeInsets.only(right: AppSpacing.s4),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search, size: AppSize.iconLarge),
|
||||
tooltip: MailListTexts.search,
|
||||
color: extra.textStrong,
|
||||
onPressed: onSearch,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 여러 통 고르기 막대 — 선택 해제 · "N통 선택됨" · 읽음 표시 · 중요 표시 · 이동 · 삭제
|
||||
/// (보드 `App1MailList` 「길게 눌러 고르기」 — 바탕 옅은 청록, 화면 낭독기 이름 "고른 메일")
|
||||
class MailSelectionBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const MailSelectionBar({
|
||||
required this.count,
|
||||
required this.allRead,
|
||||
required this.allFlagged,
|
||||
required this.inTrash,
|
||||
required this.busy,
|
||||
required this.onClear,
|
||||
required this.onToggleRead,
|
||||
required this.onToggleFlag,
|
||||
required this.onMove,
|
||||
required this.onDelete,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final int count;
|
||||
|
||||
/// 고른 것이 모두 읽음 — [안 읽음 표시]
|
||||
final bool allRead;
|
||||
|
||||
/// 고른 것이 모두 중요 — [중요 해제]
|
||||
final bool allFlagged;
|
||||
|
||||
/// 휴지통 — [삭제]가 빨간 [영구 삭제]
|
||||
final bool inTrash;
|
||||
|
||||
/// 옮기기 · 지우기를 하는 중 — 이동 · 삭제를 다시 누르지 못한다
|
||||
final bool busy;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback onToggleRead;
|
||||
final VoidCallback onToggleFlag;
|
||||
final VoidCallback onMove;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(AppSize.appBarHeight);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final Color toolColor = scheme.onPrimaryContainer;
|
||||
return Semantics(
|
||||
container: true,
|
||||
label: MailBulkTexts.toolbar,
|
||||
explicitChildNodes: true,
|
||||
child: AppBar(
|
||||
toolbarHeight: AppSize.appBarHeight,
|
||||
backgroundColor: scheme.secondaryContainer,
|
||||
leadingWidth: AppSize.touchMin + AppSpacing.s8,
|
||||
titleSpacing: AppSpacing.s4,
|
||||
shape: Border(
|
||||
bottom: BorderSide(
|
||||
color: extra.brandTintBorder,
|
||||
width: AppSize.borderWidth,
|
||||
),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close, size: AppSize.iconLarge),
|
||||
tooltip: MailBulkTexts.clear,
|
||||
color: toolColor,
|
||||
onPressed: onClear,
|
||||
),
|
||||
title: Semantics(
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
MailBulkTexts.selected(count),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: AppTypography.bold,
|
||||
color: toolColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.only(right: AppSpacing.s4),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
allRead
|
||||
? Icons.mark_email_unread_outlined
|
||||
: Icons.drafts_outlined,
|
||||
size: AppSize.iconToolbar,
|
||||
),
|
||||
tooltip: allRead
|
||||
? MailBulkTexts.markUnread
|
||||
: MailBulkTexts.markRead,
|
||||
color: toolColor,
|
||||
onPressed: onToggleRead,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
allFlagged ? Icons.star : Icons.star_border,
|
||||
size: AppSize.iconToolbar,
|
||||
),
|
||||
tooltip: allFlagged ? MailBulkTexts.unflag : MailBulkTexts.flag,
|
||||
color: toolColor,
|
||||
onPressed: onToggleFlag,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.drive_file_move_outlined,
|
||||
size: AppSize.iconToolbar,
|
||||
),
|
||||
tooltip: MailBulkTexts.move,
|
||||
color: toolColor,
|
||||
onPressed: busy ? null : onMove,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
inTrash ? Icons.delete_forever_outlined : Icons.delete_outline,
|
||||
size: AppSize.iconToolbar,
|
||||
),
|
||||
tooltip: inTrash ? MailBulkTexts.purge : MailBulkTexts.delete,
|
||||
color: inTrash ? scheme.error : toolColor,
|
||||
onPressed: busy ? null : onDelete,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
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/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:flutter/material.dart';
|
||||
|
||||
/// 문의 코드로 보이는 추적 ID 앞자리 수 — 웹 `TRACE_CODE_LENGTH` 와 같다
|
||||
const int kTraceCodeLength = 8;
|
||||
|
||||
/// 처음 불러오는 동안의 뼈대 줄 수 — 휴대폰 한 화면쯤
|
||||
const int _skeletonRows = 8;
|
||||
|
||||
/// 빈 메일함 · 받지 못함의 아래 여백 — 안내가 화면 가운데보다 조금 위에 오게(보드의 `padding-bottom: 120px`)
|
||||
const double _stateBottomSpace = 120;
|
||||
|
||||
/// 뼈대 줄의 막대 폭 — 보낸 사람 · 제목(미리 보기 막대는 줄 폭을 다 쓴다)
|
||||
const double _skeletonWhoWidth = 0.4;
|
||||
const double _skeletonSubjectWidth = 0.8;
|
||||
|
||||
/// 끝에서 도는 표시의 선 두께 — 작은 원(24)에 맞춘다
|
||||
const double _footerSpinnerStroke = 2.5;
|
||||
|
||||
/// 빈 메일함 — 아이콘 원 · "휴지통이 비어 있습니다" · 메일함마다 안내(휴지통 · 스팸메일함은 30일 안내가 붙는다)
|
||||
/// (보드 `App1MailList` 「빈 메일함 (휴지통)」)
|
||||
class MailListEmpty extends StatelessWidget {
|
||||
const MailListEmpty({
|
||||
required this.mailboxName,
|
||||
required this.role,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String mailboxName;
|
||||
final MailboxRole? role;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
return _StateLayout(
|
||||
icon: mailboxIconOf(role),
|
||||
iconColor: extra.textMuted,
|
||||
iconBackground: theme.colorScheme.surfaceContainerHigh,
|
||||
title: MailListTexts.emptyTitle(mailboxName),
|
||||
children: <Widget>[
|
||||
KeepAllText(
|
||||
MailListTexts.emptyHint(role),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: extra.textMuted,
|
||||
height: AppTypography.lineHeightRelaxed,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일함은 [받은편지함으로] — 다시 해도 같다)
|
||||
/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다)
|
||||
class MailListError extends StatelessWidget {
|
||||
const MailListError({
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
this.onLeave,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ApiException error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
/// 없는 메일함(`RES_001`)일 때 갈 곳 — 없으면 늘 [다시 시도]
|
||||
final VoidCallback? onLeave;
|
||||
|
||||
@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 _StateLayout(
|
||||
icon: Icons.error_outline,
|
||||
iconColor: theme.colorScheme.error,
|
||||
iconBackground: theme.colorScheme.errorContainer,
|
||||
title: error.message,
|
||||
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 ? MailListTexts.toInbox : 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;
|
||||
}
|
||||
|
||||
class _StateLayout extends StatelessWidget {
|
||||
const _StateLayout({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.iconBackground,
|
||||
required this.title,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final Color iconBackground;
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
@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: 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({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color bar = Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
return Semantics(
|
||||
label: MailListTexts.loading,
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 휴지통 · 스팸메일함 목록 맨 위의 30일 줄 — 회색 바탕 · 시계 아이콘(웹 13단계 — 늘 보인다, 닫기 없음)
|
||||
class MailRetentionNotice extends StatelessWidget {
|
||||
const MailRetentionNotice(this.text, {super.key});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: extra.borderSubtle,
|
||||
width: AppSize.borderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.s16,
|
||||
vertical: AppSpacing.s8,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: AppSize.iconTiny,
|
||||
color: extra.textMuted,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s8),
|
||||
Expanded(
|
||||
child: KeepAllText(
|
||||
text,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: AppTypography.regular,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 목록 끝 — 다음 쪽을 받는 동안 도는 표시, 받지 못했으면 [다시 시도].
|
||||
/// 화면에 들어오면(목록이 끝 근처를 그리면) 그린 뒤 [onReached] 를 부르고, 끝 표시가 남아 있는 동안 다시 그릴 때마다 또 부른다
|
||||
/// (받은 쪽이 모두 이미 있던 메일이라 줄이 늘지 않음 · 새로고침이 앞 요청을 버림). 받는 중 · 더 없음은 부르는 쪽이 거른다.
|
||||
/// 받지 못한 동안은 부르지 않는다(저절로 다시 하지 않는다 — 같은 커서가 되풀이돼도 [다시 시도]로 멈춘다)
|
||||
class MailListFooter extends StatefulWidget {
|
||||
const MailListFooter({
|
||||
required this.loading,
|
||||
required this.failed,
|
||||
required this.onReached,
|
||||
required this.onRetry,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool loading;
|
||||
final bool failed;
|
||||
final VoidCallback onReached;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
State<MailListFooter> createState() => _MailListFooterState();
|
||||
}
|
||||
|
||||
class _MailListFooterState extends State<MailListFooter> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_reachAfterFrame();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MailListFooter oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 받는 중이던 표시가 끝난 순간을 기다리지 않는다 — 응답이 한 프레임 안에 오면 그 순간을 보지 못한다
|
||||
_reachAfterFrame();
|
||||
}
|
||||
|
||||
void _reachAfterFrame() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((Duration _) {
|
||||
if (mounted && !widget.loading && !widget.failed) widget.onReached();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: AppSize.touchMin + AppSpacing.s16,
|
||||
child: Center(
|
||||
child: widget.failed
|
||||
? TextButton(
|
||||
onPressed: widget.onRetry,
|
||||
child: const Text(MailListTexts.retry),
|
||||
)
|
||||
: Semantics(
|
||||
label: MailListTexts.loadingMore,
|
||||
child: const SizedBox.square(
|
||||
dimension: AppSize.iconLarge,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: _footerSpinnerStroke,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/theme/app_typography.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 옮길 메일함을 고르는 시트 — 아래에서 올라온다(보드에 없다 — 서랍과 같은 줄 모양).
|
||||
///
|
||||
/// [targets] 는 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함이다(`moveTargetsOf` — 웹과 같다). 고르면 그 메일함, 닫으면 null.
|
||||
Future<Mailbox?> showMailboxPickerSheet(
|
||||
BuildContext context, {
|
||||
required List<MailboxNode> targets,
|
||||
}) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
return showModalBottomSheet<Mailbox>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
||||
),
|
||||
builder: (BuildContext sheetContext) => _MailboxPickerSheet(targets),
|
||||
);
|
||||
}
|
||||
|
||||
class _MailboxPickerSheet extends StatelessWidget {
|
||||
const _MailboxPickerSheet(this.targets);
|
||||
|
||||
final List<MailboxNode> targets;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s28,
|
||||
0,
|
||||
AppSpacing.s28,
|
||||
AppSpacing.s8,
|
||||
),
|
||||
child: Semantics(
|
||||
header: true,
|
||||
child: Text(
|
||||
MailBulkTexts.moveSheetTitle,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: AppTypography.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s12,
|
||||
AppSpacing.s4,
|
||||
AppSpacing.s12,
|
||||
AppSpacing.s16,
|
||||
),
|
||||
children: <Widget>[
|
||||
for (final MailboxNode node in targets)
|
||||
MailboxTile(
|
||||
node: node,
|
||||
selected: false,
|
||||
onTap: () => Navigator.of(context).pop(node.mailbox),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
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/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_icons.dart';
|
||||
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 서랍이 미끄러져 들어오는 시간
|
||||
const Duration _drawerDuration = Duration(milliseconds: 250);
|
||||
|
||||
/// 하위 메일함 들여쓰기는 3단계까지 (그 아래는 같은 깊이 — 웹과 같다)
|
||||
const int _maxIndentDepth = 3;
|
||||
|
||||
/// 서랍의 그림자 높이 — 보드의 `0 14px 36px` 그림자에 가깝게(Material 모달 서랍)
|
||||
const double _drawerElevation = 16;
|
||||
|
||||
/// 메일함 서랍을 연다 — 탭 막대 위까지 화면 전체를 덮는다(앱 보드 `App1MailList` 「메일함 서랍」 · 앱 디자인 README).
|
||||
///
|
||||
/// 메일함을 고르면 그 메일함을 돌려준다(부르는 쪽이 그 목록으로 간다). 바깥을 누르거나 뒤로 가기면 null.
|
||||
Future<Mailbox?> showMailboxDrawer(
|
||||
BuildContext context, {
|
||||
required String? currentMailboxId,
|
||||
}) {
|
||||
return showGeneralDialog<Mailbox>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: MailDrawerTexts.close,
|
||||
barrierColor: Theme.of(context).colorScheme.scrim,
|
||||
transitionDuration: _drawerDuration,
|
||||
pageBuilder:
|
||||
(
|
||||
BuildContext dialogContext,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) => Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: MailboxDrawer(currentMailboxId: currentMailboxId),
|
||||
),
|
||||
transitionBuilder:
|
||||
(
|
||||
BuildContext dialogContext,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) => SlideTransition(
|
||||
position: Tween<Offset>(begin: const Offset(-1, 0), end: Offset.zero)
|
||||
.animate(
|
||||
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 메일함 서랍 — 기본 메일함 · 내 메일함(하위는 들여 쓴다). 안 읽은 수는 회색 숫자(뱃지 없음)
|
||||
class MailboxDrawer extends ConsumerWidget {
|
||||
const MailboxDrawer({required this.currentMailboxId, super.key});
|
||||
|
||||
/// 지금 보는 메일함 — 청록 바탕
|
||||
final String? currentMailboxId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final MailboxesState mailboxes = ref.watch(mailboxesProvider);
|
||||
final double width = math.min(
|
||||
AppSize.drawerWidth,
|
||||
MediaQuery.sizeOf(context).width - AppSize.drawerMinGap,
|
||||
);
|
||||
return Semantics(
|
||||
container: true,
|
||||
scopesRoute: true,
|
||||
namesRoute: true,
|
||||
explicitChildNodes: true,
|
||||
label: MailDrawerTexts.title,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surface,
|
||||
elevation: _drawerElevation,
|
||||
shadowColor: theme.colorScheme.shadow,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
right: Radius.circular(AppRadius.xxl),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: double.infinity,
|
||||
child: SafeArea(
|
||||
right: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s28,
|
||||
AppSpacing.s24,
|
||||
AppSpacing.s28,
|
||||
AppSpacing.s8,
|
||||
),
|
||||
child: Semantics(
|
||||
header: true,
|
||||
child: Text(
|
||||
MailDrawerTexts.title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: AppTypography.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _DrawerBody(
|
||||
mailboxes: mailboxes,
|
||||
currentMailboxId: currentMailboxId,
|
||||
onRetry: () =>
|
||||
ref.read(mailboxesProvider.notifier).reload(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DrawerBody extends StatelessWidget {
|
||||
const _DrawerBody({
|
||||
required this.mailboxes,
|
||||
required this.currentMailboxId,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final MailboxesState mailboxes;
|
||||
final String? currentMailboxId;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final ApiException? failure = mailboxes.failure;
|
||||
if (failure != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.s24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
KeepAllText(
|
||||
failure.message,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: extra.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s8),
|
||||
TextButton(
|
||||
onPressed: onRetry,
|
||||
child: const Text(MailListTexts.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (mailboxes.status != MailboxesStatus.ready) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final ({List<MailboxNode> system, List<MailboxNode> mine}) groups =
|
||||
splitMailboxGroups(mailboxes.ordered);
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s12,
|
||||
AppSpacing.s4,
|
||||
AppSpacing.s12,
|
||||
AppSpacing.s12,
|
||||
),
|
||||
children: <Widget>[
|
||||
for (final MailboxNode node in groups.system)
|
||||
MailboxTile(
|
||||
node: node,
|
||||
selected: node.mailbox.id == currentMailboxId,
|
||||
showUnread: true,
|
||||
onTap: () => Navigator.of(context).pop(node.mailbox),
|
||||
),
|
||||
if (groups.mine.isNotEmpty) ...<Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: AppSpacing.s8,
|
||||
horizontal: AppSpacing.s4,
|
||||
),
|
||||
child: Divider(color: extra.borderSubtle),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s4,
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s2,
|
||||
),
|
||||
child: Semantics(
|
||||
header: true,
|
||||
child: Text(
|
||||
MailDrawerTexts.myMailboxes,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
color: extra.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final MailboxNode node in groups.mine)
|
||||
MailboxTile(
|
||||
node: node,
|
||||
selected: node.mailbox.id == currentMailboxId,
|
||||
showUnread: true,
|
||||
onTap: () => Navigator.of(context).pop(node.mailbox),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 메일함 한 줄 — 알약 모양(높이 48 이상) · 아이콘 · 이름 · 안 읽은 수(회색 숫자). 서랍 · 옮길 곳 시트가 함께 쓴다
|
||||
class MailboxTile extends StatelessWidget {
|
||||
const MailboxTile({
|
||||
required this.node,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.showUnread = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final MailboxNode node;
|
||||
|
||||
/// 지금 보는 메일함 — 청록 바탕 · 굵게
|
||||
final bool selected;
|
||||
|
||||
/// 안 읽은 수를 보인다 (서랍만)
|
||||
final bool showUnread;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final Mailbox mailbox = node.mailbox;
|
||||
final Color foreground = selected
|
||||
? scheme.onPrimaryContainer
|
||||
: extra.textStrong;
|
||||
final int unread = mailbox.unreadCount;
|
||||
final double indent =
|
||||
math.min(node.depth, _maxIndentDepth) * AppSpacing.s16;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.s2),
|
||||
child: Semantics(
|
||||
selected: selected,
|
||||
child: Material(
|
||||
color: selected ? scheme.primaryContainer : Colors.transparent,
|
||||
shape: const StadiumBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: AppSize.touchMin),
|
||||
child: Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: AppSpacing.s16 + indent,
|
||||
end: AppSpacing.s16,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
mailboxIconOf(mailbox.role),
|
||||
size: AppSize.iconMedium,
|
||||
color: foreground,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
mailbox.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: selected
|
||||
? AppTypography.semiBold
|
||||
: AppTypography.medium,
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showUnread && unread > 0)
|
||||
Text(
|
||||
formatCount(unread),
|
||||
semanticsLabel: MailDrawerTexts.unreadCount(unread),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: selected ? foreground : extra.textMuted,
|
||||
fontFeatures: const <FontFeature>[
|
||||
FontFeature.tabularFigures(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 메일함 아이콘 — 웹 `MAILBOX_ICONS`(lucide)에 맞춘 Material 아이콘 (서랍 · 옮길 곳 · 빈 메일함)
|
||||
IconData mailboxIconOf(MailboxRole? role) => switch (role) {
|
||||
MailboxRole.inbox => Icons.inbox_outlined,
|
||||
MailboxRole.sent => Icons.send_outlined,
|
||||
MailboxRole.drafts => Icons.description_outlined,
|
||||
MailboxRole.junk => Icons.report_outlined,
|
||||
MailboxRole.trash => Icons.delete_outline,
|
||||
MailboxRole.archive => Icons.archive_outlined,
|
||||
null => Icons.folder_outlined,
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
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_row_view.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 목록 줄 하나 — 보드 `App1MailList` 의 줄: 동그란 사진(40) · 보낸 사람 · 시각 / 제목 · 첨부 · 중요 / 미리 보기 한 줄.
|
||||
///
|
||||
/// - 안 읽음은 굵은 글자로만 가른다(뱃지 없음 — 화면 낭독기는 앞에 "안 읽음").
|
||||
/// - 사진을 누르면 고르기 · 풀기, 줄을 길게 누르면 고르기. 고르는 중에는 줄을 눌러도 고르기 · 풀기다(열지 않는다).
|
||||
/// - 아직 보내지 않은 메일은 고를 수 없다 — 사진 단추는 꺼진 것으로 읽히고, 눌러도 까닭만 알린다(부르는 쪽이).
|
||||
class MessageRow extends StatelessWidget {
|
||||
const MessageRow({
|
||||
required this.view,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
required this.onAvatarTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final MailRowView view;
|
||||
|
||||
/// 고른 줄 — 바탕이 옅은 청록이고 사진 자리에 체크
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
final VoidCallback onAvatarTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
return Semantics(
|
||||
container: true,
|
||||
selected: selected,
|
||||
label: view.semanticsLabel,
|
||||
child: Material(
|
||||
color: selected ? extra.rowChecked : theme.colorScheme.surface,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
width: AppSize.borderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s8,
|
||||
AppSpacing.s10,
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s10,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
MailAvatarButton(
|
||||
initial: view.initial,
|
||||
selected: selected,
|
||||
held: view.held,
|
||||
label: view.selectLabel,
|
||||
onTap: onAvatarTap,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s6),
|
||||
Expanded(child: ExcludeSemantics(child: _RowTexts(view))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 동그란 사진 — 누르는 곳 48 안의 40 원(머리글자 · 고르면 청록 원에 체크).
|
||||
/// 화면 낭독기는 "고르기: 제목" 체크 칸으로 읽는다. 아직 보내지 않은 메일이면 꺼진 칸이고 설명에 까닭을 단다
|
||||
class MailAvatarButton extends StatelessWidget {
|
||||
const MailAvatarButton({
|
||||
required this.initial,
|
||||
required this.selected,
|
||||
required this.held,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String? initial;
|
||||
final bool selected;
|
||||
final bool held;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
checked: selected,
|
||||
enabled: !held,
|
||||
label: label,
|
||||
hint: held ? MailListTexts.heldNotSelectable : null,
|
||||
onTap: onTap,
|
||||
child: InkResponse(
|
||||
onTap: onTap,
|
||||
excludeFromSemantics: true,
|
||||
radius: AppSize.touchMin / 2,
|
||||
customBorder: const CircleBorder(),
|
||||
child: SizedBox.square(
|
||||
dimension: AppSize.touchMin,
|
||||
child: Center(
|
||||
child: ExcludeSemantics(
|
||||
child: _AvatarFace(initial: initial, selected: selected),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AvatarFace extends StatelessWidget {
|
||||
const _AvatarFace({required this.initial, required this.selected});
|
||||
|
||||
final String? initial;
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final String? letter = initial;
|
||||
final Widget face;
|
||||
if (selected) {
|
||||
face = Icon(
|
||||
Icons.check,
|
||||
size: AppSize.iconMedium,
|
||||
color: scheme.onPrimary,
|
||||
);
|
||||
} else if (letter != null) {
|
||||
face = Text(
|
||||
letter,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
color: scheme.onPrimaryContainer,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
face = Icon(
|
||||
Icons.person_outline,
|
||||
size: AppSize.iconMedium,
|
||||
color: scheme.onPrimaryContainer,
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
width: AppSize.avatar,
|
||||
height: AppSize.avatar,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: selected ? scheme.primary : scheme.primaryContainer,
|
||||
),
|
||||
child: face,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 줄의 글 세 줄 — 보낸 사람 · 시각 / 제목 · 첨부 · 중요 / 미리 보기
|
||||
class _RowTexts extends StatelessWidget {
|
||||
const _RowTexts(this.view);
|
||||
|
||||
final MailRowView view;
|
||||
|
||||
static const List<FontFeature> _tabular = <FontFeature>[
|
||||
FontFeature.tabularFigures(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final TextTheme text = theme.textTheme;
|
||||
final bool unread = view.unread;
|
||||
|
||||
final TextStyle? whoStyle = text.bodyMedium?.copyWith(
|
||||
fontWeight: unread ? AppTypography.bold : AppTypography.medium,
|
||||
color: view.whoPlaceholder ? extra.textMuted : scheme.onSurface,
|
||||
);
|
||||
final TextStyle? timeStyle = text.labelSmall?.copyWith(
|
||||
fontWeight: unread ? AppTypography.semiBold : AppTypography.regular,
|
||||
color: view.timePending
|
||||
? scheme.primary
|
||||
: (unread ? scheme.onSurface : extra.textMuted),
|
||||
fontFeatures: _tabular,
|
||||
);
|
||||
final TextStyle? subjectStyle = text.bodyMedium?.copyWith(
|
||||
fontWeight: unread ? AppTypography.semiBold : AppTypography.regular,
|
||||
color: view.subjectPlaceholder
|
||||
? extra.textMuted
|
||||
: (unread ? scheme.onSurface : extra.textStrong),
|
||||
);
|
||||
final TextStyle? previewStyle = text.bodySmall?.copyWith(
|
||||
color: extra.textMuted,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
view.who,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: whoStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s8),
|
||||
Text(view.time, maxLines: 1, style: timeStyle),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
view.subject,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: subjectStyle,
|
||||
),
|
||||
),
|
||||
if (view.hasAttachment)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: AppSpacing.s6),
|
||||
child: Icon(
|
||||
Icons.attach_file,
|
||||
size: AppSize.iconTiny,
|
||||
color: extra.textMuted,
|
||||
semanticLabel: MailListTexts.attachment,
|
||||
),
|
||||
),
|
||||
if (view.flagged)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: AppSpacing.s6),
|
||||
child: Icon(
|
||||
Icons.star,
|
||||
size: AppSize.iconTiny,
|
||||
color: extra.star,
|
||||
semanticLabel: MailListTexts.flagged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
view.preview,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: previewStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/theme/app_typography.dart';
|
||||
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 확인 창 — 앱 보드 `App1Shell` 의 [로그아웃] 확인과 같은 모양(모서리 16 · 제목 굵게 · 오른쪽 아래에 [취소] · 주 단추).
|
||||
///
|
||||
/// 되돌릴 수 없는 일의 확인이라 **처음 초점은 [취소]** 다(웹 대화상자 규칙). [danger] 면 주 단추가 빨간 채움이다.
|
||||
/// [확인]을 누르면 참, [취소] · 뒤로 가기 · 바깥 누르기는 거짓.
|
||||
Future<bool> showConfirmDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
required String confirmLabel,
|
||||
required String cancelLabel,
|
||||
bool danger = false,
|
||||
}) async {
|
||||
final bool? confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) => _ConfirmDialog(
|
||||
title: title,
|
||||
message: message,
|
||||
confirmLabel: confirmLabel,
|
||||
cancelLabel: cancelLabel,
|
||||
danger: danger,
|
||||
),
|
||||
);
|
||||
return confirmed ?? false;
|
||||
}
|
||||
|
||||
class _ConfirmDialog extends StatelessWidget {
|
||||
const _ConfirmDialog({
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.confirmLabel,
|
||||
required this.cancelLabel,
|
||||
required this.danger,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String message;
|
||||
final String confirmLabel;
|
||||
final String cancelLabel;
|
||||
final bool danger;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final RoundedRectangleBorder buttonShape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
);
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.xxl),
|
||||
),
|
||||
backgroundColor: scheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
titlePadding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s24,
|
||||
AppSpacing.s24,
|
||||
AppSpacing.s24,
|
||||
0,
|
||||
),
|
||||
contentPadding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s24,
|
||||
AppSpacing.s12,
|
||||
AppSpacing.s24,
|
||||
0,
|
||||
),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s24,
|
||||
AppSpacing.s20,
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s12,
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: AppTypography.bold,
|
||||
),
|
||||
),
|
||||
content: KeepAllText(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
height: AppTypography.lineHeightRelaxed,
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: scheme.onPrimaryContainer,
|
||||
shape: buttonShape,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(cancelLabel),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: danger ? scheme.error : scheme.primary,
|
||||
foregroundColor: danger ? scheme.onError : scheme.onPrimary,
|
||||
shape: buttonShape,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(confirmLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -12,6 +12,7 @@ import 'package:arasystem_app/core/utils/provider_retry.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart';
|
||||
import 'package:arasystem_app/features/mail/mail_routes.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart';
|
||||
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -22,6 +23,11 @@ import 'support/fakes.dart';
|
||||
import 'support/login_fakes.dart';
|
||||
import 'support/provider_harness.dart';
|
||||
|
||||
/// 메일 목록 화면 — [mailboxId] 가 null 이면 받은편지함(주소 `/mail`)
|
||||
Finder mailList(String? mailboxId) => find.byWidgetPredicate(
|
||||
(Widget widget) => widget is MailListPage && widget.mailboxId == mailboxId,
|
||||
);
|
||||
|
||||
void main() {
|
||||
late ProviderHarness h;
|
||||
|
||||
@@ -90,13 +96,7 @@ void main() {
|
||||
|
||||
await pumpApp(tester);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(ListView),
|
||||
matching: find.text(MailTexts.home),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(mailList(null), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ void main() {
|
||||
final GoRouter router = await pumpSignedIn(tester);
|
||||
router.go(MailRoutes.mailbox('archive'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(page(MailTexts.mailbox), findsOneWidget);
|
||||
expect(mailList('archive'), findsOneWidget);
|
||||
|
||||
await tester.tap(tab('설정'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -162,7 +162,7 @@ void main() {
|
||||
|
||||
await tester.tap(tab('메일'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(page(MailTexts.mailbox), findsOneWidget);
|
||||
expect(mailList('archive'), findsOneWidget);
|
||||
expect(
|
||||
router.routerDelegate.currentConfiguration.uri.path,
|
||||
MailRoutes.mailbox('archive'),
|
||||
@@ -171,7 +171,8 @@ void main() {
|
||||
// 지금 탭을 다시 누르면 그 모듈의 첫 화면으로
|
||||
await tester.tap(tab('메일'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(page(MailTexts.home), findsOneWidget);
|
||||
expect(mailList(null), findsOneWidget);
|
||||
expect(mailList('archive'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('읽기 · 쓰기는 탭 막대 없이 전체 화면으로 연다', (WidgetTester tester) async {
|
||||
@@ -204,7 +205,7 @@ void main() {
|
||||
MailRoutes.home,
|
||||
reason: location,
|
||||
);
|
||||
expect(page(MailTexts.home), findsOneWidget, reason: location);
|
||||
expect(mailList(null), findsOneWidget, reason: location);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:arasystem_app/app.dart';
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/constants/app_constants.dart';
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:arasystem_app/core/router/app_router.dart';
|
||||
import 'package:arasystem_app/core/router/shell_bar.dart';
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/utils/provider_retry.dart';
|
||||
import 'package:arasystem_app/features/mail/mail_routes.dart';
|
||||
import 'package:arasystem_app/features/settings/settings_routes.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/provider_harness.dart';
|
||||
|
||||
/// 휴대폰 화면 크기 (보드의 390 × 844)
|
||||
const Size _phone = Size(390, 844);
|
||||
|
||||
/// 로그인 뒤 화면의 틀 — 아래 탭 막대 (앱 보드 `App1Shell`)
|
||||
void main() {
|
||||
late ProviderHarness h;
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() => h = ProviderHarness());
|
||||
|
||||
GoRouter router() => container.read(routerProvider);
|
||||
|
||||
/// 되살린 세션으로 앱을 띄운다 — 첫 화면은 메일 탭(메일 API 는 없는 경로라 메일 화면은 받지 못함을 그린다)
|
||||
Future<void> pumpSignedIn(WidgetTester tester) async {
|
||||
tester.view.physicalSize = _phone * 3;
|
||||
tester.view.devicePixelRatio = 3;
|
||||
addTearDown(tester.view.reset);
|
||||
await h.prepare(refreshToken: 'refresh-1');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
...sessionJson(),
|
||||
'tokens': tokensJson(2, now: h.clock.now),
|
||||
}),
|
||||
);
|
||||
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
retry: noAutomaticRetry,
|
||||
overrides: h.overrides,
|
||||
child: const App(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(MaterialApp)),
|
||||
);
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
}
|
||||
|
||||
testWidgets('탭 막대는 위 안전 영역(상태 표시줄)만큼 커지지 않는다 — 아래 안전 영역만 더한다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// 위 24 · 아래 16 (물리 픽셀은 3배)
|
||||
tester.view.padding = const FakeViewPadding(top: 72, bottom: 48);
|
||||
await pumpSignedIn(tester);
|
||||
|
||||
final Rect bar = tester.getRect(find.byType(NavigationBar));
|
||||
expect(bar.height, moreOrLessEquals(AppSize.navigationBarHeight + 16));
|
||||
expect(bar.bottom, moreOrLessEquals(_phone.height));
|
||||
});
|
||||
|
||||
testWidgets('탭 막대의 화면 낭독기 이름은 "앱 메뉴"', (WidgetTester tester) async {
|
||||
await pumpSignedIn(tester);
|
||||
|
||||
expect(find.bySemanticsLabel(kAppMenuLabel), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('자판이 열리면 탭 막대를 뺀다(화면이 자판 위까지 줄어든다)', (WidgetTester tester) async {
|
||||
await pumpSignedIn(tester);
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 900);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
|
||||
tester.view.resetViewInsets();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('화면이 숨긴 탭 막대는 그 모듈의 탭에서만 빠진다 — 다른 탭에서는 보인다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await pumpSignedIn(tester);
|
||||
final ShellBarHiding hiding = container.read(
|
||||
shellBarHidingProvider.notifier,
|
||||
);
|
||||
|
||||
hiding.hide(AppModuleId.mail);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
|
||||
router().go(SettingsRoutes.home);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
|
||||
router().go(MailRoutes.home);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
|
||||
hiding.reveal(AppModuleId.mail);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:arasystem_app/core/router/shell_bar.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 아래 탭 막대 숨기기 — 화면마다 숨기고 돌려준다(모듈마다 센다)
|
||||
void main() {
|
||||
test('숨긴 화면이 모두 돌려줄 때까지 그 모듈의 탭에서 숨긴다 — 다른 모듈은 그대로', () {
|
||||
final ProviderContainer container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
final ShellBarHiding hiding = container.read(
|
||||
shellBarHidingProvider.notifier,
|
||||
);
|
||||
Map<AppModuleId, int> state() => container.read(shellBarHidingProvider);
|
||||
|
||||
hiding
|
||||
..hide(AppModuleId.mail)
|
||||
..hide(AppModuleId.mail);
|
||||
expect(isShellBarHidden(state(), AppModuleId.mail), isTrue);
|
||||
expect(isShellBarHidden(state(), AppModuleId.settings), isFalse);
|
||||
|
||||
hiding.reveal(AppModuleId.mail);
|
||||
expect(isShellBarHidden(state(), AppModuleId.mail), isTrue);
|
||||
hiding.reveal(AppModuleId.mail);
|
||||
expect(isShellBarHidden(state(), AppModuleId.mail), isFalse);
|
||||
// 숨기지 않은 모듈을 돌려줘도 음수가 되지 않는다
|
||||
hiding.reveal(AppModuleId.settings);
|
||||
expect(state(), isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,11 @@ import 'package:arasystem_app/features/auth/presentation/pages/two_factor_setup_
|
||||
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart';
|
||||
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_state.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -1837,7 +1842,15 @@ bool _allowedProviderValue(Object? value) =>
|
||||
value is AuthRepository ||
|
||||
value is TwoFactorRepository ||
|
||||
value is SupportContactRepository ||
|
||||
value is AuthenticatorLauncher;
|
||||
value is AuthenticatorLauncher ||
|
||||
// 로그인을 마치면 메일 목록이 뜬다 — 메일함 · 목록 · 새 메일 확인 · 알림 · 탭 막대 숨김 · 앱이 앞에 있는가
|
||||
value is MailRepository ||
|
||||
value is MailboxesState ||
|
||||
value is MailListState ||
|
||||
value is MailChange ||
|
||||
value is MailToast ||
|
||||
value is Map<AppModuleId, int> ||
|
||||
value is bool;
|
||||
|
||||
/// 프로바이더 값 · 실패를 모두 적어 둔다 — 비밀이 상태에 들어가지 않는지 본다
|
||||
final class _ValueRecorder extends ProviderObserver {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/mail_fakes.dart';
|
||||
|
||||
/// 메일 저장소 — 계약 2절의 경로 · 본문 · 응답 읽기 (인터셉터 줄을 그대로 탄다)
|
||||
void main() {
|
||||
late ApiHarness h;
|
||||
late MailRepository repository;
|
||||
|
||||
setUp(() async {
|
||||
h = ApiHarness();
|
||||
await h.prepare();
|
||||
await h.signIn();
|
||||
repository = MailRepository(h.dio);
|
||||
});
|
||||
|
||||
test('메일함 목록 — Bearer 로 부르고 읽는다, 실패는 알리지 않는다', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.mailboxes,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'items': standardMailboxes(),
|
||||
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
|
||||
}),
|
||||
);
|
||||
|
||||
final List<Mailbox> mailboxes = await repository.listMailboxes();
|
||||
|
||||
expect(mailboxes, hasLength(8));
|
||||
expect(mailboxes.first.role, MailboxRole.inbox);
|
||||
expect(bearerTokenOf(h.server.requests.single), 'access-1');
|
||||
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.mailboxes,
|
||||
(_) => FakeResponse.fail(503, 'MAIL_001'),
|
||||
);
|
||||
expect(
|
||||
apiErrorOf(await errorOf(repository.listMailboxes())).code,
|
||||
'MAIL_001',
|
||||
);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
test('목록 — 메일함 · 쪽 크기 · 커서를 그대로 보낸다', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.messages,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'items': <Object?>[messageJson('m1', mailboxId: kInboxId)],
|
||||
'pageInfo': <String, Object?>{
|
||||
'hasNext': true,
|
||||
'nextCursor': 'opaque==',
|
||||
'total': 21,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
final MailPage<MessageSummary> page = await repository.listMessages(
|
||||
mailboxId: kInboxId,
|
||||
cursor: 'abc',
|
||||
limit: 50,
|
||||
);
|
||||
|
||||
expect(page.items.single.id, 'm1');
|
||||
expect(page.pageInfo.nextCursor, 'opaque==');
|
||||
expect(page.pageInfo.total, 21);
|
||||
expect(h.server.requests.single.queryParameters, <String, Object?>{
|
||||
'mailboxId': kInboxId,
|
||||
'limit': 50,
|
||||
'cursor': 'abc',
|
||||
});
|
||||
});
|
||||
|
||||
test('목록 — 응답의 Date(서버 시각)를 쪽에 싣는다, 없으면 null', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.messages,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'items': <Object?>[],
|
||||
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
|
||||
}),
|
||||
);
|
||||
final DateTime serverTime = DateTime.utc(2026, 11, 6, 1, 2, 3);
|
||||
h.server.serverTime = serverTime;
|
||||
|
||||
final MailPage<MessageSummary> page = await repository.listMessages(
|
||||
mailboxId: kInboxId,
|
||||
);
|
||||
expect(page.serverTime, serverTime);
|
||||
|
||||
h.server.serverTime = null;
|
||||
final MailPage<MessageSummary> withoutDate = await repository.listMessages(
|
||||
mailboxId: kInboxId,
|
||||
);
|
||||
expect(withoutDate.serverTime, isNull);
|
||||
});
|
||||
|
||||
test('응답 모양이 계약과 다르면 SYS_001', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.messages,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'items': <Object?>[
|
||||
<String, Object?>{'id': 'm1'},
|
||||
],
|
||||
'pageInfo': <String, Object?>{'hasNext': false},
|
||||
}),
|
||||
);
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.mailState,
|
||||
(_) => FakeResponse.ok(<String, Object?>{'state': 3}),
|
||||
);
|
||||
|
||||
expect(
|
||||
apiErrorOf(
|
||||
await errorOf(repository.listMessages(mailboxId: kInboxId)),
|
||||
).code,
|
||||
'SYS_001',
|
||||
);
|
||||
expect(apiErrorOf(await errorOf(repository.mailState())).code, 'SYS_001');
|
||||
});
|
||||
|
||||
test('한 통 · 여러 통 바꾸기 · 영구 삭제의 경로와 본문', () async {
|
||||
h.server.on(
|
||||
'PATCH',
|
||||
MailApiPaths.message('a/b'),
|
||||
(RequestOptions options) => FakeResponse.ok(
|
||||
messageJson('a/b', mailboxId: kInboxId, isRead: true),
|
||||
),
|
||||
);
|
||||
h.server.on(
|
||||
'PATCH',
|
||||
MailApiPaths.messages,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'updatedIds': <String>['m1'],
|
||||
'failedIds': <String>['m2'],
|
||||
}),
|
||||
);
|
||||
h.server.on(
|
||||
'POST',
|
||||
MailApiPaths.messageDeletions,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'deletedIds': <String>['t1'],
|
||||
'skippedIds': <String>[],
|
||||
}),
|
||||
);
|
||||
|
||||
final MessageSummary one = await repository.updateMessage(
|
||||
'a/b',
|
||||
const MessageChanges(isRead: true),
|
||||
);
|
||||
final MessagesPatched many = await repository.updateMessages(<String>[
|
||||
'm1',
|
||||
'm2',
|
||||
], const MessageChanges(mailboxId: kTrashId));
|
||||
final MessageDeletions deleted = await repository.deleteMessages(<String>[
|
||||
't1',
|
||||
]);
|
||||
|
||||
expect(MailApiPaths.message('a/b'), '/messages/a%2Fb');
|
||||
expect(one.isRead, isTrue);
|
||||
expect(many.failedIds, <String>['m2']);
|
||||
expect(deleted.deletedIds, <String>['t1']);
|
||||
expect(h.server.requests[0].data, <String, Object?>{'isRead': true});
|
||||
expect(h.server.requests[1].data, <String, Object?>{
|
||||
'ids': <String>['m1', 'm2'],
|
||||
'mailboxId': kTrashId,
|
||||
});
|
||||
expect(h.server.requests[2].data, <String, Object?>{
|
||||
'ids': <String>['t1'],
|
||||
});
|
||||
});
|
||||
|
||||
test('여러 통 바꾸기의 실패는 인터셉터가 알린다(새 메일 확인은 알리지 않는다)', () async {
|
||||
h.server.on(
|
||||
'PATCH',
|
||||
MailApiPaths.messages,
|
||||
(_) => FakeResponse.fail(503, 'MAIL_001'),
|
||||
);
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.mailState,
|
||||
(_) => FakeResponse.fail(503, 'MAIL_001'),
|
||||
);
|
||||
|
||||
await errorOf(
|
||||
repository.updateMessages(<String>[
|
||||
'm1',
|
||||
], const MessageChanges(isRead: true)),
|
||||
);
|
||||
expect(h.notifier.shownCodes, <String>['MAIL_001']);
|
||||
h.clock.advance(const Duration(seconds: 5));
|
||||
await errorOf(repository.mailState());
|
||||
expect(h.notifier.shownCodes, <String>['MAIL_001']);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import 'package:arasystem_app/core/utils/korean_format.dart';
|
||||
import 'package:arasystem_app/core/utils/korean_text.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_bulk.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_list_merge.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/mail_fakes.dart';
|
||||
|
||||
/// 메일 목록의 순수 규칙 — 표기 · 조사 · 줄 · 메일함 차례 · 병합 · 나눠 보내기 · 모델 읽기 (웹 규칙과 같은지)
|
||||
void main() {
|
||||
/// 기기 시간대의 시각을 UTC 로 — 시험이 CI(UTC) · 개발 PC(KST) 어디서나 같게 (테스트 규칙 "시간대도 입력이다")
|
||||
DateTime local(int y, int m, int d, [int h = 0, int min = 0]) =>
|
||||
DateTime(y, m, d, h, min).toUtc();
|
||||
|
||||
MessageSummary summary(Map<String, Object?> json) =>
|
||||
MessageSummary.tryParse(json)!;
|
||||
|
||||
group('숫자 · 시각 표기 (웹 format.ts 와 같은 글)', () {
|
||||
test('개수에 천 단위 쉼표', () {
|
||||
expect(formatCount(0), '0');
|
||||
expect(formatCount(999), '999');
|
||||
expect(formatCount(1000), '1,000');
|
||||
expect(formatCount(1234567), '1,234,567');
|
||||
});
|
||||
|
||||
test('하루 안의 시각 — 오전 · 오후 · 열두 시', () {
|
||||
expect(koreanTimeOfDay(DateTime(2026, 11, 6, 10, 42)), '오전 10:42');
|
||||
expect(koreanTimeOfDay(DateTime(2026, 11, 6, 15, 5)), '오후 3:05');
|
||||
expect(koreanTimeOfDay(DateTime(2026, 11, 6, 0, 30)), '오전 12:30');
|
||||
expect(koreanTimeOfDay(DateTime(2026, 11, 6, 12)), '오후 12:00');
|
||||
});
|
||||
|
||||
test('목록의 받은 시각 — 오늘 · 어제 · 올해 · 그 전', () {
|
||||
final DateTime now = local(2026, 11, 6, 9, 12);
|
||||
expect(formatMailListTime(local(2026, 11, 6, 8, 31), now), '오전 8:31');
|
||||
expect(formatMailListTime(local(2026, 11, 5, 23, 59), now), '어제');
|
||||
expect(formatMailListTime(local(2026, 11, 3, 10), now), '11월 3일');
|
||||
expect(formatMailListTime(local(2025, 10, 24, 10), now), '2025. 10. 24.');
|
||||
});
|
||||
|
||||
test('어제는 달 · 해의 경계를 넘는다', () {
|
||||
expect(
|
||||
formatMailListTime(local(2025, 12, 31, 18), local(2026, 1, 1, 8)),
|
||||
'어제',
|
||||
);
|
||||
expect(
|
||||
formatMailListTime(local(2026, 2, 28, 18), local(2026, 3, 1, 8)),
|
||||
'어제',
|
||||
);
|
||||
});
|
||||
|
||||
test('붙잡아 둔 발송 — 보내는 중 · 예약 시각(요일 없이)', () {
|
||||
expect(
|
||||
pendingSendLabel(
|
||||
PendingSend(
|
||||
sendAt: local(2026, 11, 6, 9, 13),
|
||||
kind: PendingSendKind.undo,
|
||||
),
|
||||
),
|
||||
'보내는 중',
|
||||
);
|
||||
expect(
|
||||
pendingSendLabel(
|
||||
PendingSend(
|
||||
sendAt: local(2026, 11, 7, 9),
|
||||
kind: PendingSendKind.scheduled,
|
||||
),
|
||||
),
|
||||
'예약 · 11월 7일 오전 9:00',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('조사 (웹 korean.ts 와 같다)', () {
|
||||
test('이/가 — 받침 · 받침 없음 · 한글이 아님', () {
|
||||
expect(subjectParticle('휴지통'), '이');
|
||||
expect(subjectParticle('프로젝트'), '가');
|
||||
expect(subjectParticle('Inbox'), '이(가)');
|
||||
expect(MailListTexts.emptyTitle('휴지통'), '휴지통이 비어 있습니다');
|
||||
expect(MailListTexts.emptyTitle('프로젝트'), '프로젝트가 비어 있습니다');
|
||||
});
|
||||
|
||||
test('으로/로 — 받침 · ㄹ 받침 · 받침 없음 · 한글이 아님', () {
|
||||
expect(directionParticle('휴지통'), '으로');
|
||||
expect(directionParticle('보낸편지함'), '으로');
|
||||
expect(directionParticle('메일'), '로');
|
||||
expect(directionParticle('프로젝트'), '로');
|
||||
expect(directionParticle('Archive'), '(으)로');
|
||||
});
|
||||
});
|
||||
|
||||
group('여러 통 처리의 알림 글 (웹 mail-bulk.ts 와 같다)', () {
|
||||
test('옮김 · 영구 삭제 · 되돌림, 그대로 둔 메일은 뒤에 덧붙인다', () {
|
||||
expect(MailBulkTexts.moved(2, '휴지통'), '메일 2통을 휴지통으로 옮겼습니다.');
|
||||
expect(
|
||||
MailBulkTexts.moved(1, '프로젝트', skipped: 1),
|
||||
'메일 1통을 프로젝트로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
);
|
||||
expect(
|
||||
MailBulkTexts.moved(0, '휴지통', skipped: 2),
|
||||
'2통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
);
|
||||
expect(MailBulkTexts.purged(1234), '메일 1,234통을 영구 삭제했습니다.');
|
||||
expect(MailBulkTexts.restored(3), '메일 3통을 되돌렸습니다.');
|
||||
expect(MailBulkTexts.purgeTitle(2), '메일 2통을 영구 삭제할까요?');
|
||||
});
|
||||
|
||||
test('위 막대 아래 안 읽은 수', () {
|
||||
expect(MailListTexts.unread(3), '안 읽은 메일 3통');
|
||||
expect(MailListTexts.unread(0), '안 읽은 메일이 없습니다');
|
||||
});
|
||||
});
|
||||
|
||||
group('목록 줄 (웹 mail-list-row.ts 의 분할 보기와 같다)', () {
|
||||
final DateTime now = local(2026, 11, 6, 11);
|
||||
|
||||
test('받은편지함 — 보낸 사람 · 머리글자 · 안 읽음 · 첨부 · 중요 · 미리 보기', () {
|
||||
final MailRowView view = mailRowViewOf(
|
||||
summary(
|
||||
messageJson(
|
||||
'm1',
|
||||
mailboxId: kInboxId,
|
||||
fromName: '(주)한빛솔루션',
|
||||
subject: '견적서',
|
||||
preview: '첨부했습니다.',
|
||||
receivedAt: local(2026, 11, 6, 10, 42),
|
||||
hasAttachment: true,
|
||||
isFlagged: true,
|
||||
),
|
||||
),
|
||||
role: MailboxRole.inbox,
|
||||
now: now,
|
||||
);
|
||||
expect(view.who, '(주)한빛솔루션');
|
||||
expect(view.initial, '한');
|
||||
expect(view.time, '오전 10:42');
|
||||
expect(view.unread, isTrue);
|
||||
expect(view.held, isFalse);
|
||||
expect(view.selectLabel, '고르기: 견적서');
|
||||
expect(
|
||||
view.semanticsLabel,
|
||||
'안 읽음, (주)한빛솔루션, 오전 10:42, 견적서, 첨부 있음, 중요, 첨부했습니다.',
|
||||
);
|
||||
});
|
||||
|
||||
test('이름이 없으면 주소, 보낸 사람이 없으면 "(보낸 사람 없음)" · 사람 아이콘', () {
|
||||
final MailRowView noName = mailRowViewOf(
|
||||
summary(
|
||||
messageJson(
|
||||
'm1',
|
||||
mailboxId: kInboxId,
|
||||
fromName: '',
|
||||
fromEmail: 'bot@example.com',
|
||||
),
|
||||
),
|
||||
role: MailboxRole.inbox,
|
||||
now: now,
|
||||
);
|
||||
expect(noName.who, 'bot@example.com');
|
||||
expect(noName.initial, 'B');
|
||||
final MailRowView noSender = mailRowViewOf(
|
||||
summary(messageJson('m2', mailboxId: kInboxId, noSender: true)),
|
||||
role: MailboxRole.inbox,
|
||||
now: now,
|
||||
);
|
||||
expect(noSender.who, MailListTexts.noSender);
|
||||
expect(noSender.initial, isNull);
|
||||
});
|
||||
|
||||
test('보낸편지함 — "받는 사람: 박준호 외 2명", 받는 사람이 없으면 "받는 사람 없음"', () {
|
||||
final MailRowView view = mailRowViewOf(
|
||||
summary(
|
||||
messageJson(
|
||||
's1',
|
||||
mailboxId: kSentId,
|
||||
to: <Map<String, Object?>>[
|
||||
addressJson('박준호', 'junho@example.com'),
|
||||
addressJson('', 'b@example.com'),
|
||||
addressJson('최서연', 'c@example.com'),
|
||||
],
|
||||
isRead: true,
|
||||
),
|
||||
),
|
||||
role: MailboxRole.sent,
|
||||
now: now,
|
||||
);
|
||||
expect(view.who, '받는 사람: 박준호 외 2명');
|
||||
expect(view.initial, '박');
|
||||
final MailRowView none = mailRowViewOf(
|
||||
summary(messageJson('s2', mailboxId: kSentId)),
|
||||
role: MailboxRole.sent,
|
||||
now: now,
|
||||
);
|
||||
expect(none.who, MailListTexts.noRecipients);
|
||||
expect(none.whoPlaceholder, isFalse);
|
||||
});
|
||||
|
||||
test('임시보관함 — 받는 사람을 쉼표로, 없으면 흐린 "(받는 사람 없음)", 읽음 여부 · 빈 제목', () {
|
||||
final MailRowView view = mailRowViewOf(
|
||||
summary(
|
||||
messageJson(
|
||||
'd1',
|
||||
mailboxId: kDraftsId,
|
||||
to: <Map<String, Object?>>[
|
||||
addressJson('박준호', 'junho@example.com'),
|
||||
addressJson('', 'infra@example.com'),
|
||||
],
|
||||
),
|
||||
),
|
||||
role: MailboxRole.drafts,
|
||||
now: now,
|
||||
);
|
||||
expect(view.who, '박준호, infra@example.com');
|
||||
expect(view.unread, isFalse, reason: '임시 저장본은 읽음 여부를 보이지 않는다');
|
||||
final MailRowView empty = mailRowViewOf(
|
||||
summary(messageJson('d2', mailboxId: kDraftsId, subject: '')),
|
||||
role: MailboxRole.drafts,
|
||||
now: now,
|
||||
);
|
||||
expect(empty.who, MailListTexts.draftNoRecipients);
|
||||
expect(empty.whoPlaceholder, isTrue);
|
||||
expect(empty.subject, MailListTexts.noSubject);
|
||||
expect(empty.subjectPlaceholder, isTrue);
|
||||
});
|
||||
|
||||
test('아직 보내지 않은 메일 — 시각 자리에 표시(청록) · 고를 수 없다', () {
|
||||
final MailRowView view = mailRowViewOf(
|
||||
summary(
|
||||
messageJson(
|
||||
's1',
|
||||
mailboxId: kSentId,
|
||||
isRead: true,
|
||||
pendingSend: pendingJson(local(2026, 11, 7, 9), kind: 'scheduled'),
|
||||
),
|
||||
),
|
||||
role: MailboxRole.sent,
|
||||
now: now,
|
||||
);
|
||||
expect(view.time, '예약 · 11월 7일 오전 9:00');
|
||||
expect(view.timePending, isTrue);
|
||||
expect(view.held, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('모델 읽기', () {
|
||||
test('메일 — 칸이 틀리면 null, 붙잡아 둔 발송만은 틀리면 없는 것으로', () {
|
||||
final Map<String, Object?> json = messageJson('m1', mailboxId: kInboxId);
|
||||
expect(summary(json).pendingSend, isNull);
|
||||
expect(
|
||||
MessageSummary.tryParse(<String, Object?>{...json, 'id': 1}),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
MessageSummary.tryParse(<String, Object?>{
|
||||
...json,
|
||||
'mailboxIds': <Object?>[1],
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
MessageSummary.tryParse(<String, Object?>{
|
||||
...json,
|
||||
'receivedAt': 'nope',
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
summary(<String, Object?>{
|
||||
...json,
|
||||
'pendingSend': <String, Object?>{'kind': 'later', 'sendAt': 'x'},
|
||||
}).pendingSend,
|
||||
isNull,
|
||||
);
|
||||
// 9단계 앞 서버 — 칸이 없다
|
||||
expect(
|
||||
summary(<String, Object?>{...json}..remove('pendingSend')).isHeld,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('메일함 — 모르는 역할은 내 메일함, 목록 쪽 — 항목 하나라도 틀리면 null', () {
|
||||
expect(
|
||||
Mailbox.tryParse(mailboxJson('x', '보관', role: 'mystery'))!.role,
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
Mailbox.tryParse(mailboxJson('x', '받은편지함', role: 'inbox'))!.role,
|
||||
MailboxRole.inbox,
|
||||
);
|
||||
final MailPage<Mailbox>? page = MailPage.tryParse<Mailbox>(
|
||||
<String, Object?>{
|
||||
'items': <Object?>[
|
||||
mailboxJson('a', 'A'),
|
||||
<String, Object?>{'id': 2},
|
||||
],
|
||||
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
|
||||
},
|
||||
Mailbox.tryParse,
|
||||
);
|
||||
expect(page, isNull);
|
||||
});
|
||||
|
||||
test('다음 쪽은 hasNext 이고 커서가 있을 때만', () {
|
||||
expect(
|
||||
MailPageInfo.tryParse(<String, Object?>{
|
||||
'hasNext': true,
|
||||
'nextCursor': 'c:20',
|
||||
'total': 40,
|
||||
})!.hasMore,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
MailPageInfo.tryParse(<String, Object?>{
|
||||
'hasNext': true,
|
||||
'nextCursor': null,
|
||||
})!.hasMore,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('바꿀 것 — 정한 칸만 싣는다', () {
|
||||
expect(const MessageChanges(isRead: true).toJson(), <String, Object?>{
|
||||
'isRead': true,
|
||||
});
|
||||
expect(
|
||||
const MessageChanges(mailboxId: 'mb-trash', isJunk: false).toJson(),
|
||||
<String, Object?>{'mailboxId': 'mb-trash', 'isJunk': false},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('메일함 차례 · 묶음 · 옮길 곳 (웹 mailbox.ts 와 같다)', () {
|
||||
final List<Mailbox> mailboxes = <Mailbox>[
|
||||
for (final Map<String, Object?> json in standardMailboxes())
|
||||
Mailbox.tryParse(json)!,
|
||||
];
|
||||
|
||||
test('역할 차례 → 내 메일함(sortOrder · 이름) — 하위는 부모 바로 아래 한 단 깊이', () {
|
||||
final List<MailboxNode> nodes = orderMailboxes(mailboxes);
|
||||
expect(
|
||||
<String>[for (final MailboxNode node in nodes) node.mailbox.name],
|
||||
<String>[
|
||||
'받은편지함',
|
||||
'보낸편지함',
|
||||
'임시보관함',
|
||||
'스팸메일함',
|
||||
'휴지통',
|
||||
'거래처',
|
||||
'프로젝트',
|
||||
'설계',
|
||||
],
|
||||
);
|
||||
expect(nodes.last.depth, 1);
|
||||
final ({List<MailboxNode> system, List<MailboxNode> mine}) groups =
|
||||
splitMailboxGroups(nodes);
|
||||
expect(groups.system, hasLength(5));
|
||||
expect(groups.mine, hasLength(3));
|
||||
});
|
||||
|
||||
test('내 메일함 이름은 대소문자를 가리지 않고 글자 값으로 — 영문 · 한글이 섞이면 웹(ko 정렬)과 다를 수 있다', () {
|
||||
final List<Mailbox> mine = <Mailbox>[
|
||||
for (final (String id, String name) in <(String, String)>[
|
||||
('1', '가나다'),
|
||||
('2', 'erp'),
|
||||
('3', 'ARA 프로젝트'),
|
||||
('4', 'ara'),
|
||||
('5', 'Ara'),
|
||||
])
|
||||
Mailbox.tryParse(mailboxJson(id, name))!,
|
||||
];
|
||||
|
||||
expect(
|
||||
<String>[
|
||||
for (final MailboxNode node in orderMailboxes(mine))
|
||||
node.mailbox.name,
|
||||
],
|
||||
// 같은 글(대소문자만 다름)은 원래 글자로 한 번 더 견준다 — 늘 같은 차례
|
||||
<String>['Ara', 'ara', 'ARA 프로젝트', 'erp', '가나다'],
|
||||
);
|
||||
});
|
||||
|
||||
test('부모를 찾을 수 없거나 서로를 가리키는 메일함도 빠뜨리지 않는다', () {
|
||||
final List<Mailbox> broken = <Mailbox>[
|
||||
Mailbox.tryParse(mailboxJson('a', 'A', parentId: 'b'))!,
|
||||
Mailbox.tryParse(mailboxJson('b', 'B', parentId: 'a'))!,
|
||||
Mailbox.tryParse(mailboxJson('c', 'C', parentId: 'gone'))!,
|
||||
];
|
||||
expect(orderMailboxes(broken), hasLength(3));
|
||||
});
|
||||
|
||||
test('옮길 곳은 지금 메일함 · 임시보관함 · 휴지통을 뺀다', () {
|
||||
expect(
|
||||
<String>[
|
||||
for (final MailboxNode node in moveTargetsOf(mailboxes, kInboxId))
|
||||
node.mailbox.id,
|
||||
],
|
||||
<String>[kSentId, kJunkId, kClientsId, kProjectId, kProjectChildId],
|
||||
);
|
||||
});
|
||||
|
||||
test('첫 화면은 받은편지함, 안 읽은 수는 보낸편지함 · 임시보관함 · 휴지통에 없다', () {
|
||||
expect(defaultMailboxOf(mailboxes)!.id, kInboxId);
|
||||
expect(showsUnreadCount(MailboxRole.inbox), isTrue);
|
||||
expect(showsUnreadCount(null), isTrue);
|
||||
expect(showsUnreadCount(MailboxRole.sent), isFalse);
|
||||
expect(showsUnreadCount(MailboxRole.drafts), isFalse);
|
||||
expect(showsUnreadCount(MailboxRole.trash), isFalse);
|
||||
expect(MailListTexts.retentionNotice(MailboxRole.trash), isNotNull);
|
||||
expect(MailListTexts.retentionNotice(MailboxRole.junk), isNotNull);
|
||||
expect(MailListTexts.retentionNotice(MailboxRole.inbox), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('새 메일 확인의 병합 (웹 mail-list-merge.ts 와 같다)', () {
|
||||
MessageSummary m(String id) =>
|
||||
summary(messageJson(id, mailboxId: kInboxId));
|
||||
const MailPageInfo oldInfo = MailPageInfo(
|
||||
hasNext: true,
|
||||
nextCursor: 'c:40',
|
||||
total: 50,
|
||||
);
|
||||
const MailPageInfo newInfo = MailPageInfo(
|
||||
hasNext: true,
|
||||
nextCursor: 'c:20',
|
||||
total: 51,
|
||||
);
|
||||
|
||||
test('다시 받은 범위는 서버 기준, 그 뒤의 옛 메일은 남기고 옛 커서를 쓴다', () {
|
||||
final MailListMerge merged = mergeRefreshedList(
|
||||
current: <MessageSummary>[m('a'), m('b'), m('c'), m('d')],
|
||||
currentPageInfo: oldInfo,
|
||||
fetched: <MessageSummary>[m('new'), m('a'), m('c')],
|
||||
fetchedPageInfo: newInfo,
|
||||
);
|
||||
expect(
|
||||
<String>[for (final MessageSummary x in merged.items) x.id],
|
||||
<String>['new', 'a', 'c', 'd'],
|
||||
reason: '다른 곳에서 지운 b 는 빠지고, 범위 밖의 d 는 남는다',
|
||||
);
|
||||
expect(merged.pageInfo.nextCursor, 'c:40');
|
||||
expect(merged.pageInfo.total, 51);
|
||||
});
|
||||
|
||||
test('다시 받은 쪽이 끝까지였으면 받지 못한 옛 메일은 지워진 것이라 남기지 않는다', () {
|
||||
final MailListMerge merged = mergeRefreshedList(
|
||||
current: <MessageSummary>[m('a'), m('b'), m('c')],
|
||||
currentPageInfo: const MailPageInfo(hasNext: false),
|
||||
fetched: <MessageSummary>[m('new'), m('a'), m('b')],
|
||||
fetchedPageInfo: const MailPageInfo(hasNext: false, total: 3),
|
||||
);
|
||||
expect(
|
||||
<String>[for (final MessageSummary x in merged.items) x.id],
|
||||
<String>['new', 'a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('겹치는 메일이 없으면 다시 받은 것만 쓴다', () {
|
||||
final MailListMerge merged = mergeRefreshedList(
|
||||
current: <MessageSummary>[m('a')],
|
||||
currentPageInfo: oldInfo,
|
||||
fetched: <MessageSummary>[m('x'), m('y')],
|
||||
fetchedPageInfo: newInfo,
|
||||
);
|
||||
expect(
|
||||
<String>[for (final MessageSummary x in merged.items) x.id],
|
||||
<String>['x', 'y'],
|
||||
);
|
||||
expect(merged.pageInfo, newInfo);
|
||||
});
|
||||
});
|
||||
|
||||
group('나눠 보내기 · 되돌리기 묶음', () {
|
||||
test('100통씩 나눈다', () {
|
||||
final List<String> ids = <String>[for (int i = 0; i < 250; i++) '$i'];
|
||||
expect(chunked<String>(ids, 100).map((List<String> c) => c.length), <int>[
|
||||
100,
|
||||
100,
|
||||
50,
|
||||
]);
|
||||
});
|
||||
|
||||
test('요청이 실패하면 그 묶음과 남은 묶음은 보내지 않은 것으로 두고 멈춘다', () async {
|
||||
int calls = 0;
|
||||
final BulkResult result = await sendInChunks(
|
||||
<String>[for (int i = 0; i < 250; i++) '$i'],
|
||||
100,
|
||||
(List<String> chunk) async {
|
||||
calls++;
|
||||
if (calls == 2) {
|
||||
throw DioException(requestOptions: RequestOptions(path: '/x'));
|
||||
}
|
||||
return (done: chunk.sublist(1), skipped: chunk.sublist(0, 1));
|
||||
},
|
||||
);
|
||||
expect(calls, 2);
|
||||
expect(result.doneIds, hasLength(99));
|
||||
expect(result.skippedIds, <String>['0']);
|
||||
expect(result.unsentIds, hasLength(150));
|
||||
expect(result.hasLeftovers, isTrue);
|
||||
});
|
||||
|
||||
test('원래 메일함별로 묶는다 — 모르는 메일은 뺀다', () {
|
||||
final List<BulkOrigin> groups = groupByOrigin(
|
||||
<String>['a', 'b', 'c', 'd'],
|
||||
<String, String>{'a': kInboxId, 'b': kClientsId, 'c': kInboxId},
|
||||
);
|
||||
expect(groups, hasLength(2));
|
||||
expect(groups.first.mailboxId, kInboxId);
|
||||
expect(groups.first.ids, <String>['a', 'c']);
|
||||
expect(groups.last.ids, <String>['b']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'fakes.dart';
|
||||
|
||||
/// 시험 메일함 id
|
||||
const String kInboxId = 'mb-inbox';
|
||||
const String kSentId = 'mb-sent';
|
||||
const String kDraftsId = 'mb-drafts';
|
||||
const String kJunkId = 'mb-junk';
|
||||
const String kTrashId = 'mb-trash';
|
||||
const String kClientsId = 'mb-clients';
|
||||
const String kProjectId = 'mb-project';
|
||||
const String kProjectChildId = 'mb-project-child';
|
||||
|
||||
/// 메일함 한 칸 (계약 2절 `Mailbox`)
|
||||
Map<String, Object?> mailboxJson(
|
||||
String id,
|
||||
String name, {
|
||||
String? role,
|
||||
String? parentId,
|
||||
int sortOrder = 0,
|
||||
int total = 0,
|
||||
int unread = 0,
|
||||
}) => <String, Object?>{
|
||||
'id': id,
|
||||
'name': name,
|
||||
'role': role,
|
||||
'parentId': parentId,
|
||||
'sortOrder': sortOrder,
|
||||
'totalCount': total,
|
||||
'unreadCount': unread,
|
||||
};
|
||||
|
||||
/// 메일 한 통 (계약 2절 `MessageSummary`)
|
||||
Map<String, Object?> messageJson(
|
||||
String id, {
|
||||
required String mailboxId,
|
||||
String fromName = '이도윤',
|
||||
String fromEmail = 'doyun.lee@example.com',
|
||||
List<Map<String, Object?>> to = const <Map<String, Object?>>[],
|
||||
String? subject,
|
||||
String preview = '미리 보기 글',
|
||||
DateTime? receivedAt,
|
||||
bool isRead = false,
|
||||
bool isFlagged = false,
|
||||
bool hasAttachment = false,
|
||||
Map<String, Object?>? pendingSend,
|
||||
bool noSender = false,
|
||||
}) => <String, Object?>{
|
||||
'id': id,
|
||||
'threadId': 'thread-$id',
|
||||
'mailboxIds': <String>[mailboxId],
|
||||
'from': noSender
|
||||
? null
|
||||
: <String, Object?>{'name': fromName, 'email': fromEmail},
|
||||
'to': to,
|
||||
'subject': subject ?? '제목 $id',
|
||||
'preview': preview,
|
||||
'receivedAt': (receivedAt ?? kTestNow).toUtc().toIso8601String(),
|
||||
'isRead': isRead,
|
||||
'isFlagged': isFlagged,
|
||||
'hasAttachment': hasAttachment,
|
||||
'size': 1024,
|
||||
'pendingSend': pendingSend,
|
||||
};
|
||||
|
||||
/// 받는 사람 한 사람
|
||||
Map<String, Object?> addressJson(String name, String email) =>
|
||||
<String, Object?>{'name': name, 'email': email};
|
||||
|
||||
/// 붙잡아 둔 발송 (9단계)
|
||||
Map<String, Object?> pendingJson(DateTime sendAt, {String kind = 'undo'}) =>
|
||||
<String, Object?>{'sendAt': sendAt.toUtc().toIso8601String(), 'kind': kind};
|
||||
|
||||
/// 기본 메일함 다섯과 내 메일함 셋(프로젝트 아래에 하나)
|
||||
List<Map<String, Object?>> standardMailboxes({
|
||||
int inboxUnread = 3,
|
||||
}) => <Map<String, Object?>>[
|
||||
mailboxJson(kInboxId, '받은편지함', role: 'inbox', total: 30, unread: inboxUnread),
|
||||
mailboxJson(kSentId, '보낸편지함', role: 'sent', total: 5),
|
||||
mailboxJson(kDraftsId, '임시보관함', role: 'drafts', total: 1),
|
||||
mailboxJson(kJunkId, '스팸메일함', role: 'junk', total: 2, unread: 2),
|
||||
mailboxJson(kTrashId, '휴지통', role: 'trash'),
|
||||
mailboxJson(kClientsId, '거래처', total: 1, unread: 1),
|
||||
mailboxJson(kProjectId, '프로젝트', sortOrder: 1),
|
||||
mailboxJson(kProjectChildId, '설계', parentId: kProjectId),
|
||||
];
|
||||
|
||||
/// 메일 API 를 흉내 내는 가짜 뒤쪽 — 메일함 · 목록(커서) · 여러 통 바꾸기 · 영구 삭제 · 새 메일 확인.
|
||||
///
|
||||
/// 상태는 메모리에 두고 요청대로 바꾼다. [failIds] 는 바꾸기 · 지우기가 그대로 둘 메일(`failedIds` · `skippedIds`),
|
||||
/// [patchFailure] · [listFailure] 는 그 요청을 그 응답으로 실패시킨다. 문([listGate] 등)을 두면 그 응답이 문이 열릴 때까지 기다린다.
|
||||
class FakeMailBackend {
|
||||
FakeMailBackend(this.server, {List<Map<String, Object?>>? mailboxes})
|
||||
: mailboxes = mailboxes ?? standardMailboxes();
|
||||
|
||||
final FakeServer server;
|
||||
List<Map<String, Object?>> mailboxes;
|
||||
|
||||
/// 메일함마다 메일 — 받은 시각 내림차순(앞이 최신)
|
||||
final Map<String, List<Map<String, Object?>>> messages =
|
||||
<String, List<Map<String, Object?>>>{};
|
||||
|
||||
/// 새 메일 확인의 상태 글
|
||||
String state = 'state-1';
|
||||
|
||||
/// 바꾸기 · 지우기가 그대로 둘 메일 id
|
||||
final Set<String> failIds = <String>{};
|
||||
|
||||
/// 있으면 PATCH 본문 · 메일 id 마다 그대로 둘지 묻는다(요청마다 다르게 — 읽음만 실패 등)
|
||||
bool Function(Map<String, Object?> body, String id)? failWhen;
|
||||
|
||||
FakeResponse? mailboxesFailure;
|
||||
FakeResponse? listFailure;
|
||||
FakeResponse? patchFailure;
|
||||
FakeResponse? deletionFailure;
|
||||
FakeResponse? stateFailure;
|
||||
|
||||
/// 있으면 다음 목록 응답들이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다 — [listGateOnce])
|
||||
Completer<void>? listGate;
|
||||
bool listGateOnce = true;
|
||||
Completer<void>? patchGate;
|
||||
Completer<void>? mailboxesGate;
|
||||
|
||||
/// 받은 PATCH /messages 본문
|
||||
final List<Map<String, Object?>> patches = <Map<String, Object?>>[];
|
||||
|
||||
/// 받은 POST /message-deletions 의 ids
|
||||
final List<List<String>> deletions = <List<String>>[];
|
||||
|
||||
int get stateRequests =>
|
||||
server.received('GET', MailApiPaths.mailState).length;
|
||||
|
||||
List<RequestOptions> get listRequests =>
|
||||
server.received('GET', MailApiPaths.messages);
|
||||
|
||||
/// 경로마다 처리기를 건다
|
||||
void install() {
|
||||
server.on('GET', MailApiPaths.mailboxes, (_) async {
|
||||
await mailboxesGate?.future;
|
||||
final FakeResponse? failure = mailboxesFailure;
|
||||
if (failure != null) return failure;
|
||||
return FakeResponse.ok(<String, Object?>{
|
||||
'items': mailboxes,
|
||||
'pageInfo': <String, Object?>{
|
||||
'hasNext': false,
|
||||
'nextCursor': null,
|
||||
'total': mailboxes.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
server.on('GET', MailApiPaths.messages, (RequestOptions options) async {
|
||||
final Completer<void>? gate = listGate;
|
||||
if (gate != null) {
|
||||
if (listGateOnce) listGate = null;
|
||||
await gate.future;
|
||||
}
|
||||
final FakeResponse? failure = listFailure;
|
||||
if (failure != null) return failure;
|
||||
final Map<String, Object?> query = options.queryParameters;
|
||||
final String mailboxId = query['mailboxId']! as String;
|
||||
if (!mailboxes.any((Map<String, Object?> m) => m['id'] == mailboxId)) {
|
||||
return FakeResponse.fail(404, 'RES_001');
|
||||
}
|
||||
final List<Map<String, Object?>> all =
|
||||
messages[mailboxId] ?? <Map<String, Object?>>[];
|
||||
final int limit = (query['limit'] as int?) ?? kMailPageSize;
|
||||
final Object? cursor = query['cursor'];
|
||||
final int start = cursor is String ? int.parse(cursor.substring(2)) : 0;
|
||||
final int end = (start + limit).clamp(0, all.length);
|
||||
final bool hasNext = end < all.length;
|
||||
return FakeResponse.ok(<String, Object?>{
|
||||
'items': all.sublist(start.clamp(0, all.length), end),
|
||||
'pageInfo': <String, Object?>{
|
||||
'hasNext': hasNext,
|
||||
'nextCursor': hasNext ? 'c:$end' : null,
|
||||
'total': all.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
server.on('PATCH', MailApiPaths.messages, (RequestOptions options) async {
|
||||
final Map<String, Object?> body = options.data! as Map<String, Object?>;
|
||||
patches.add(body);
|
||||
await patchGate?.future;
|
||||
final FakeResponse? failure = patchFailure;
|
||||
if (failure != null) return failure;
|
||||
final List<String> ids = (body['ids']! as List<Object?>).cast<String>();
|
||||
final List<String> updated = <String>[];
|
||||
final List<String> failed = <String>[];
|
||||
for (final String id in ids) {
|
||||
if (failIds.contains(id) ||
|
||||
(failWhen?.call(body, id) ?? false) ||
|
||||
_find(id) == null) {
|
||||
failed.add(id);
|
||||
continue;
|
||||
}
|
||||
_apply(id, body);
|
||||
updated.add(id);
|
||||
}
|
||||
return FakeResponse.ok(<String, Object?>{
|
||||
'updatedIds': updated,
|
||||
'failedIds': failed,
|
||||
});
|
||||
});
|
||||
server.on('POST', MailApiPaths.messageDeletions, (
|
||||
RequestOptions options,
|
||||
) async {
|
||||
final Map<String, Object?> body = options.data! as Map<String, Object?>;
|
||||
final List<String> ids = (body['ids']! as List<Object?>).cast<String>();
|
||||
deletions.add(ids);
|
||||
final FakeResponse? failure = deletionFailure;
|
||||
if (failure != null) return failure;
|
||||
final List<String> deleted = <String>[];
|
||||
final List<String> skipped = <String>[];
|
||||
final List<Map<String, Object?>> trash =
|
||||
messages[kTrashId] ?? <Map<String, Object?>>[];
|
||||
for (final String id in ids) {
|
||||
final int index = trash.indexWhere(
|
||||
(Map<String, Object?> m) => m['id'] == id,
|
||||
);
|
||||
if (failIds.contains(id) || index == -1) {
|
||||
skipped.add(id);
|
||||
continue;
|
||||
}
|
||||
trash.removeAt(index);
|
||||
deleted.add(id);
|
||||
}
|
||||
return FakeResponse.ok(<String, Object?>{
|
||||
'deletedIds': deleted,
|
||||
'skippedIds': skipped,
|
||||
});
|
||||
});
|
||||
server.on('GET', MailApiPaths.mailState, (_) {
|
||||
final FakeResponse? failure = stateFailure;
|
||||
if (failure != null) return failure;
|
||||
return FakeResponse.ok(<String, Object?>{'state': state});
|
||||
});
|
||||
}
|
||||
|
||||
/// [mailboxId] 에 메일 [count] 통을 둔다 — id 는 `<prefix>1` 부터(앞이 최신)
|
||||
void seed(
|
||||
String mailboxId,
|
||||
int count, {
|
||||
String prefix = 'm',
|
||||
bool isRead = false,
|
||||
}) {
|
||||
messages[mailboxId] = <Map<String, Object?>>[
|
||||
for (int n = 1; n <= count; n++)
|
||||
messageJson(
|
||||
'$prefix$n',
|
||||
mailboxId: mailboxId,
|
||||
receivedAt: kTestNow.subtract(Duration(minutes: n)),
|
||||
isRead: isRead,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 맨 앞에 새 메일을 넣는다(새 메일이 왔다)
|
||||
void arrive(String mailboxId, Map<String, Object?> message) {
|
||||
(messages[mailboxId] ??= <Map<String, Object?>>[]).insert(0, message);
|
||||
}
|
||||
|
||||
({String mailboxId, Map<String, Object?> message})? _find(String id) {
|
||||
for (final MapEntry<String, List<Map<String, Object?>>> entry
|
||||
in messages.entries) {
|
||||
for (final Map<String, Object?> message in entry.value) {
|
||||
if (message['id'] == id) {
|
||||
return (mailboxId: entry.key, message: message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _apply(String id, Map<String, Object?> body) {
|
||||
final ({String mailboxId, Map<String, Object?> message})? found = _find(id);
|
||||
if (found == null) return;
|
||||
final Map<String, Object?> message = found.message;
|
||||
if (body['isRead'] case final bool isRead) message['isRead'] = isRead;
|
||||
if (body['isFlagged'] case final bool isFlagged) {
|
||||
message['isFlagged'] = isFlagged;
|
||||
}
|
||||
if (body['mailboxId'] case final String target
|
||||
when target != found.mailboxId) {
|
||||
messages[found.mailboxId]!.remove(message);
|
||||
message['mailboxIds'] = <String>[target];
|
||||
final List<Map<String, Object?>> into = messages[target] ??=
|
||||
<Map<String, Object?>>[];
|
||||
into.add(message);
|
||||
into.sort(
|
||||
(Map<String, Object?> a, Map<String, Object?> b) =>
|
||||
(b['receivedAt']! as String).compareTo(a['receivedAt']! as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [mailboxId] 에 있는 메일 id (차례대로)
|
||||
List<String> idsIn(String mailboxId) => <String>[
|
||||
for (final Map<String, Object?> message
|
||||
in messages[mailboxId] ?? <Map<String, Object?>>[])
|
||||
message['id']! as String,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:arasystem_app/app.dart';
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/router/app_router.dart';
|
||||
import 'package:arasystem_app/core/utils/provider_retry.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'fakes.dart';
|
||||
import 'mail_fakes.dart';
|
||||
import 'provider_harness.dart';
|
||||
|
||||
/// 시험 직원 (fakes.dart 의 `sessionJson` 과 같다)
|
||||
const String kMailTestAccount = 'ara.kim@arasystem.it.kr';
|
||||
|
||||
/// 휴대폰 화면 크기 (보드의 390 × 844)
|
||||
const Size kPhoneSize = Size(390, 844);
|
||||
|
||||
/// 메일 화면 시험대 — 로그인한 앱 전체를 가짜 서버(인증 + 메일)로 띄운다
|
||||
class MailAppHarness {
|
||||
MailAppHarness();
|
||||
|
||||
final ProviderHarness base = ProviderHarness();
|
||||
late final FakeMailBackend mail = FakeMailBackend(base.server);
|
||||
|
||||
FakeServer get server => base.server;
|
||||
ManualClock get clock => base.clock;
|
||||
RecordingNotifier get notifier => base.notifier;
|
||||
|
||||
late ProviderContainer container;
|
||||
|
||||
GoRouter get router => container.read(routerProvider);
|
||||
|
||||
/// 지금 주소
|
||||
String location() =>
|
||||
router.routerDelegate.currentConfiguration.uri.toString();
|
||||
|
||||
/// 앱을 띄운다 — 리프레시 토큰으로 세션을 되살리고 메일 첫 화면(또는 [location])까지 그린다
|
||||
Future<void> pump(
|
||||
WidgetTester tester, {
|
||||
String? location,
|
||||
Size size = kPhoneSize,
|
||||
}) async {
|
||||
tester.view.physicalSize = size * 3;
|
||||
tester.view.devicePixelRatio = 3;
|
||||
addTearDown(tester.view.reset);
|
||||
await base.prepare(refreshToken: 'refresh-1');
|
||||
server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
...sessionJson(),
|
||||
'tokens': tokensJson(2, now: clock.now),
|
||||
}),
|
||||
);
|
||||
server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
|
||||
mail.install();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
retry: noAutomaticRetry,
|
||||
overrides: <Override>[...base.overrides],
|
||||
child: const App(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(MaterialApp)),
|
||||
);
|
||||
expect(container.read(mailAccountProvider), kMailTestAccount);
|
||||
if (location != null) {
|
||||
router.go(location);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user