feat(flutter): 앱 첫 판 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 검색 · 새 메일 알림 #21

Merged
ttipo merged 34 commits from feat/app-push into main 2026-11-08 16:34:38 +09:00
49 changed files with 8684 additions and 59 deletions
Showing only changes of commit ad8b126abb - Show all commits
@@ -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';
+104 -24
View File
@@ -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,
),
],
),
),
);
}
}
+129
View File
@@ -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;
}
+6
View File
@@ -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);
+41
View File
@@ -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;
}
+51
View File
@@ -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;
}
}
+46
View File
@@ -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);
+38 -2
View File
@@ -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;
+15 -13
View File
@@ -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
View File
@@ -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']);
});
});
}
+306
View File
@@ -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,
];
}
+78
View File
@@ -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();
}
}
}