diff --git a/flutter/lib/core/constants/app_constants.dart b/flutter/lib/core/constants/app_constants.dart index 7787fa6..382d0fa 100644 --- a/flutter/lib/core/constants/app_constants.dart +++ b/flutter/lib/core/constants/app_constants.dart @@ -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'; diff --git a/flutter/lib/core/router/app_shell.dart b/flutter/lib/core/router/app_shell.dart index 69a5e6d..028d85b 100644 --- a/flutter/lib/core/router/app_shell.dart +++ b/flutter/lib/core/router/app_shell.dart @@ -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: [ - 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 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: [ + // 늘 같은 모양으로 감싼다 — 감싸는 위젯이 바뀌면 탭 안 화면(이동 기록)이 처음부터 다시 만들어진다 + 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 modules; + final int selectedIndex; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final AppExtraColors extra = Theme.of(context).extension()!; + 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: [ + for (final AppModule module in modules) + NavigationDestination( + icon: Icon(module.icon), + selectedIcon: Icon(module.selectedIcon), + label: module.label, + ), + ], + ), + ), ); } } diff --git a/flutter/lib/core/router/shell_bar.dart b/flutter/lib/core/router/shell_bar.dart new file mode 100644 index 0000000..04bba5d --- /dev/null +++ b/flutter/lib/core/router/shell_bar.dart @@ -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> +shellBarHidingProvider = + NotifierProvider>(ShellBarHiding.new); + +/// 모듈 [module] 의 탭에서 막대를 숨겼는가 +bool isShellBarHidden(Map hiding, AppModuleId module) => + (hiding[module] ?? 0) > 0; + +/// 탭 막대를 숨긴 화면 수 +class ShellBarHiding extends Notifier> { + @override + Map build() => const {}; + + /// 한 화면이 숨긴다 + void hide(AppModuleId module) { + if (!ref.mounted) return; + state = {...state, module: (state[module] ?? 0) + 1}; + } + + /// 숨겼던 화면 하나가 돌려준다 + void reveal(AppModuleId module) { + if (!ref.mounted) return; + final int left = (state[module] ?? 0) - 1; + final Map next = {...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 createState() => _ShellBarHiddenState(); +} + +class _ShellBarHiddenState extends ConsumerState { + /// 지금 숨겨 둔 모듈 — 숨기지 않았으면 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; +} diff --git a/flutter/lib/core/theme/app_colors.dart b/flutter/lib/core/theme/app_colors.dart index eab1dff..a6d49be 100644 --- a/flutter/lib/core/theme/app_colors.dart +++ b/flutter/lib/core/theme/app_colors.dart @@ -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); diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index beade93..0bd4d15 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -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; } diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart index 2bca6ef..8e6a075 100644 --- a/flutter/lib/core/theme/app_theme.dart +++ b/flutter/lib/core/theme/app_theme.dart @@ -97,10 +97,45 @@ abstract final class AppTheme { borderRadius: BorderRadius.circular(AppRadius.lg), ), ), + navigationBarTheme: _navigationBarTheme(scheme, textTheme), extensions: const [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(( + Set 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( + (Set 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 { 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 { warningBorder: AppColors.warningBorder, successText: AppColors.successText, star: AppColors.star, + rowChecked: AppColors.rowChecked, + brandTintBorder: AppColors.brandTintBorder, ); /// 시각 · 개수 · 보조 설명 @@ -214,6 +253,12 @@ class AppExtraColors extends ThemeExtension { /// 중요 표시 final Color star; + /// 고른 행 (여러 통 처리) + final Color rowChecked; + + /// 여러 통 고르기 막대(바탕 `secondaryContainer`)의 아래 선 + final Color brandTintBorder; + @override AppExtraColors copyWith({ Color? textMuted, @@ -228,6 +273,8 @@ class AppExtraColors extends ThemeExtension { Color? warningBorder, Color? successText, Color? star, + Color? rowChecked, + Color? brandTintBorder, }) { return AppExtraColors( textMuted: textMuted ?? this.textMuted, @@ -242,6 +289,8 @@ class AppExtraColors extends ThemeExtension { 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 { 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)!, ); } } diff --git a/flutter/lib/core/utils/accessible_navigation.dart b/flutter/lib/core/utils/accessible_navigation.dart new file mode 100644 index 0000000..2689a09 --- /dev/null +++ b/flutter/lib/core/utils/accessible_navigation.dart @@ -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 +accessibleNavigationProvider = NotifierProvider( + AccessibleNavigation.new, +); + +/// 기기의 접근성 설정을 듣는다 +class AccessibleNavigation extends Notifier 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; +} diff --git a/flutter/lib/core/utils/app_foreground.dart b/flutter/lib/core/utils/app_foreground.dart new file mode 100644 index 0000000..ab67473 --- /dev/null +++ b/flutter/lib/core/utils/app_foreground.dart @@ -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 appForegroundProvider = + NotifierProvider(AppForeground.new); + +/// 앱 생명주기를 듣는다 +class AppForeground extends Notifier { + @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; + } +} diff --git a/flutter/lib/core/utils/korean_format.dart b/flutter/lib/core/utils/korean_format.dart new file mode 100644 index 0000000..1152fba --- /dev/null +++ b/flutter/lib/core/utils/korean_format.dart @@ -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); diff --git a/flutter/lib/core/utils/korean_text.dart b/flutter/lib/core/utils/korean_text.dart index 8c56773..072f886 100644 --- a/flutter/lib/core/utils/korean_text.dart +++ b/flutter/lib/core/utils/korean_text.dart @@ -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; diff --git a/flutter/lib/features/mail/data/.gitkeep b/flutter/lib/features/mail/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/flutter/lib/features/mail/data/models/mail_address.dart b/flutter/lib/features/mail/data/models/mail_address.dart new file mode 100644 index 0000000..3999fc7 --- /dev/null +++ b/flutter/lib/features/mail/data/models/mail_address.dart @@ -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) 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? tryParseList(Object? json) { + if (json is! List) return null; + final List addresses = []; + for (final Object? item in json) { + final MailAddress? address = tryParse(item); + if (address == null) return null; + addresses.add(address); + } + return List.unmodifiable(addresses); + } + + @override + bool operator ==(Object other) => + other is MailAddress && other.email == email && other.name == name; + + @override + int get hashCode => Object.hash(email, name); +} diff --git a/flutter/lib/features/mail/data/models/mail_bulk_results.dart b/flutter/lib/features/mail/data/models/mail_bulk_results.dart new file mode 100644 index 0000000..10eb26f --- /dev/null +++ b/flutter/lib/features/mail/data/models/mail_bulk_results.dart @@ -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 toJson() => { + '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 updatedIds; + + /// 없음 · 그사이 지워짐 · 엔진이 거절함 · 붙잡힌 메일의 이동 — 나머지는 그대로 처리했다 + final List failedIds; + + static MessagesPatched? tryParse(Object? json) { + if (json is! Map) return null; + final List? updated = _idsOf(json['updatedIds']); + final List? 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 deletedIds; + + /// 휴지통에 없음 · 없음 · 엔진이 거절 · 붙잡힌 메일 — 지우지 않았다 + final List skippedIds; + + static MessageDeletions? tryParse(Object? json) { + if (json is! Map) return null; + final List? deleted = _idsOf(json['deletedIds']); + final List? skipped = _idsOf(json['skippedIds']); + if (deleted == null || skipped == null) return null; + return MessageDeletions(deletedIds: deleted, skippedIds: skipped); + } +} + +List? _idsOf(Object? value) { + if (value is! List) return null; + if (value.any((Object? id) => id is! String)) return null; + return List.unmodifiable(value.cast()); +} diff --git a/flutter/lib/features/mail/data/models/mail_page.dart b/flutter/lib/features/mail/data/models/mail_page.dart new file mode 100644 index 0000000..7709985 --- /dev/null +++ b/flutter/lib/features/mail/data/models/mail_page.dart @@ -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) 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 { + const MailPage({ + required this.items, + required this.pageInfo, + this.serverTime, + }); + + final List items; + final MailPageInfo pageInfo; + + /// 응답의 `Date` 머리(서버 시각) — 없으면 null. 붙잡아 둔 발송이 풀릴 때를 서버 시계로 재는 데 쓴다 + final DateTime? serverTime; + + /// 서버 시각을 붙인 쪽 + MailPage withServerTime(DateTime? serverTime) => + MailPage(items: items, pageInfo: pageInfo, serverTime: serverTime); + + /// 모양이 틀리면(항목 하나라도) null + static MailPage? tryParse( + Object? json, + T? Function(Object? item) parseItem, + ) { + if (json is! Map) return null; + final Object? rawItems = json['items']; + final MailPageInfo? pageInfo = MailPageInfo.tryParse(json['pageInfo']); + if (rawItems is! List || pageInfo == null) return null; + final List items = []; + for (final Object? raw in rawItems) { + final T? item = parseItem(raw); + if (item == null) return null; + items.add(item); + } + return MailPage(items: List.unmodifiable(items), pageInfo: pageInfo); + } +} diff --git a/flutter/lib/features/mail/data/models/mailbox.dart b/flutter/lib/features/mail/data/models/mailbox.dart new file mode 100644 index 0000000..7cfa6a4 --- /dev/null +++ b/flutter/lib/features/mail/data/models/mailbox.dart @@ -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) 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); +} diff --git a/flutter/lib/features/mail/data/models/message_summary.dart b/flutter/lib/features/mail/data/models/message_summary.dart new file mode 100644 index 0000000..654e5f1 --- /dev/null +++ b/flutter/lib/features/mail/data/models/message_summary.dart @@ -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) 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 mailboxIds; + final MailAddress? from; + final List 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? 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) 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? 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 || + 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.unmodifiable(mailboxIds.cast()), + 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, + ); +} diff --git a/flutter/lib/features/mail/data/repositories/mail_repository.dart b/flutter/lib/features/mail/data/repositories/mail_repository.dart new file mode 100644 index 0000000..d01397e --- /dev/null +++ b/flutter/lib/features/mail/data/repositories/mail_repository.dart @@ -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> listMailboxes() async { + final Response response = await _dio.get( + MailApiPaths.mailboxes, + options: ApiOptions.silent(), + ); + final MailPage? page = MailPage.tryParse( + response.data, + Mailbox.tryParse, + ); + if (page == null) throw _malformed(response); + return page.items; + } + + /// 메일함 하나의 목록 — 받은 시각 내림차순. [cursor] 는 해석하지 않고 그대로 보낸다. + /// [silent] 면 실패를 알리지 않는다(처음 여는 목록은 화면이 받지 못함을 직접 그린다 · 새 메일 맞추기) + Future> listMessages({ + required String mailboxId, + String? cursor, + int limit = kMailPageSize, + bool silent = false, + }) async { + final Response response = await _dio.get( + MailApiPaths.messages, + queryParameters: { + 'mailboxId': mailboxId, + 'limit': limit, + 'cursor': ?cursor, + }, + options: silent ? ApiOptions.silent() : null, + ); + final MailPage? page = MailPage.tryParse( + response.data, + MessageSummary.tryParse, + ); + if (page == null) throw _malformed(response); + return page.withServerTime(serverTimeOf(response)); + } + + /// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다) + Future updateMessage( + String id, + MessageChanges changes, + ) async { + final Response response = await _dio.patch( + MailApiPaths.message(id), + data: changes.toJson(), + ); + final MessageSummary? summary = MessageSummary.tryParse(response.data); + if (summary == null) throw _malformed(response); + return summary; + } + + /// 여러 통 바꾸기 — [ids] 는 100개까지(나눠 보내는 일은 부르는 쪽이 한다) + Future updateMessages( + List ids, + MessageChanges changes, + ) async { + final Response response = await _dio.patch( + MailApiPaths.messages, + data: {'ids': ids, ...changes.toJson()}, + ); + final MessagesPatched? result = MessagesPatched.tryParse(response.data); + if (result == null) throw _malformed(response); + return result; + } + + /// 휴지통에서 영구 삭제 — 확인을 받은 뒤에만 부른다. [ids] 는 100개까지 + Future deleteMessages(List ids) async { + final Response response = await _dio.post( + MailApiPaths.messageDeletions, + data: {'ids': ids}, + ); + final MessageDeletions? result = MessageDeletions.tryParse(response.data); + if (result == null) throw _malformed(response); + return result; + } + + /// 새 메일 확인 — 상태 글(해석하지 않고 지난 값과 같은지만 본다). 실패는 알리지 않는다(다음 차례에 다시 묻는다) + Future mailState() async { + final Response response = await _dio.get( + MailApiPaths.mailState, + options: ApiOptions.silent(), + ); + final Object? data = response.data; + final Object? state = data is Map ? data['state'] : null; + if (state is! String) throw _malformed(response); + return state; + } + + DioException _malformed(Response response) => + apiFailure(response.requestOptions, kFallbackCode); +} diff --git a/flutter/lib/features/mail/domain/.gitkeep b/flutter/lib/features/mail/domain/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/flutter/lib/features/mail/domain/mail_bulk.dart b/flutter/lib/features/mail/domain/mail_bulk.dart new file mode 100644 index 0000000..af824cd --- /dev/null +++ b/flutter/lib/features/mail/domain/mail_bulk.dart @@ -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> chunked(List items, int size) { + final int step = math.max(1, size); + return >[ + 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 [], + this.skippedIds = const [], + this.unsentIds = const [], + }); + + /// 처리한 메일 + final List doneIds; + + /// 그사이 옮겨졌거나 지워져 그대로 둔 메일 — 서버의 failedIds · skippedIds + final List skippedIds; + + /// 요청이 실패해 보내지 못한 메일 — 알림은 인터셉터가 띄웠다 + final List unsentIds; + + /// 그대로 둔 메일 · 보내지 못한 메일이 있다 — 목록을 서버 기준으로 다시 맞춘다 + bool get hasLeftovers => skippedIds.isNotEmpty || unsentIds.isNotEmpty; +} + +/// 한 묶음을 보낸 결과 — 처리한 메일 · 그대로 둔 메일 +typedef BulkChunkSender = + Future<({List done, List skipped})> Function( + List chunkIds, + ); + +/// [ids] 를 [size] 통씩 차례로 보낸다 — 요청이 실패하면(알림은 인터셉터) 그 묶음과 남은 묶음은 보내지 않은 것으로 두고 멈춘다 +/// (같은 오류가 이어질 것이고, 503 은 일부만 바뀌었을 수 있어 부르는 쪽이 목록을 다시 맞춘다) +Future sendInChunks( + List ids, + int size, + BulkChunkSender send, +) async { + final List done = []; + final List skipped = []; + final List unsent = []; + final List> chunks = chunked(ids, size); + for (int index = 0; index < chunks.length; index++) { + try { + final ({List done, List skipped}) result = await send( + chunks[index], + ); + done.addAll(result.done); + skipped.addAll(result.skipped); + } on DioException { + unsent.addAll(chunks.sublist(index).expand((List 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 ids; +} + +/// 옮긴 메일을 원래 메일함별로 묶는다(옮긴 차례대로). 원래 메일함을 모르는 메일은 뺀다 +List groupByOrigin(List ids, Map originOf) { + final Map> groups = >{}; + for (final String id in ids) { + final String? origin = originOf[id]; + if (origin == null) continue; + (groups[origin] ??= []).add(id); + } + return [ + for (final MapEntry> entry in groups.entries) + BulkOrigin(entry.key, List.unmodifiable(entry.value)), + ]; +} diff --git a/flutter/lib/features/mail/domain/mail_list_merge.dart b/flutter/lib/features/mail/domain/mail_list_merge.dart new file mode 100644 index 0000000..8274d50 --- /dev/null +++ b/flutter/lib/features/mail/domain/mail_list_merge.dart @@ -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 items, MailPageInfo pageInfo}); + +/// 지금 목록([current] · [currentPageInfo])과 다시 받은 첫 범위([fetched] · [fetchedPageInfo])를 합친다 +MailListMerge mergeRefreshedList({ + required List current, + required MailPageInfo? currentPageInfo, + required List fetched, + required MailPageInfo fetchedPageInfo, +}) { + final Set fetchedIds = { + 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 tail = + lastOverlap == -1 || !fetchedPageInfo.hasNext + ? [] + : 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.unmodifiable([ + ...fetched, + ...tail, + ]), + pageInfo: pageInfo, + ); +} diff --git a/flutter/lib/features/mail/domain/mailbox_tree.dart b/flutter/lib/features/mail/domain/mailbox_tree.dart new file mode 100644 index 0000000..1ab4472 --- /dev/null +++ b/flutter/lib/features/mail/domain/mailbox_tree.dart @@ -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 kMailboxRoleOrder = [ + 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 orderMailboxes(List mailboxes) { + final Set ids = { + for (final Mailbox mailbox in mailboxes) mailbox.id, + }; + final Map> childrenOf = >{}; + for (final Mailbox mailbox in mailboxes) { + final String? parentId = mailbox.parentId; + final String? key = parentId != null && ids.contains(parentId) + ? parentId + : null; + (childrenOf[key] ??= []).add(mailbox); + } + + final List result = []; + final Set visited = {}; + void visit(String? parentId, int depth) { + final List children = [...?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 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 system, List mine}) splitMailboxGroups( + List nodes, +) { + final List system = []; + final List mine = []; + 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 mailboxes, MailboxRole role) { + for (final Mailbox mailbox in mailboxes) { + if (mailbox.role == role) return mailbox; + } + return null; +} + +/// 주소 `/mail` 이 여는 메일함 — 받은편지함, 없으면 차례의 첫 메일함 +Mailbox? defaultMailboxOf(List mailboxes) { + final Mailbox? inbox = findMailboxByRole(mailboxes, MailboxRole.inbox); + if (inbox != null) return inbox; + final List ordered = orderMailboxes(mailboxes); + return ordered.isEmpty ? null : ordered.first.mailbox; +} + +/// 옮길 수 있는 메일함 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함(서랍 차례). 휴지통으로는 [삭제]로 옮긴다 +/// (웹 `useBulkMailActions` 의 `moveTargets` 와 같다) +List moveTargetsOf( + List 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; diff --git a/flutter/lib/features/mail/mail_routes.dart b/flutter/lib/features/mail/mail_routes.dart index b3f0a68..407255d 100644 --- a/flutter/lib/features/mail/mail_routes.dart +++ b/flutter/lib/features/mail/mail_routes.dart @@ -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=`) + static String composeDraft(String draftId) => Uri( + path: compose, + queryParameters: {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) => [ GoRoute( path: MailRoutes.home, - builder: (BuildContext _, GoRouterState _) => PlaceholderPage( - title: MailTexts.home, - links: [ - PlaceholderLink( - label: MailTexts.compose, - location: MailRoutes.compose, - ), - ], - ), + builder: (BuildContext _, GoRouterState _) => const MailListPage(), routes: [ 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', diff --git a/flutter/lib/features/mail/presentation/mail_row_view.dart b/flutter/lib/features/mail/presentation/mail_row_view.dart new file mode 100644 index 0000000..2a6462e --- /dev/null +++ b/flutter/lib/features/mail/presentation/mail_row_view.dart @@ -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 => [ + 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 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)}', + ); +} diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index 372384f..2090b8c 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -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 = '취소'; +} diff --git a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart new file mode 100644 index 0000000..51fe5d0 --- /dev/null +++ b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart @@ -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 createState() => _MailListPageState(); +} + +class _MailListPageState extends ConsumerState { + /// 컨트롤러에 마지막으로 알린 보임 — 그린 뒤에 알린다(그리는 동안에는 프로바이더를 바꿀 수 없다) + 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 _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( + role == MailboxRole.drafts + ? MailRoutes.composeDraft(message.id) + : MailRoutes.message(message.id), + ), + ); + } + + Future _move(List mailboxes, String? currentMailboxId) async { + final List 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 _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('mail-compose-area'), + showCompose: !selecting, + safeInsets: MediaQuery.paddingOf(context), + onCompose: () => unawaited(context.push(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 Function() onRefresh; + final VoidCallback onRetry; + final VoidCallback onRetryMailboxes; + final VoidCallback? onLeave; + final VoidCallback onLoadMore; + final ValueChanged onRowTap; + final ValueChanged 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 [MailListSkeleton()], + ); + } +} + +/// 빈 메일함 · 받지 못함 — 화면 높이를 채우고도 당겨서 새로고침이 되게 스크롤 안에 둔다 +class _FillViewport extends StatelessWidget { + const _FillViewport({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + 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 onRowTap; + final ValueChanged 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 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(message.id), + view: mailRowViewOf(message, role: role, now: now), + selected: list.selectedIds.contains(message.id), + onTap: () => onRowTap(message), + onLongPress: () => onRowSelect(message), + onAvatarTap: () => onRowSelect(message), + ); + }, + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/providers/mail_list_controller.dart b/flutter/lib/features/mail/presentation/providers/mail_list_controller.dart new file mode 100644 index 0000000..419ca75 --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_list_controller.dart @@ -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 +mailListProvider = NotifierProvider.autoDispose + .family(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 { + MailListController(this.route); + + /// 주소의 메일함 id — null 이면 받은편지함 + final String? route; + + int _listSeq = 0; + int _localGeneration = 0; + final Map _pendingRead = {}; + final Map _pendingFlagged = {}; + + /// 읽음 · 중요 표시 요청마다 하나씩 — 보내는 중인 표시의 주인 + int _flagRequestSeq = 0; + + /// 목록 화면이 보인다 — 다른 탭 · 전체 화면 · 위에 쌓인 메일함 아래에 가렸으면 거짓 + bool _visible = false; + + /// 가려진 동안 메일이 바뀌었다 — 다시 보이면 맞춘다 + bool _dirty = false; + MailStateWatcher? _watcher; + + /// 도는 맞추기 — 끝날 때까지 새 맞추기는 이것에 묶는다 + Future? _syncRun; + + /// 맞추는 동안 또 맞춰 달라고 했다 + bool _syncAgain = false; + + /// 서버 시계 − 기기 시계 — 받은 목록 응답의 `Date` 로 잰다(붙잡아 둔 발송이 풀릴 때를 서버 시계로 잰다) + Duration _serverOffset = Duration.zero; + Timer? _heldTimer; + final Map _heldRechecks = {}; + + 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(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 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 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 _openFirst( + String mailboxId, + int seq, { + required bool silent, + }) async { + final Ref ref = this.ref; + final bool keepVisible = !silent; + try { + final MailPage 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 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 page = await _repository.listMessages( + mailboxId: mailboxId, + cursor: cursor, + ); + if (!ref.mounted || seq != _listSeq) return; + _noteServerTime(page); + // 그사이 새 메일이 와서 경계가 밀렸을 수 있다 — 이미 있는 메일은 건너뛴다 + final Set known = { + for (final MessageSummary message in state.items) message.id, + }; + final List added = [ + 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([ + ...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 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 _sync() { + final Future? running = _syncRun; + if (running != null) { + _syncAgain = true; + return running; + } + final Future run = _runSyncs(ref); + _syncRun = run; + return run; + } + + Future _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 _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 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 next = {...state.selectedIds}; + if (!next.remove(id)) next.add(id); + state = state.copyWith(selectedIds: Set.unmodifiable(next)); + return true; + } + + /// 고르기를 끝낸다 — ✕ · 뒤로 가기 · 목록이 가려짐 + void clearSelection() { + if (!ref.mounted || state.selectedIds.isEmpty) return; + state = state.copyWith(selectedIds: const {}); + } + + MessageSummary? _find(String id) { + for (final MessageSummary message in state.items) { + if (message.id == id) return message; + } + return null; + } + + // ── 여러 통 처리 ───────────────────────────────────── + + /// [읽음 표시] — 고른 것이 모두 읽음이면 안 읽음으로 + Future toggleSelectedRead() async { + if (!ref.mounted) return; + await _patchSelectedFlags(( + isRead: !state.allSelectedRead, + isFlagged: null, + )); + } + + /// [중요 표시] — 고른 것이 모두 중요면 해제 + Future toggleSelectedFlag() async { + if (!ref.mounted) return; + await _patchSelectedFlags(( + isRead: null, + isFlagged: !state.allSelectedFlagged, + )); + } + + /// 고른 메일의 읽음 · 중요 표시 — 화면을 먼저 바꾸고 고르기를 끝낸다. + /// 서버가 바꾸지 못한 메일 · 보내지 못한 메일만 바꾼 칸을 되돌리고(그사이 보내는 중인 다른 표시는 다시 입힌다), + /// 통째로 실패했으면 고른 것도 되살린다. 알림은 띄우지 않는다(목록이 바로 바뀐다 — 요청 실패는 인터셉터가 알린다) + Future _patchSelectedFlags(_FlagChange change) async { + final Ref ref = this.ref; + if (!ref.mounted) return; + final List 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 ids = [ + for (final MessageSummary message in targets) message.id, + ]; + final Map before = { + 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 {}); + final int owner = ++_flagRequestSeq; + _beginPending(ids, change, owner); + + final MailRepository repository = _repository; + final BulkResult result = await sendInChunks(ids, kMailBulkMaxIds, ( + List 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 revertIds = { + ...result.skippedIds, + ...result.unsentIds, + }; + final List reverted = [ + 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([ + 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 items = state.items; + state = state + .copyWith( + selectedIds: Set.unmodifiable({ + ...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 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 _applyFlags( + List items, + Set ids, + _FlagChange change, + ) => [ + for (final MessageSummary message in items) + ids.contains(message.id) + ? message.copyWith(isRead: change.isRead, isFlagged: change.isFlagged) + : message, + ]; + + void _beginPending(List 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 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 _withPendingFlags(List items) { + if (_pendingRead.isEmpty && _pendingFlagged.isEmpty) return items; + return [ + 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 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 moveSelected(Mailbox target) async { + final Ref ref = this.ref; + if (!ref.mounted || state.busy) return; + final List 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 origins = { + 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( + [for (final MessageSummary message in targets) message.id], + kMailBulkMaxIds, + (List 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 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 purgeSelected() async { + final Ref ref = this.ref; + if (!ref.mounted || state.busy) return; + final List 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( + [for (final MessageSummary message in targets) message.id], + kMailBulkMaxIds, + (List 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 {}, + ); + if (result.hasLeftovers) unawaited(_sync()); + } + + /// 목록에서 뺀다 — 전체 수도 그만큼 줄인다 + void _removeFromList(Set ids) { + if (ids.isEmpty) return; + final List kept = [ + 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 _restoreMoved( + List 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 done = []; + final List skipped = []; + for (final BulkOrigin group in origins) { + final BulkResult result = await sendInChunks(group.ids, kMailBulkMaxIds, ( + List 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)); + } +} diff --git a/flutter/lib/features/mail/presentation/providers/mail_list_state.dart b/flutter/lib/features/mail/presentation/providers/mail_list_state.dart new file mode 100644 index 0000000..3290f4e --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_list_state.dart @@ -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 [], + this.pageInfo, + this.error, + this.refreshing = false, + this.loadingMore = false, + this.loadMoreFailed = false, + this.selectedIds = const {}, + this.busy = false, + }); + + final MailListStatus status; + + /// 보는 메일함 — 받은편지함 주소(`/mail`)는 메일함 목록을 받기 전까지 null + final String? mailboxId; + final List items; + final MailPageInfo? pageInfo; + + /// 첫 쪽을 받지 못한 까닭 (코드 · 추적 ID) + final ApiException? error; + + /// 당겨서 새로고침하는 중 + final bool refreshing; + + /// 다음 쪽을 받는 중 + final bool loadingMore; + + /// 다음 쪽을 받지 못했다 — 끝에 [다시 시도] + final bool loadMoreFailed; + + /// 고른 메일 id — 아직 보내지 않은 메일(붙잡힌 발송)은 들지 않는다 + final Set selectedIds; + + /// 옮기기 · 지우기를 하는 중 — 끝날 때까지 다시 누르지 못한다(읽음 · 중요 표시는 막지 않는다) + final bool busy; + + /// 다음 쪽이 있다 + bool get hasMore => pageInfo?.hasMore ?? false; + + /// 여러 통 고르기 중 + bool get selecting => selectedIds.isNotEmpty; + + /// 고른 메일 — 목록 차례 그대로 + List get selectedMessages => [ + for (final MessageSummary message in items) + if (selectedIds.contains(message.id) && !message.isHeld) message, + ]; + + int get selectedCount => selectedMessages.length; + + /// 고른 메일이 모두 읽음 — [읽음 표시]가 [안 읽음 표시]가 된다 + bool get allSelectedRead { + final List selected = selectedMessages; + return selected.isNotEmpty && + selected.every((MessageSummary message) => message.isRead); + } + + /// 고른 메일이 모두 중요 — [중요 표시]가 [중요 해제]가 된다 + bool get allSelectedFlagged { + final List selected = selectedMessages; + return selected.isNotEmpty && + selected.every((MessageSummary message) => message.isFlagged); + } + + MailListState copyWith({ + MailListStatus? status, + String? mailboxId, + bool? refreshing, + bool? loadingMore, + bool? loadMoreFailed, + Set? 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 items, { + MailPageInfo? pageInfo, + MailListStatus status = MailListStatus.ready, + }) { + final Set selectable = { + for (final MessageSummary message in items) + if (!message.isHeld) message.id, + }; + final Set kept = selectedIds.where(selectable.contains).toSet(); + return MailListState( + status: status, + mailboxId: mailboxId, + items: List.unmodifiable(items), + pageInfo: pageInfo ?? this.pageInfo, + refreshing: refreshing, + loadingMore: loadingMore, + loadMoreFailed: loadMoreFailed, + selectedIds: kept.length == selectedIds.length + ? selectedIds + : Set.unmodifiable(kept), + busy: busy, + ); + } + + /// 첫 쪽을 받지 못했다 + MailListState failed(ApiException error) => MailListState( + status: MailListStatus.failed, + mailboxId: mailboxId, + error: error, + ); +} diff --git a/flutter/lib/features/mail/presentation/providers/mail_providers.dart b/flutter/lib/features/mail/presentation/providers/mail_providers.dart new file mode 100644 index 0000000..d37d490 --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_providers.dart @@ -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 mailRepositoryProvider = + Provider( + (Ref ref) => MailRepository(ref.watch(dioProvider)), + ); + +/// 메일을 보는 직원 — 로그인했고 제한이 없을 때 그 주소, 그 밖에는 null. +/// +/// 메일 모듈의 상태(메일함 · 목록 · 알림 · 새 메일 확인)는 이것을 듣고 바뀌면 처음부터 다시 만든다 — +/// 로그아웃 · 세션 끝 · 다른 직원 로그인 뒤에 앞 직원의 메일이 남지 않게. 로그아웃은 늘 null 을 거치므로 같은 직원이 다시 들어와도 새로 시작한다. +final Provider mailAccountProvider = Provider((Ref ref) { + final SessionState? session = ref.watch(sessionProvider).value; + return session is SignedIn && session.info.restriction == null + ? session.info.email + : null; +}); diff --git a/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart b/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart new file mode 100644 index 0000000..c4413ce --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart @@ -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 mailStateWatcherProvider = + NotifierProvider.autoDispose( + MailStateWatcher.new, + ); + +/// 새 메일 확인 (계약 2절 "새 메일 확인" · 웹 3단계 "새 메일" — `useMailStatePolling` 과 같은 결) +/// +/// - 메일 목록 화면이 보이고([setViewing]) 앱이 앞에 있는 동안만 1분마다 `GET /api/mail-state` 를 묻는다. 한 번에 하나씩. +/// - 다시 보이게 되면(앱이 앞으로 옴 · 다른 탭이나 전체 화면에서 돌아옴) 마지막으로 물은 지 10초가 지났으면 바로 한 번, 아니면 원래 차례에. +/// - 처음 받은 값은 기준으로만 쓴다. 값이 바뀌면 [MailChange] 를 하나 올린다 — 목록은 불러온 만큼 다시 받아 맞추고, 메일함은 개수를 맞춘다. +/// - 실패(`MAIL_001` · 연결 끊김)는 알리지 않고 다음 차례에 다시 묻는다. 401 은 인증 인터셉터의 세션 흐름을 그대로 탄다. +/// - 이 앱의 목록이 메일을 옮기거나 되돌리면 [markChanged] 로 다른 목록에 알린다(그 목록이 보일 때 맞춘다). +/// - 푸시(새 메일 알림)는 다음 차례다. +class MailStateWatcher extends Notifier { + final Set _viewers = {}; + 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(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 _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); + } + } + } +} diff --git a/flutter/lib/features/mail/presentation/providers/mail_toast_provider.dart b/flutter/lib/features/mail/presentation/providers/mail_toast_provider.dart new file mode 100644 index 0000000..7c9e9ac --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_toast_provider.dart @@ -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 mailNoticeSinkProvider = + Provider((Ref ref) { + final GlobalKey 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 mailToastProvider = + NotifierProvider.autoDispose( + MailToastNotifier.new, + ); + +/// 메일 화면의 알림. +/// +/// - 단추가 있는 알림([offer])은 화면 아래 줄 하나 — 새 단추 알림이 앞의 것을 바꾼다. 10초 뒤 닫는다. +/// 화면 낭독기를 쓰는 동안은 저절로 닫지 않고, 끄면 그때부터 10초를 센다(Flutter SnackBar 와 같은 결). +/// - 단추 없는 알림([notify] — "고를 수 없습니다" · 영구 삭제 결과 등)은 전역 알림 자리에 띄운다 — +/// 떠 있는 [되돌리기]를 바꿔 끼우지 않아 되돌릴 길을 잃지 않는다(2026-11-06 리뷰에서 정함). +/// - API 실패는 여기 두지 않는다 — 인터셉터의 전역 알림이 띄운다. +/// - 단추는 한 번만 돈다(두 번 눌러도 — 웹 `takeAction`). +class MailToastNotifier extends Notifier { + Timer? _timer; + Future Function()? _action; + int _seq = 0; + + /// 화면 낭독기를 쓰는 중 — 단추 알림을 저절로 닫지 않는다 + bool _holding = false; + + @override + MailToast? build() { + ref.watch(mailAccountProvider); + _cancelTimer(); + _action = null; + _holding = ref.read(accessibleNavigationProvider); + ref.listen(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 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 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; + } +} diff --git a/flutter/lib/features/mail/presentation/providers/mailboxes_provider.dart b/flutter/lib/features/mail/presentation/providers/mailboxes_provider.dart new file mode 100644 index 0000000..b08d6b8 --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mailboxes_provider.dart @@ -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 [], + this.error, + }); + + final MailboxesStatus status; + final List 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 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 mailboxesProvider = + NotifierProvider.autoDispose( + MailboxesNotifier.new, + ); + +/// 메일함 목록 — `GET /api/mailboxes` (계약 2절) +/// +/// - 처음 받지 못하면 목록 화면이 받지 못함 + [다시 시도]를 그린다. 이미 받은 뒤의 다시 받기는 실패해도 조용히 둔다(웹과 같다). +/// - 새 메일 확인이 바뀜을 알리면 개수를 다시 맞춘다. 읽음 표시는 개수를 먼저 바꾸고 다음 조회가 서버 기준으로 맞춘다. +/// - 늦게 온 응답은 버린다(나중에 보낸 요청의 응답만 쓴다). +class MailboxesNotifier extends Notifier { + int _seq = 0; + + @override + MailboxesState build() { + final String? account = ref.watch(mailAccountProvider); + _seq++; + // 새 메일 확인 · 다른 목록의 옮기기가 바뀜을 알리면 개수를 맞춘다 (처음부터 다시 만들 때의 0 은 바뀜이 아니다) + ref.listen(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 reload() { + if (!ref.mounted) return Future.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 mailboxIds, int delta) { + if (!ref.mounted || delta == 0) return; + state = MailboxesState( + status: state.status, + error: state.error, + items: [ + 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 _load(int seq) async { + final Ref ref = this.ref; + try { + final List items = await ref + .read(mailRepositoryProvider) + .listMailboxes(); + if (!ref.mounted || seq != _seq) return; + state = MailboxesState( + status: MailboxesStatus.ready, + items: List.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), + ); + } + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_compose_area.dart b/flutter/lib/features/mail/presentation/widgets/mail_compose_area.dart new file mode 100644 index 0000000..32704d3 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_compose_area.dart @@ -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: [ + 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()!; + 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: [ + 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); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_list_app_bar.dart b/flutter/lib/features/mail/presentation/widgets/mail_list_app_bar.dart new file mode 100644 index 0000000..741a0b3 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_list_app_bar.dart @@ -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()!; + 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: [ + 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: [ + 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()!; + 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: [ + 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, + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart b/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart new file mode 100644 index 0000000..24e2cc9 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart @@ -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()!; + return _StateLayout( + icon: mailboxIconOf(role), + iconColor: extra.textMuted, + iconBackground: theme.colorScheme.surfaceContainerHigh, + title: MailListTexts.emptyTitle(mailboxName), + children: [ + 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()!; + 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: [ + 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 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: [ + 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: [ + for (int index = 0; index < _skeletonRows; index++) + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s12, + AppSpacing.s14, + AppSpacing.s16, + AppSpacing.s14, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: AppSize.avatar, + height: AppSize.avatar, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: bar, + ), + ), + const SizedBox(width: AppSpacing.s10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FractionallySizedBox( + widthFactor: _skeletonWhoWidth, + child: _SkeletonBar(color: bar), + ), + const SizedBox(height: AppSpacing.s8), + FractionallySizedBox( + widthFactor: _skeletonSubjectWidth, + child: _SkeletonBar(color: bar), + ), + const SizedBox(height: AppSpacing.s8), + _SkeletonBar(color: bar), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +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()!; + 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: [ + 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 createState() => _MailListFooterState(); +} + +class _MailListFooterState extends State { + @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, + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_move_sheet.dart b/flutter/lib/features/mail/presentation/widgets/mail_move_sheet.dart new file mode 100644 index 0000000..f06a9db --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_move_sheet.dart @@ -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 showMailboxPickerSheet( + BuildContext context, { + required List targets, +}) { + final ThemeData theme = Theme.of(context); + return showModalBottomSheet( + 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 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: [ + 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: [ + for (final MailboxNode node in targets) + MailboxTile( + node: node, + selected: false, + onTap: () => Navigator.of(context).pop(node.mailbox), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mailbox_drawer.dart b/flutter/lib/features/mail/presentation/widgets/mailbox_drawer.dart new file mode 100644 index 0000000..5cbc239 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mailbox_drawer.dart @@ -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 showMailboxDrawer( + BuildContext context, { + required String? currentMailboxId, +}) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MailDrawerTexts.close, + barrierColor: Theme.of(context).colorScheme.scrim, + transitionDuration: _drawerDuration, + pageBuilder: + ( + BuildContext dialogContext, + Animation animation, + Animation secondaryAnimation, + ) => Align( + alignment: AlignmentDirectional.centerStart, + child: MailboxDrawer(currentMailboxId: currentMailboxId), + ), + transitionBuilder: + ( + BuildContext dialogContext, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) => SlideTransition( + position: Tween(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: [ + 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()!; + final ApiException? failure = mailboxes.failure; + if (failure != null) { + return Padding( + padding: const EdgeInsets.all(AppSpacing.s24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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 system, List mine}) groups = + splitMailboxGroups(mailboxes.ordered); + return ListView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s12, + AppSpacing.s4, + AppSpacing.s12, + AppSpacing.s12, + ), + children: [ + 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) ...[ + 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()!; + 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: [ + 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.tabularFigures(), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mailbox_icons.dart b/flutter/lib/features/mail/presentation/widgets/mailbox_icons.dart new file mode 100644 index 0000000..8856c11 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mailbox_icons.dart @@ -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, +}; diff --git a/flutter/lib/features/mail/presentation/widgets/message_row.dart b/flutter/lib/features/mail/presentation/widgets/message_row.dart new file mode 100644 index 0000000..1c757af --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/message_row.dart @@ -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()!; + 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: [ + 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 _tabular = [ + FontFeature.tabularFigures(), + ]; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final AppExtraColors extra = theme.extension()!; + 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: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + 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: [ + 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, + ), + ], + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/confirm_dialog.dart b/flutter/lib/shared/widgets/confirm_dialog.dart new file mode 100644 index 0000000..f55b3f8 --- /dev/null +++ b/flutter/lib/shared/widgets/confirm_dialog.dart @@ -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 showConfirmDialog( + BuildContext context, { + required String title, + required String message, + required String confirmLabel, + required String cancelLabel, + bool danger = false, +}) async { + final bool? confirmed = await showDialog( + 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: [ + 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), + ), + ], + ); + } +} diff --git a/flutter/test/app_test.dart b/flutter/test/app_test.dart index 4a84e83..9f1d5fa 100644 --- a/flutter/test/app_test.dart +++ b/flutter/test/app_test.dart @@ -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); } }); diff --git a/flutter/test/core/router/app_shell_test.dart b/flutter/test/core/router/app_shell_test.dart new file mode 100644 index 0000000..dadfbf7 --- /dev/null +++ b/flutter/test/core/router/app_shell_test.dart @@ -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 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({ + ...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); + }); +} diff --git a/flutter/test/core/router/shell_bar_test.dart b/flutter/test/core/router/shell_bar_test.dart new file mode 100644 index 0000000..66a5c8b --- /dev/null +++ b/flutter/test/core/router/shell_bar_test.dart @@ -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 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); + }); +} diff --git a/flutter/test/features/auth/login_pages_test.dart b/flutter/test/features/auth/login_pages_test.dart index ea5118e..58eaddc 100644 --- a/flutter/test/features/auth/login_pages_test.dart +++ b/flutter/test/features/auth/login_pages_test.dart @@ -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 || + value is bool; /// 프로바이더 값 · 실패를 모두 적어 둔다 — 비밀이 상태에 들어가지 않는지 본다 final class _ValueRecorder extends ProviderObserver { diff --git a/flutter/test/features/mail/mail_list_controller_test.dart b/flutter/test/features/mail/mail_list_controller_test.dart new file mode 100644 index 0000000..8df985e --- /dev/null +++ b/flutter/test/features/mail/mail_list_controller_test.dart @@ -0,0 +1,1035 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/utils/accessible_navigation.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/core/utils/provider_retry.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/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_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'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; +import '../../support/provider_harness.dart'; + +/// 앱이 늘 앞에 있는 것으로 — 생명주기 시험은 화면 시험(`mail_list_page_test.dart`)이 한다 +class _Foreground extends AppForeground { + @override + bool build() => true; +} + +/// 화면 낭독기를 쓰지 않는 것으로 — 접근성 시험은 화면 시험이 한다 +class _NoScreenReader extends AccessibleNavigation { + @override + bool build() => false; +} + +/// 메일을 보는 직원 — 시험이 바꾼다(세션이 바뀜) +class _Account extends Notifier { + @override + String? build() => kMailTestAccount; + + void set(String? value) => state = value; +} + +final NotifierProvider<_Account, String?> _accountProvider = + NotifierProvider<_Account, String?>(_Account.new); + +/// 메일 목록 컨트롤러 — 계약 2절 목록 · 여러 통 한꺼번에 · 새 메일 확인 (웹 list · selection · bulk 와 같은 규칙) +void main() { + late ProviderHarness h; + late FakeMailBackend mail; + late ProviderContainer container; + + /// 단추 없는 알림(전역 알림 자리에 뜨는 것) + late List notices; + + setUp(() async { + h = ProviderHarness(); + mail = FakeMailBackend(h.server)..install(); + await h.prepare(); + await h.store.save(tokensFor(1, now: h.clock.now)); + notices = []; + container = ProviderContainer( + overrides: [ + ...h.overrides, + mailAccountProvider.overrideWith( + (Ref ref) => ref.watch(_accountProvider), + ), + appForegroundProvider.overrideWith(_Foreground.new), + accessibleNavigationProvider.overrideWith(_NoScreenReader.new), + mailNoticeSinkProvider.overrideWithValue(notices.add), + ], + retry: noAutomaticRetry, + ); + addTearDown(container.dispose); + }); + + MailListState stateOf([String? route]) => + container.read(mailListProvider(route)); + + MailListController controllerOf([String? route]) => + container.read(mailListProvider(route).notifier); + + /// 목록을 열고(화면이 보는 것처럼 구독한다) 첫 쪽이 끝날 때까지 기다린다 + Future open([String? route]) async { + final ProviderSubscription subscription = container.listen( + mailListProvider(route), + (MailListState? previous, MailListState next) {}, + ); + addTearDown(subscription.close); + // 알림 · 메일함 목록도 화면처럼 구독한다(autoDispose 가 사라지지 않게) + final ProviderSubscription toast = container.listen( + mailToastProvider, + (MailToast? previous, MailToast? next) {}, + ); + addTearDown(toast.close); + await pumpEventsUntil( + () => stateOf(route).status != MailListStatus.loading, + reason: '첫 쪽', + ); + return controllerOf(route); + } + + List idsOf([String? route]) => [ + for (final MessageSummary message in stateOf(route).items) message.id, + ]; + + MailToast? toast() => container.read(mailToastProvider); + + group('목록 · 이어 받기 · 새로고침', () { + test('받은편지함 주소는 메일함 목록을 받은 뒤 받은편지함의 첫 쪽(20통)을 받는다', () async { + mail.seed(kInboxId, 45); + + await open(); + + expect(stateOf().mailboxId, kInboxId); + expect(idsOf(), hasLength(20)); + expect(stateOf().hasMore, isTrue); + final RequestOptions request = mail.listRequests.single; + expect(request.queryParameters['mailboxId'], kInboxId); + expect(request.queryParameters['limit'], kMailPageSize); + expect(request.queryParameters.containsKey('cursor'), isFalse); + }); + + test('다음 쪽 — 커서를 그대로 보내고, 그사이 밀려 겹친 메일은 건너뛴다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + // 새 메일이 와서 경계가 한 통 밀렸다 — 다음 쪽의 첫 메일(m20)은 이미 있다 + mail.arrive(kInboxId, messageJson('new', mailboxId: kInboxId)); + + await controller.loadMore(); + + expect(mail.listRequests.last.queryParameters['cursor'], 'c:20'); + // 다음 쪽은 m20 ~ m39 — 이미 있는 m20 을 건너뛴다 + expect(idsOf(), hasLength(39)); + expect(idsOf().where((String id) => id == 'm20'), hasLength(1)); + expect(idsOf().last, 'm39'); + }); + + test('다음 쪽을 받지 못하면 끝에 [다시 시도] — 알림은 인터셉터가 띄운다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + + await controller.loadMore(); + + expect(stateOf().loadMoreFailed, isTrue); + expect(stateOf().loadingMore, isFalse); + expect(h.notifier.shownCodes, ['MAIL_001']); + mail.listFailure = null; + await controller.loadMore(); + expect(idsOf(), hasLength(40)); + expect(stateOf().loadMoreFailed, isFalse); + }); + + test('처음 받지 못하면 알리지 않고 받지 못함 — [다시 시도]로 다시 받는다', () async { + mail.seed(kInboxId, 3); + mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + + final MailListController controller = await open(); + + expect(stateOf().status, MailListStatus.failed); + expect(stateOf().error?.code, 'MAIL_001'); + expect(stateOf().error?.traceId, 'trace-MAIL_001'); + expect(h.notifier.shownCodes, isEmpty); + mail.listFailure = null; + await controller.retry(); + expect(stateOf().status, MailListStatus.ready); + expect(idsOf(), ['m1', 'm2', 'm3']); + }); + + test('메일함 목록을 받지 못하면 받은편지함도 받지 못함 — [다시 시도]는 메일함 목록부터', () async { + mail.seed(kInboxId, 2); + mail.mailboxesFailure = FakeResponse.fail(503, 'MAIL_001'); + + final MailListController controller = await open(); + + expect(stateOf().status, MailListStatus.failed); + expect(stateOf().error?.code, 'MAIL_001'); + expect(mail.listRequests, isEmpty); + mail.mailboxesFailure = null; + await controller.retry(); + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '받은편지함', + ); + expect(idsOf(), ['m1', 'm2']); + }); + + test('응답 모양이 계약과 다르면 SYS_001 로 받지 못함이다', () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({'items': 'nope'}), + ); + + await open(); + + expect(stateOf().status, MailListStatus.failed); + expect(stateOf().error?.code, 'SYS_001'); + }); + + test('당겨서 새로고침 — 메일함 개수와 첫 쪽을 다시 받고, 고른 것은 남은 메일만 둔다', () async { + mail.seed(kInboxId, 3); + final MailListController controller = await open(); + controller + ..toggleSelection('m1') + ..toggleSelection('m3'); + mail.messages[kInboxId]!.removeWhere( + (Map m) => m['id'] == 'm3', + ); + final int boxesBefore = h.server + .received('GET', MailApiPaths.mailboxes) + .length; + + await controller.refresh(); + + expect(idsOf(), ['m1', 'm2']); + expect(stateOf().selectedIds, {'m1'}); + expect(stateOf().refreshing, isFalse); + expect( + h.server.received('GET', MailApiPaths.mailboxes).length, + boxesBefore + 1, + ); + }); + + test('늦은 응답 — 새로고침 뒤에 도착한 앞 쪽 응답은 버린다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + final Completer gate = Completer(); + mail.listGate = gate; + final Future more = controller.loadMore(); + await pumpEventsUntil( + () => mail.listRequests.length == 2, + reason: '다음 쪽 요청', + ); + + await controller.refresh(); + gate.complete(); + await more; + + expect(idsOf(), hasLength(20), reason: '새로고침 전의 다음 쪽을 붙이지 않는다'); + expect(stateOf().loadingMore, isFalse); + }); + + test('메일함을 바꾸면(앞 목록이 사라지면) 앞 목록의 응답은 버린다', () async { + mail.seed(kClientsId, 2); + final Completer gate = Completer(); + mail.listGate = gate; + final ProviderSubscription subscription = container.listen( + mailListProvider(kClientsId), + (_, _) {}, + ); + await pumpEventsUntil( + () => mail.listRequests.isNotEmpty, + reason: '목록 요청', + ); + + subscription.close(); + await pumpEventsUntil( + () => !container.exists(mailListProvider(kClientsId)), + reason: '앞 목록이 사라짐', + ); + gate.complete(); + await Future.delayed(Duration.zero); + + expect(container.exists(mailListProvider(kClientsId)), isFalse); + // 다시 열면 처음부터 받는다 + await open(kClientsId); + expect(idsOf(kClientsId), ['m1', 'm2']); + expect(mail.listRequests, hasLength(2)); + }); + + test('세션이 바뀌면 목록 · 고른 것 · 알림을 비우고, 세션이 없는 동안은 묻지 않는다', () async { + mail.seed(kInboxId, 3); + mail.seed(kClientsId, 1, prefix: 'c'); + final MailListController controller = await open(); + await open(kClientsId); + controller.toggleSelection('m1'); + container + .read(mailToastProvider.notifier) + .offer('알림', actionLabel: MailBulkTexts.undo, onAction: () async {}); + final int requests = h.server.requests.length; + + container.read(_accountProvider.notifier).set(null); + + expect(stateOf().items, isEmpty); + expect(stateOf().selectedIds, isEmpty); + expect(stateOf(kClientsId).items, isEmpty); + expect(toast(), isNull); + expect(container.read(mailboxesProvider).items, isEmpty); + await Future.delayed(Duration.zero); + expect(h.server.requests, hasLength(requests)); + + // 다시 로그인하면 처음부터 받는다 + container.read(_accountProvider.notifier).set(kMailTestAccount); + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '다시 받음', + ); + expect(idsOf(), ['m1', 'm2', 'm3']); + expect(stateOf().selectedIds, isEmpty); + }); + }); + + group('고르기', () { + test('하나씩 고르고 푼다 — 아직 보내지 않은 메일은 고를 수 없다', () async { + mail.messages[kSentId] = >[ + messageJson('s1', mailboxId: kSentId, isRead: true), + messageJson( + 's2', + mailboxId: kSentId, + isRead: true, + pendingSend: pendingJson(kTestNow.add(const Duration(days: 1))), + ), + ]; + final MailListController controller = await open(kSentId); + + expect(controller.toggleSelection('s1'), isTrue); + expect(controller.toggleSelection('s2'), isFalse); + expect(controller.toggleSelection('없음'), isFalse); + expect(stateOf(kSentId).selectedIds, {'s1'}); + expect(stateOf(kSentId).selectedCount, 1); + controller.toggleSelection('s1'); + expect(stateOf(kSentId).selecting, isFalse); + controller + ..toggleSelection('s1') + ..clearSelection(); + expect(stateOf(kSentId).selecting, isFalse); + }); + + test('고른 뒤 붙잡히면(목록을 맞추니 보내기를 기다림) 고른 것에서 뺀다', () async { + mail.messages[kSentId] = >[ + messageJson('s1', mailboxId: kSentId, isRead: true), + ]; + final MailListController controller = await open(kSentId); + controller + ..setVisible(true) + ..toggleSelection('s1'); + mail.messages[kSentId]!.first['pendingSend'] = pendingJson( + kTestNow.add(const Duration(minutes: 5)), + ); + + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil( + () => stateOf(kSentId).items.first.isHeld, + reason: '맞추기', + ); + + expect(stateOf(kSentId).selecting, isFalse); + }); + }); + + group('여러 통 처리', () { + test('읽음 표시 — 먼저 바꾸고 고르기를 끝내며, failedIds 만 되돌린다', () async { + mail.seed(kInboxId, 3); + mail.failIds.add('m2'); + final MailListController controller = await open(); + controller + ..toggleSelection('m1') + ..toggleSelection('m2') + ..toggleSelection('m3'); + + final Future marking = controller.toggleSelectedRead(); + // 응답 전 — 화면이 먼저 바뀌고(안 읽은 수도) 고르기가 끝난다 + expect( + stateOf().items.every((MessageSummary message) => message.isRead), + isTrue, + ); + expect(stateOf().selecting, isFalse); + expect(container.read(mailboxesProvider).byId(kInboxId)!.unreadCount, 0); + await marking; + + expect(mail.patches.single, { + 'ids': ['m1', 'm2', 'm3'], + 'isRead': true, + }); + expect( + { + for (final MessageSummary message in stateOf().items) + message.id: message.isRead, + }, + {'m1': true, 'm2': false, 'm3': true}, + ); + expect(toast(), isNull, reason: '읽음 · 중요 표시는 알리지 않는다'); + expect(notices, isEmpty); + }); + + test('고른 것이 모두 읽음이면 안 읽음으로, 모두 중요면 해제로', () async { + mail.seed(kInboxId, 2, isRead: true); + for (final Map message in mail.messages[kInboxId]!) { + message['isFlagged'] = true; + } + final MailListController controller = await open(); + controller + ..toggleSelection('m1') + ..toggleSelection('m2'); + expect(stateOf().allSelectedRead, isTrue); + expect(stateOf().allSelectedFlagged, isTrue); + + await controller.toggleSelectedRead(); + controller + ..toggleSelection('m1') + ..toggleSelection('m2'); + await controller.toggleSelectedFlag(); + + expect(mail.patches.first['isRead'], isFalse); + expect(mail.patches.last['isFlagged'], isFalse); + }); + + test('요청이 통째로 실패하면 읽음 표시를 되돌린다 — 알림은 인터셉터가', () async { + mail.seed(kInboxId, 2); + mail.patchFailure = FakeResponse.fail(503, 'MAIL_001'); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + + await controller.toggleSelectedRead(); + + expect(stateOf().items.first.isRead, isFalse); + expect(h.notifier.shownCodes, ['MAIL_001']); + // 다시 해 볼 수 있게 고른 것을 되살린다 + expect(stateOf().selectedIds, {'m1'}); + }); + + test('이동 — 응답의 updatedIds 만 빼고, 알림의 [되돌리기]는 원래 메일함으로 옮긴다', () async { + mail.seed(kInboxId, 3); + mail.failIds.add('m2'); + final MailListController controller = await open(); + controller + ..setVisible(true) + ..toggleSelection('m1') + ..toggleSelection('m2'); + final Mailbox clients = container + .read(mailboxesProvider) + .byId(kClientsId)!; + + await controller.moveSelected(clients); + + expect(mail.patches.single, { + 'ids': ['m1', 'm2'], + 'mailboxId': kClientsId, + }); + expect(idsOf(), ['m2', 'm3']); + expect(stateOf().selecting, isFalse); + expect(stateOf().busy, isFalse); + expect(toast()?.text, '메일 1통을 거래처로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.'); + expect(toast()?.actionLabel, MailBulkTexts.undo); + // 그대로 둔 메일이 있으면 서버 기준으로 다시 맞춘다 + await pumpEventsUntil(() => mail.listRequests.length >= 2, reason: '맞추기'); + + container.read(mailToastProvider.notifier).runAction(toast()!.id); + expect(toast(), isNull, reason: '단추를 누르면 알림 줄은 닫힌다'); + await pumpEventsUntil(() => mail.patches.length == 2, reason: '되돌리기'); + await pumpEventsUntil(() => notices.isNotEmpty, reason: '되돌린 알림'); + + expect(mail.patches.last, { + 'ids': ['m1'], + 'mailboxId': kInboxId, + }); + expect(notices, ['메일 1통을 되돌렸습니다.']); + await pumpEventsUntil(() => idsOf().contains('m1'), reason: '되돌린 목록'); + }); + + test('[되돌리기]는 한 번만 — 두 번 눌러도 한 번 보낸다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + await controller.moveSelected( + container.read(mailboxesProvider).byId(kClientsId)!, + ); + final int id = toast()!.id; + + container.read(mailToastProvider.notifier) + ..runAction(id) + ..runAction(id); + await pumpEventsUntil(() => mail.patches.length == 2, reason: '되돌리기'); + await Future.delayed(Duration.zero); + + expect(mail.patches, hasLength(2)); + }); + + test('[되돌리기]는 만든 세션에서만 — 그사이 로그아웃했으면 보내지 않는다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + await controller.moveSelected( + container.read(mailboxesProvider).byId(kClientsId)!, + ); + + await h.store.clear(); + container.read(mailToastProvider.notifier).runAction(toast()!.id); + await Future.delayed(Duration.zero); + + expect(mail.patches, hasLength(1)); + }); + + test('통째로 실패하면 목록 · 고른 것을 그대로 둔다(다시 해 볼 수 있게)', () async { + mail.seed(kInboxId, 2); + mail.patchFailure = FakeResponse.fail(503, 'MAIL_001'); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + + await controller.moveSelected( + container.read(mailboxesProvider).byId(kClientsId)!, + ); + + expect(idsOf(), ['m1', 'm2']); + expect(stateOf().selectedIds, {'m1'}); + expect(toast(), isNull); + expect(h.notifier.shownCodes, ['MAIL_001']); + }); + + test('휴지통 밖의 [삭제]는 휴지통으로 옮긴다 — 영구 삭제 요청을 보내지 않는다', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + controller.toggleSelection('m2'); + + await controller.moveSelectedToTrash(); + + expect(mail.patches.single['mailboxId'], kTrashId); + expect(mail.deletions, isEmpty); + expect(idsOf(), ['m1']); + expect(toast()?.text, '메일 1통을 휴지통으로 옮겼습니다.'); + expect(mail.idsIn(kTrashId), ['m2']); + }); + + test('휴지통에서는 영구 삭제 — deletedIds 만 빼고 되돌리기는 없다', () async { + mail.seed(kTrashId, 3, prefix: 't'); + mail.failIds.add('t2'); + final MailListController controller = await open(kTrashId); + controller + ..toggleSelection('t1') + ..toggleSelection('t2') + ..toggleSelection('t3'); + + await controller.purgeSelected(); + + expect(mail.deletions.single, ['t1', 't2', 't3']); + expect(idsOf(kTrashId), ['t2']); + expect(stateOf(kTrashId).pageInfo?.total, 1); + expect(toast(), isNull, reason: '영구 삭제는 되돌릴 수 없다 — 단추 알림이 아니다'); + expect(notices, [ + '메일 2통을 영구 삭제했습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.', + ]); + }); + + test('100통씩 차례로 보낸다', () async { + mail.seed(kInboxId, 150); + final MailListController controller = await open(); + while (stateOf().hasMore) { + await controller.loadMore(); + } + for (final String id in idsOf()) { + controller.toggleSelection(id); + } + + await controller.moveSelectedToTrash(); + + expect( + mail.patches.map( + (Map body) => (body['ids']! as List).length, + ), + [100, 50], + ); + expect(idsOf(), isEmpty); + expect(toast()?.text, '메일 150통을 휴지통으로 옮겼습니다.'); + }); + + test('휴지통을 모르면(메일함 목록에 없음) 옮기지 않고 알린다', () async { + mail.mailboxes = >[ + mailboxJson(kInboxId, '받은편지함', role: 'inbox'), + ]; + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + + await controller.moveSelectedToTrash(); + + expect(mail.patches, isEmpty); + expect(h.notifier.shownCodes, ['BIZ_001']); + expect(stateOf().selectedIds, {'m1'}); + }); + }); + + group('새 메일 확인으로 맞추기', () { + test('바뀜을 알리면 보이는 목록을 불러온 만큼 다시 받아 맞춘다 — 고른 것은 그대로', () async { + mail.seed(kInboxId, 3); + final MailListController controller = await open(); + controller + ..setVisible(true) + ..toggleSelection('m2'); + mail.arrive(kInboxId, messageJson('new', mailboxId: kInboxId)); + mail.messages[kInboxId]!.removeWhere( + (Map m) => m['id'] == 'm3', + ); + + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil(() => idsOf().first == 'new', reason: '맞추기'); + + expect(idsOf(), ['new', 'm1', 'm2']); + expect(stateOf().selectedIds, {'m2'}); + expect(mail.listRequests.last.queryParameters['limit'], kMailPageSize); + }); + + test('가려진 동안의 바뀜은 다시 보일 때 맞춘다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.setVisible(false); + mail.arrive(kInboxId, messageJson('new', mailboxId: kInboxId)); + + container.read(mailStateWatcherProvider.notifier).markChanged(); + await Future.delayed(Duration.zero); + expect(mail.listRequests, hasLength(1)); + + controller.setVisible(true); + await pumpEventsUntil(() => idsOf().first == 'new', reason: '맞추기'); + }); + + test('스스로 바꾼 목록은 그 소식으로 다시 받지 않는다 — 다른 목록은 맞춘다', () async { + mail.seed(kInboxId, 2); + mail.seed(kClientsId, 1, prefix: 'c'); + final MailListController inbox = await open(); + final MailListController clients = await open(kClientsId); + inbox.setVisible(true); + clients.setVisible(true); + final int before = mail.listRequests.length; + inbox.toggleSelection('m1'); + + await inbox.moveSelected( + container.read(mailboxesProvider).byId(kClientsId)!, + ); + await pumpEventsUntil( + () => idsOf(kClientsId).contains('m1'), + reason: '거래처 목록', + ); + + expect( + mail.listRequests + .skip(before) + .map((RequestOptions r) => r.queryParameters['mailboxId']), + [kClientsId], + ); + }); + + test('맞추는 사이 이 목록을 바꿨으면 그 응답은 버리고 한 번 더 맞춘다', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + controller.setVisible(true); + final Completer gate = Completer(); + mail.listGate = gate; + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil(() => mail.listRequests.length == 2, reason: '맞추기'); + + controller.toggleSelection('m1'); + final Future flagging = controller.toggleSelectedFlag(); + gate.complete(); + await flagging; + await pumpEventsUntil( + () => mail.listRequests.length == 3, + reason: '한 번 더', + ); + await Future.delayed(Duration.zero); + + expect(stateOf().items.first.isFlagged, isTrue); + }); + }); + + group('경합 · 세션 · 되돌리기 (2026-11-06 리뷰)', () { + test('이어 받는 중 새로고침이 실패해도 끝 표시가 멈추지 않는다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + final Completer gate = Completer(); + mail.listGate = gate; + final Future more = controller.loadMore(); + await pumpEventsUntil( + () => mail.listRequests.length == 2, + reason: '다음 쪽 요청', + ); + expect(stateOf().loadingMore, isTrue); + mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + + await controller.refresh(); + gate.complete(); + await more; + + expect(stateOf().loadingMore, isFalse); + expect(stateOf().refreshing, isFalse); + expect(idsOf(), hasLength(20)); + mail.listFailure = null; + await controller.loadMore(); + expect(idsOf(), hasLength(40)); + }); + + test('여러 쪽을 불러온 뒤 맞춘 쪽이 메일함 끝까지면 그 뒤의 옛 메일 · 커서를 남기지 않는다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + await controller.loadMore(); + expect(idsOf(), hasLength(40)); + expect(stateOf().hasMore, isTrue); + controller.setVisible(true); + // 다른 곳에서 앞의 10통을 지웠다 — 서버에는 35통이 남았다 + mail.messages[kInboxId]!.removeRange(0, 10); + + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil(() => idsOf().length == 35, reason: '맞추기'); + + expect(mail.listRequests.last.queryParameters['limit'], 40); + expect(idsOf().first, 'm11'); + expect(idsOf().last, 'm45'); + expect(stateOf().hasMore, isFalse); + expect(stateOf().pageInfo?.nextCursor, isNull); + }); + + test('이어 받기가 맞추기보다 먼저 끝나면 그 맞추기 응답은 버리고 불러온 만큼 다시 맞춘다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + controller.setVisible(true); + final Completer gate = Completer(); + mail.listGate = gate; + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil(() => mail.listRequests.length == 2, reason: '맞추기'); + + await controller.loadMore(); + expect(idsOf(), hasLength(40)); + gate.complete(); + await pumpEventsUntil( + () => mail.listRequests.length == 4, + reason: '한 번 더', + ); + await Future.delayed(Duration.zero); + + expect(mail.listRequests[1].queryParameters['limit'], kMailPageSize); + expect(mail.listRequests[2].queryParameters['cursor'], 'c:20'); + expect(mail.listRequests[3].queryParameters['limit'], 40); + expect(idsOf(), hasLength(40)); + }); + + test('맞추기는 한 번에 하나 — 도는 동안 바뀜이 여러 번 오면 끝난 뒤 한 번만 더', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + controller.setVisible(true); + final Completer gate = Completer(); + mail.listGate = gate; + final MailStateWatcher watcher = container.read( + mailStateWatcherProvider.notifier, + ); + watcher.markChanged(); + await pumpEventsUntil(() => mail.listRequests.length == 2, reason: '맞추기'); + + watcher + ..markChanged() + ..markChanged(); + await Future.delayed(Duration.zero); + expect(mail.listRequests, hasLength(2), reason: '도는 동안은 새로 묻지 않는다'); + mail.arrive(kInboxId, messageJson('new', mailboxId: kInboxId)); + gate.complete(); + await pumpEventsUntil( + () => mail.listRequests.length == 3, + reason: '끝난 뒤 한 번 더', + ); + await pumpEventsUntil(() => idsOf().first == 'new', reason: '맞춤'); + await Future.delayed(Duration.zero); + + expect(mail.listRequests, hasLength(3)); + }); + + test('새로 붙은 메일 없이 같은 커서가 오면 끝에 [다시 시도] — 같은 커서로 저절로 다시 묻지 않는다', () async { + mail.seed(kInboxId, 25); + final MailListController controller = await open(); + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [messageJson('m1', mailboxId: kInboxId)], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'c:20', + 'total': 25, + }, + }), + ); + + await controller.loadMore(); + + expect(mail.listRequests, hasLength(2)); + expect(idsOf(), hasLength(20)); + expect(stateOf().loadingMore, isFalse); + expect(stateOf().loadMoreFailed, isTrue); + expect(stateOf().hasMore, isTrue); + expect(h.notifier.shownCodes, isEmpty); + }); + + group('되돌리기는 실패한 칸만', () { + /// 요청마다 따로 답한다 — 시험이 차례를 정한다 + late List> replies; + final FakeResponse updated = FakeResponse.ok({ + 'updatedIds': ['m1'], + 'failedIds': [], + }); + final FakeResponse failed = FakeResponse.ok({ + 'updatedIds': [], + 'failedIds': ['m1'], + }); + + setUp(() { + replies = >[]; + mail.seed(kInboxId, 1); + h.server.on('PATCH', MailApiPaths.messages, (RequestOptions options) { + mail.patches.add(options.data! as Map); + final Completer reply = Completer(); + replies.add(reply); + return reply.future; + }); + }); + + test('중요 표시가 먼저 끝난 뒤 읽음 표시가 실패하면 읽음만 되돌린다', () async { + final MailListController controller = await open(); + controller.toggleSelection('m1'); + final Future reading = controller.toggleSelectedRead(); + controller.toggleSelection('m1'); + final Future flagging = controller.toggleSelectedFlag(); + expect(stateOf().items.single.isRead, isTrue); + expect(stateOf().items.single.isFlagged, isTrue); + await pumpEventsUntil(() => replies.length == 2, reason: '두 요청'); + + replies[1].complete(updated); + await flagging; + replies[0].complete(failed); + await reading; + + expect(stateOf().items.single.isRead, isFalse, reason: '읽음만 실패했다'); + expect( + stateOf().items.single.isFlagged, + isTrue, + reason: '먼저 끝난 중요 표시는 그대로', + ); + }); + + test('되돌린 칸에 아직 보내는 중인 표시가 있으면 그 값을 다시 입힌다', () async { + final MailListController controller = await open(); + // 읽음 → 안 읽음 → 읽음을 잇달아 누른다(셋 다 보내는 중) + final List> marks = >[]; + for (int n = 0; n < 3; n++) { + controller.toggleSelection('m1'); + marks.add(controller.toggleSelectedRead()); + } + expect(stateOf().items.single.isRead, isTrue); + await pumpEventsUntil(() => replies.length == 3, reason: '세 요청'); + expect( + mail.patches.map((Map b) => b['isRead']), + [true, false, true], + ); + + replies[0].complete(failed); + await marks[0]; + + expect( + stateOf().items.single.isRead, + isTrue, + reason: '마지막으로 보낸 읽음이 아직 오가는 중이다', + ); + replies[1].complete(updated); + replies[2].complete(updated); + await Future.wait(marks); + expect(stateOf().items.single.isRead, isTrue); + }); + }); + + test('읽음 표시 중에 목록이 사라져도 안 읽은 수를 되돌리고 다른 목록에 알린다', () async { + mail.seed(kInboxId, 2); + mail.failIds.add('m2'); + final ProviderSubscription boxes = container.listen( + mailboxesProvider, + (_, _) {}, + ); + addTearDown(boxes.close); + final ProviderSubscription changes = container.listen( + mailStateWatcherProvider, + (_, _) {}, + ); + addTearDown(changes.close); + final ProviderSubscription list = container.listen( + mailListProvider(null), + (_, _) {}, + ); + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '첫 쪽', + ); + int inboxUnread() => + container.read(mailboxesProvider).byId(kInboxId)!.unreadCount; + expect(inboxUnread(), 3); + final Completer gate = Completer(); + mail.patchGate = gate; + final MailListController controller = controllerOf() + ..toggleSelection('m1') + ..toggleSelection('m2'); + final Future marking = controller.toggleSelectedRead(); + expect(inboxUnread(), 1); + + list.close(); + await pumpEventsUntil( + () => !container.exists(mailListProvider(null)), + reason: '목록이 사라짐', + ); + // 바뀐 소식으로 메일함 개수를 다시 받는 것은 문에서 붙잡아 둔다 — 되돌린 값을 보려고 + final Completer boxesGate = Completer(); + mail.mailboxesGate = boxesGate; + addTearDown(boxesGate.complete); + gate.complete(); + await marking; + + expect(inboxUnread(), 2, reason: '바꾸지 못한 m2 만큼 되돌린다'); + expect(container.read(mailStateWatcherProvider).seq, 1); + }); + + test('세션이 바뀌는 사이 끝난 옮기기는 새 세션에 알리지 않는다 — [되돌리기]도 없다', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + final Completer gate = Completer(); + mail.patchGate = gate; + controller.toggleSelection('m1'); + final Future moving = controller.moveSelectedToTrash(); + await pumpEventsUntil(() => mail.patches.isNotEmpty, reason: '옮기기 요청'); + + // 로그아웃한 뒤 다른 직원이 로그인했다 + await h.store.clear(); + container.read(_accountProvider.notifier).set(null); + await h.store.save(tokensFor(2, now: h.clock.now)); + container.read(_accountProvider.notifier).set('other@arasystem.it.kr'); + gate.complete(); + await moving; + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '새 세션의 목록', + ); + + expect(toast(), isNull); + expect(notices, isEmpty); + expect(container.read(mailStateWatcherProvider).seq, 0); + }); + + test('휴지통을 모르다가 메일함 목록을 다시 받아 찾으면 그리로 옮긴다', () async { + mail.mailboxes = >[ + mailboxJson(kInboxId, '받은편지함', role: 'inbox'), + ]; + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + mail.mailboxes = standardMailboxes(); + + await controller.moveSelectedToTrash(); + + expect(mail.patches.single['mailboxId'], kTrashId); + expect(h.notifier.shownCodes, isEmpty); + expect(idsOf(), isEmpty); + expect(toast()?.text, '메일 1통을 휴지통으로 옮겼습니다.'); + }); + + test('목록이 가려지면(다른 메일함 · 다른 탭 · 전체 화면) 고른 것을 푼다', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + controller + ..setVisible(true) + ..toggleSelection('m1'); + + controller.setVisible(false); + + expect(stateOf().selecting, isFalse); + }); + + test('다른 직원으로 바뀌어 새 메일 확인이 처음부터 다시 만들어져도 보이는 목록이 있으면 다시 묻는다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + final ProviderSubscription watching = container.listen( + mailStateWatcherProvider, + (_, _) {}, + ); + addTearDown(watching.close); + controller.setVisible(true); + await pumpEventsUntil(() => mail.stateRequests == 1, reason: '기준'); + + container.read(_accountProvider.notifier).set('other@arasystem.it.kr'); + + await pumpEventsUntil(() => mail.stateRequests == 2, reason: '다시 물음'); + }); + + test('세션이 없는 동안 새 메일 확인이 사라졌다 다시 생겨도 보이는 목록은 새 것에 다시 알린다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.setVisible(true); + await pumpEventsUntil(() => mail.stateRequests == 1, reason: '기준'); + + container.read(_accountProvider.notifier).set(null); + await pumpEventsUntil( + () => !container.exists(mailStateWatcherProvider), + reason: '아무도 듣지 않아 사라짐', + ); + container.read(_accountProvider.notifier).set(kMailTestAccount); + + await pumpEventsUntil(() => mail.stateRequests == 2, reason: '새 것이 묻는다'); + }); + }); + + test('목록이 없어지면 새 메일 확인에서도 빠진다', () async { + mail.seed(kInboxId, 1); + final ProviderSubscription subscription = container.listen( + mailListProvider(null), + (_, _) {}, + ); + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '첫 쪽', + ); + controllerOf().setVisible(true); + await pumpEventsUntil(() => mail.stateRequests == 1, reason: '기준'); + final ProviderSubscription watcher = container.listen( + mailStateWatcherProvider, + (_, _) {}, + ); + addTearDown(watcher.close); + + subscription.close(); + await pumpEventsUntil( + () => !container.exists(mailListProvider(null)), + reason: '목록이 사라짐', + ); + await Future.delayed(Duration.zero); + + // 보는 목록이 없으니 새로 묻지 않는다 — 기준만 남는다 + expect(mail.stateRequests, 1); + expect(container.read(mailStateWatcherProvider).seq, 0); + }); +} diff --git a/flutter/test/features/mail/mail_list_page_test.dart b/flutter/test/features/mail/mail_list_page_test.dart new file mode 100644 index 0000000..9be493b --- /dev/null +++ b/flutter/test/features/mail/mail_list_page_test.dart @@ -0,0 +1,1085 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +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/data/repositories/mail_repository.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/mail/presentation/providers/mail_list_controller.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/widgets/mail_compose_area.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart'; +import 'package:arasystem_app/features/settings/settings_routes.dart'; +import 'package:arasystem_app/shared/widgets/placeholder_page.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; + +/// 메일 목록 화면 (앱 보드 `App1MailList`) — 로그인한 앱 전체를 가짜 서버로 띄워 본다 +void main() { + late MailAppHarness h; + + setUp(() => h = MailAppHarness()); + + /// 시간을 넘긴다 — 손 시계와 시험 시간을 함께 + Future elapse(WidgetTester tester, Duration duration) async { + h.clock.advance(duration); + await tester.pump(duration); + await tester.pumpAndSettle(); + } + + Finder row(String subject) => + find.ancestor(of: find.text(subject), matching: find.byType(MessageRow)); + + Finder avatarOf(String subject) => find.descendant( + of: row(subject), + matching: find.byType(MailAvatarButton), + ); + + Finder toastText(String text) => find.descendant( + of: find.byType(MailToastCard), + matching: find.text(text), + ); + + /// 단추 없는 알림 — 전역 알림 자리(스낵바)에 뜬다 + Finder noticeText(String text) => + find.descendant(of: find.byType(SnackBar), matching: find.text(text)); + + Future scrollToFooter(WidgetTester tester) async { + await tester.scrollUntilVisible( + find.byType(MailListFooter), + 500, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + } + + Future longPress(WidgetTester tester, String subject) async { + await tester.longPress(find.text(subject)); + await tester.pumpAndSettle(); + } + + MailListPage currentPage(WidgetTester tester) => + tester.widget(find.byType(MailListPage)); + + /// 위에 쌓아 연 전체 화면(읽기 · 쓰기 자리)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다 + String pushedLocation(WidgetTester tester) => GoRouterState.of( + tester.element(find.byType(PlaceholderPage).last), + ).uri.toString(); + + group('목록 줄', () { + testWidgets('받은편지함 — 보낸 사람 · 시각 · 제목 · 미리 보기, 안 읽음은 굵게 · 첨부 · 중요', ( + WidgetTester tester, + ) async { + h.mail.messages[kInboxId] = >[ + messageJson( + 'm1', + mailboxId: kInboxId, + fromName: '이도윤', + subject: '유지보수 계약', + preview: '계약서 초안을 고쳐 첨부했습니다.', + hasAttachment: true, + isFlagged: true, + ), + messageJson( + 'm2', + mailboxId: kInboxId, + fromName: '인사팀', + subject: '채용 일정', + isRead: true, + ), + ]; + await h.pump(tester); + + expect(find.text('받은편지함'), findsOneWidget); + expect(find.text(MailListTexts.unread(3)), findsOneWidget); + expect(find.text('이도윤'), findsOneWidget); + expect(find.text('계약서 초안을 고쳐 첨부했습니다.'), findsOneWidget); + expect(find.byIcon(Icons.attach_file), findsOneWidget); + expect(find.byIcon(Icons.star), findsOneWidget); + final Text unread = tester.widget(find.text('이도윤')); + final Text read = tester.widget(find.text('인사팀')); + expect(unread.style?.fontWeight, AppTypography.bold); + expect(read.style?.fontWeight, AppTypography.medium); + expect( + tester.getSemantics(row('유지보수 계약')).label, + startsWith('안 읽음, 이도윤, '), + ); + expect(tester.getSemantics(avatarOf('채용 일정')).label, '고르기: 채용 일정'); + }); + + testWidgets('줄을 누르면 읽기 화면(탭 막대 없는 전체 화면) — 읽음 처리는 하지 않는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + + await tester.tap(find.text('제목 m1')); + await tester.pumpAndSettle(); + + expect(pushedLocation(tester), MailRoutes.message('m1')); + expect(find.text(MailTexts.message), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + expect(h.mail.patches, isEmpty); + }); + + testWidgets('임시보관함 — 받는 사람을 보이고, 누르면 쓰기 화면에서 이어 쓴다', ( + WidgetTester tester, + ) async { + h.mail.messages[kDraftsId] = >[ + messageJson( + 'd1', + mailboxId: kDraftsId, + to: >[addressJson('박준호', 'junho@example.com')], + subject: '쓰던 메일', + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kDraftsId)); + + expect(find.text('박준호'), findsOneWidget); + await tester.tap(find.text('쓰던 메일')); + await tester.pumpAndSettle(); + + expect(pushedLocation(tester), '/mail/compose?draft=d1'); + }); + + testWidgets('보낸편지함 — 받는 사람과 보내는 중 · 예약 표시', (WidgetTester tester) async { + final DateTime sendAt = h.clock.now.add(const Duration(seconds: 8)); + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + to: >[addressJson('박준호', 'junho@example.com')], + subject: '보내는 메일', + isRead: true, + pendingSend: pendingJson(sendAt), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + + expect(find.text('받는 사람: 박준호'), findsOneWidget); + expect(find.text(MailListTexts.sending), findsOneWidget); + expect(find.text(MailListTexts.unread(0)), findsNothing); + }); + }); + + group('메일함 서랍', () { + testWidgets('탭 막대 위까지 덮고, 메일함을 고르면 그 목록 — 뒤로 가기는 받은편지함', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + h.mail.seed(kClientsId, 1, prefix: 'c'); + await h.pump(tester); + + await tester.tap(find.byTooltip(MailListTexts.openDrawer)); + await tester.pumpAndSettle(); + + expect(find.byType(MailboxDrawer), findsOneWidget); + expect(find.text(MailDrawerTexts.myMailboxes), findsOneWidget); + for (final String name in [ + '보낸편지함', + '임시보관함', + '스팸메일함', + '휴지통', + '거래처', + '프로젝트', + '설계', + ]) { + expect( + find.descendant( + of: find.byType(MailboxDrawer), + matching: find.text(name), + ), + findsOneWidget, + reason: name, + ); + } + // 서랍이 탭 막대를 덮는다 — 탭 막대 자리를 누르면 서랍 쪽이 받는다 + final Rect drawer = tester.getRect(find.byType(MailboxDrawer)); + final Rect bar = tester.getRect(find.byType(NavigationBar)); + expect(drawer.bottom, greaterThanOrEqualTo(bar.bottom)); + expect( + tester + .getSemantics( + find.descendant( + of: find.byType(MailboxDrawer), + matching: find.text('3'), + ), + ) + .label, + contains(MailDrawerTexts.unreadCount(3)), + ); + + await tester.tap( + find.descendant( + of: find.byType(MailboxDrawer), + matching: find.text('거래처'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(MailboxDrawer), findsNothing); + expect(h.location(), MailRoutes.mailbox(kClientsId)); + expect(currentPage(tester).mailboxId, kClientsId); + expect(find.text('제목 c1'), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(h.location(), MailRoutes.home); + expect(find.text('제목 m1'), findsOneWidget); + }); + + testWidgets('메일함 목록을 받지 못하면 서랍에 사전 문구와 [다시 시도] — 다시 받으면 메일함', ( + WidgetTester tester, + ) async { + h.mail.mailboxesFailure = FakeResponse.fail(503, 'MAIL_001'); + await h.pump(tester); + + await tester.tap(find.byTooltip(MailListTexts.openDrawer)); + await tester.pumpAndSettle(); + final Finder drawer = find.byType(MailboxDrawer); + expect( + find.descendant( + of: drawer, + matching: findCopy(ErrorLexicon.getMessage('MAIL_001')), + ), + findsOneWidget, + ); + h.mail.mailboxesFailure = null; + await tester.tap( + find.descendant(of: drawer, matching: find.text(MailListTexts.retry)), + ); + await tester.pumpAndSettle(); + + expect( + find.descendant(of: drawer, matching: find.text('거래처')), + findsOneWidget, + ); + }); + + testWidgets('받은편지함을 고르면 첫 화면 주소로 돌아간다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester, location: MailRoutes.mailbox(kClientsId)); + + await tester.tap(find.byTooltip(MailListTexts.openDrawer)); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(MailboxDrawer), + matching: find.text('받은편지함'), + ), + ); + await tester.pumpAndSettle(); + + expect(h.location(), MailRoutes.home); + }); + }); + + group('고르기', () { + testWidgets('길게 누르면 고르기 막대 — 탭 막대 · [메일 쓰기]를 숨기고 ✕ · 뒤로 가기로 나온다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 3); + await h.pump(tester); + + await longPress(tester, '제목 m1'); + + expect(find.text(MailBulkTexts.selected(1)), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + expect(find.text(MailListTexts.compose), findsNothing); + // 고르는 중에는 줄을 눌러도 고르기 · 풀기 — 열지 않는다 + await tester.tap(find.text('제목 m2')); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(2)), findsOneWidget); + expect(h.location(), MailRoutes.home); + await tester.tap(avatarOf('제목 m2')); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + expect(h.location(), MailRoutes.home); + + await tester.tap(avatarOf('제목 m3')); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsOneWidget); + await tester.tap(find.byTooltip(MailBulkTexts.clear)); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.text(MailListTexts.compose), findsOneWidget); + }); + + testWidgets('다른 탭으로 가면 고르기를 풀고 탭 막대가 보인다 — 돌아와도 그대로', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + await longPress(tester, '제목 m1'); + expect(find.byType(NavigationBar), findsNothing); + + h.router.go(SettingsRoutes.home); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + + h.router.go(MailRoutes.home); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('고르던 받은편지함 위에 다른 메일함을 열면 고르기를 풀고 그 화면에는 탭 막대가 있다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + await longPress(tester, '제목 m1'); + expect(find.byType(NavigationBar), findsNothing); + + h.router.go(MailRoutes.mailbox(kClientsId)); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.text(MailListTexts.compose), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(h.location(), MailRoutes.home); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('아직 보내지 않은 메일은 고를 수 없다 — 까닭을 알린다', (WidgetTester tester) async { + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + subject: '예약한 메일', + isRead: true, + pendingSend: pendingJson( + h.clock.now.add(const Duration(days: 1)), + kind: 'scheduled', + ), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + + await tester.tap(avatarOf('예약한 메일')); + await tester.pumpAndSettle(); + expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + + await longPress(tester, '예약한 메일'); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + final SemanticsNode avatar = tester.getSemantics(avatarOf('예약한 메일')); + expect(avatar.hint, MailListTexts.heldNotSelectable); + }); + + testWidgets('읽음 표시 — 먼저 굵은 글자가 풀리고 고르기를 끝낸다(알림 없음)', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + await longPress(tester, '제목 m1'); + expect(find.byTooltip(MailBulkTexts.markRead), findsOneWidget); + + await tester.tap(find.byTooltip(MailBulkTexts.markRead)); + await tester.pumpAndSettle(); + + expect(h.mail.patches.single['isRead'], isTrue); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(MailToastCard), findsNothing); + final Text who = tester.widget( + find.descendant(of: row('제목 m1'), matching: find.text('이도윤')), + ); + expect(who.style?.fontWeight, AppTypography.medium); + }); + }); + + group('삭제 · 되돌리기 · 영구 삭제 · 이동', () { + testWidgets('삭제는 휴지통으로 옮기고 알림에 [되돌리기] — [메일 쓰기]가 알림 위로 함께 올라간다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 3); + await h.pump(tester); + final double composeBottomBefore = tester + .getRect(find.text(MailListTexts.compose)) + .bottom; + await longPress(tester, '제목 m2'); + + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + + expect(h.mail.patches.single['mailboxId'], kTrashId); + expect(h.mail.deletions, isEmpty); + expect(find.text('제목 m2'), findsNothing); + expect(toastText('메일 1통을 휴지통으로 옮겼습니다.'), findsOneWidget); + final Rect toast = tester.getRect(find.byType(MailToastCard)); + final Rect compose = tester.getRect( + find.widgetWithText(FloatingActionButton, MailListTexts.compose), + ); + expect(compose.bottom, lessThan(toast.top)); + expect(compose.bottom, lessThan(composeBottomBefore)); + expect( + toast.bottom, + lessThan(tester.getRect(find.byType(NavigationBar)).top), + ); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + + expect(h.mail.patches.last, { + 'ids': ['m2'], + 'mailboxId': kInboxId, + }); + expect(noticeText('메일 1통을 되돌렸습니다.'), findsOneWidget); + expect(find.text('제목 m2'), findsOneWidget); + }); + + testWidgets('알림은 10초 뒤(단추가 없으면 5초 뒤) 저절로 닫힌다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + expect(find.byType(MailToastCard), findsOneWidget); + + await elapse( + tester, + kMailToastActionDuration - const Duration(seconds: 1), + ); + expect(find.byType(MailToastCard), findsOneWidget); + await elapse(tester, const Duration(seconds: 1)); + expect(find.byType(MailToastCard), findsNothing); + + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + await elapse(tester, kMailToastDuration - const Duration(seconds: 1)); + expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + await elapse(tester, const Duration(seconds: 1)); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets('단추 없는 알림은 [되돌리기] 알림을 바꾸지 않고 그 위(전역 알림 자리)에 뜬다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + + expect(toastText('메일 1통을 휴지통으로 옮겼습니다.'), findsOneWidget); + expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + final Rect snack = tester.getRect(find.byType(SnackBar)); + final Rect compose = tester.getRect( + find.widgetWithText(FloatingActionButton, MailListTexts.compose), + ); + expect(snack.bottom, lessThanOrEqualTo(compose.top)); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + expect(h.mail.patches.last['mailboxId'], kInboxId); + }); + + testWidgets('화면 낭독기를 쓰는 동안은 [되돌리기] 알림을 저절로 닫지 않는다 — 끄면 그때부터 10초', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + tester.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(accessibleNavigation: true); + addTearDown( + tester.platformDispatcher.clearAccessibilityFeaturesTestValue, + ); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + + await elapse(tester, kMailToastActionDuration * 3); + expect(find.byType(MailToastCard), findsOneWidget); + + tester.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(); + await tester.pump(); + await elapse( + tester, + kMailToastActionDuration - const Duration(seconds: 1), + ); + expect(find.byType(MailToastCard), findsOneWidget); + await elapse(tester, const Duration(seconds: 1)); + expect(find.byType(MailToastCard), findsNothing); + }); + + testWidgets('고르는 중(탭 막대가 숨음)의 알림 줄은 시스템 내비게이션 · 옆 안전 영역 안쪽에 뜬다', ( + WidgetTester tester, + ) async { + // 휴대폰의 안전 영역 — 위 24 · 아래 16 · 좌우 12 (물리 픽셀은 3배) + tester.view.padding = const FakeViewPadding( + left: 36, + top: 72, + right: 36, + bottom: 48, + ); + h.mail.seed(kInboxId, 3); + await h.pump(tester); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + expect(find.byType(MailToastCard), findsOneWidget); + + // 알림이 떠 있는 동안 다시 고른다 — 탭 막대가 숨어 화면 아래가 시스템 내비게이션 자리까지 내려온다 + await longPress(tester, '제목 m2'); + expect(find.byType(NavigationBar), findsNothing); + + final Rect toast = tester.getRect(find.byType(MailToastCard)); + expect( + toast.bottom, + moreOrLessEquals(kPhoneSize.height - 16 - AppSpacing.s12), + ); + expect(toast.left, moreOrLessEquals(12 + AppSpacing.s12)); + expect( + toast.right, + moreOrLessEquals(kPhoneSize.width - 12 - AppSpacing.s12), + ); + }); + + testWidgets('휴지통에서는 영구 삭제 — 확인 창(처음 초점 [취소]) 뒤에만 보낸다', ( + WidgetTester tester, + ) async { + h.mail.seed(kTrashId, 2, prefix: 't'); + await h.pump(tester, location: MailRoutes.mailbox(kTrashId)); + expect( + findCopy(MailListTexts.retentionNotice(MailboxRole.trash)!), + findsOneWidget, + ); + await longPress(tester, '제목 t1'); + expect(find.byTooltip(MailBulkTexts.purge), findsOneWidget); + + await tester.tap(find.byTooltip(MailBulkTexts.purge)); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.purgeTitle(1)), findsOneWidget); + expect(findCopy(MailBulkTexts.purgeMessage), findsOneWidget); + final BuildContext focused = FocusManager.instance.primaryFocus!.context!; + expect( + find.descendant( + of: find.byWidget(focused.widget), + matching: find.text(MailBulkTexts.purgeCancel), + ), + findsOneWidget, + ); + await tester.tap(find.text(MailBulkTexts.purgeCancel)); + await tester.pumpAndSettle(); + expect(h.mail.deletions, isEmpty); + expect(find.text(MailBulkTexts.selected(1)), findsOneWidget); + + await tester.tap(find.byTooltip(MailBulkTexts.purge)); + await tester.pumpAndSettle(); + await tester.tap( + find.widgetWithText(FilledButton, MailBulkTexts.purgeConfirm), + ); + await tester.pumpAndSettle(); + + expect(h.mail.deletions.single, ['t1']); + expect(h.mail.patches, isEmpty); + expect(find.text('제목 t1'), findsNothing); + expect(noticeText('메일 1통을 영구 삭제했습니다.'), findsOneWidget); + expect(find.text(MailBulkTexts.undo), findsNothing); + }); + + testWidgets('이동 — 시트에 옮길 수 있는 메일함만(지금 · 임시보관함 · 휴지통 빼고), 고르면 그리로', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + await longPress(tester, '제목 m1'); + + await tester.tap(find.byTooltip(MailBulkTexts.move)); + await tester.pumpAndSettle(); + + final Finder sheet = find.byType(BottomSheet); + expect( + find.descendant( + of: sheet, + matching: find.text(MailBulkTexts.moveSheetTitle), + ), + findsOneWidget, + ); + for (final String name in [ + '보낸편지함', + '스팸메일함', + '거래처', + '프로젝트', + '설계', + ]) { + expect( + find.descendant(of: sheet, matching: find.text(name)), + findsOneWidget, + reason: name, + ); + } + for (final String name in ['받은편지함', '임시보관함', '휴지통']) { + expect( + find.descendant(of: sheet, matching: find.text(name)), + findsNothing, + reason: name, + ); + } + await tester.tap(find.descendant(of: sheet, matching: find.text('거래처'))); + await tester.pumpAndSettle(); + + expect(h.mail.patches.single['mailboxId'], kClientsId); + expect(toastText('메일 1통을 거래처로 옮겼습니다.'), findsOneWidget); + }); + }); + + group('상태 · 새로고침 · 이어 받기', () { + testWidgets('빈 휴지통 — 30일 안내 글', (WidgetTester tester) async { + await h.pump(tester, location: MailRoutes.mailbox(kTrashId)); + + expect(findCopy('휴지통이 비어 있습니다'), findsOneWidget); + expect( + findCopy('삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.'), + findsOneWidget, + ); + expect(find.byType(MailRetentionNotice), findsNothing); + }); + + testWidgets('받지 못하면 사전 문구 · 문의 코드 · [다시 시도]', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + h.mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + await h.pump(tester); + + expect(findCopy(ErrorLexicon.getMessage('MAIL_001')), findsOneWidget); + expect(find.text(MailListTexts.traceCode('trace-MA')), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + + h.mail.listFailure = null; + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(find.text('제목 m1'), findsOneWidget); + }); + + testWidgets('없는 메일함 주소면 [받은편지함으로]', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester, location: MailRoutes.mailbox('gone')); + + expect(findCopy(ErrorLexicon.getMessage('RES_001')), findsOneWidget); + await tester.tap(find.text(MailListTexts.toInbox)); + await tester.pumpAndSettle(); + + expect(h.location(), MailRoutes.home); + expect(find.text('제목 m1'), findsOneWidget); + }); + + testWidgets('당겨서 새로고침 — 첫 쪽을 다시 받는다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + h.mail.arrive( + kInboxId, + messageJson('new', mailboxId: kInboxId, subject: '새로 온 메일'), + ); + + await tester.fling(find.text('제목 m1'), const Offset(0, 400), 1000); + await tester.pumpAndSettle(); + + expect(h.mail.listRequests, hasLength(2)); + expect(find.text('새로 온 메일'), findsOneWidget); + }); + + testWidgets('끝 근처에 닿으면 다음 쪽을 받는다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 45); + await h.pump(tester); + expect(h.mail.listRequests, hasLength(1)); + + await tester.scrollUntilVisible( + find.text('제목 m20'), + 500, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + expect(h.mail.listRequests, hasLength(2)); + expect(h.mail.listRequests.last.queryParameters['cursor'], 'c:20'); + await tester.scrollUntilVisible( + find.text('제목 m40'), + 500, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('제목 m40'), findsOneWidget); + }); + + testWidgets('받은 쪽이 모두 이미 있는 메일이어도 커서가 나아갔으면 끝 표시가 다음 쪽을 이어 받는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 45); + await h.pump(tester); + // 그사이 새 메일 20통이 와서 다음 쪽(c:20)이 모두 이미 보이는 메일이다 + for (int n = 20; n >= 1; n--) { + h.mail.arrive(kInboxId, messageJson('new$n', mailboxId: kInboxId)); + } + + await scrollToFooter(tester); + + expect( + h.mail.listRequests.map( + (RequestOptions r) => r.queryParameters['cursor'], + ), + [null, 'c:20', 'c:40'], + ); + expect(find.text('제목 m21'), findsOneWidget); + }); + + testWidgets('같은 커서가 새 메일 없이 다시 오면 끝에 [다시 시도] — 저절로 되풀이하지 않는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 25); + await h.pump(tester); + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [messageJson('m1', mailboxId: kInboxId)], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'c:20', + 'total': 25, + }, + }), + ); + + await scrollToFooter(tester); + + expect(h.mail.listRequests, hasLength(2)); + final Finder retry = find.descendant( + of: find.byType(MailListFooter), + matching: find.text(MailListTexts.retry), + ); + expect(retry, findsOneWidget); + await tester.tap(retry); + await tester.pumpAndSettle(); + expect(h.mail.listRequests, hasLength(3)); + expect(retry, findsOneWidget); + }); + + testWidgets('메일함 목록을 받기 전에는 목록 대신 뼈대 — 받은 뒤 임시보관함 줄은 쓰기 화면에서 이어 쓴다', ( + WidgetTester tester, + ) async { + h.mail.messages[kDraftsId] = >[ + messageJson('d1', mailboxId: kDraftsId, subject: '쓰던 메일'), + ]; + final Completer boxes = Completer(); + h.mail.mailboxesGate = boxes; + await h.pump(tester, location: MailRoutes.mailbox(kDraftsId)); + + // 목록은 받아 두었지만 역할(임시보관함)을 알기 전이라 그리지 않는다 + expect( + h.mail.listRequests.map( + (RequestOptions r) => r.queryParameters['mailboxId'], + ), + contains(kDraftsId), + ); + expect(find.byType(MailListSkeleton), findsOneWidget); + expect(find.text('쓰던 메일'), findsNothing); + + boxes.complete(); + await tester.pumpAndSettle(); + await tester.tap(find.text('쓰던 메일')); + await tester.pumpAndSettle(); + + expect(pushedLocation(tester), MailRoutes.composeDraft('d1')); + }); + + testWidgets('다른 메일함 주소에서 메일함 목록을 받지 못하면 그 실패와 [다시 시도] — 다시 받으면 목록', ( + WidgetTester tester, + ) async { + h.mail.seed(kClientsId, 1, prefix: 'c'); + h.mail.mailboxesFailure = FakeResponse.fail(503, 'MAIL_001'); + await h.pump(tester, location: MailRoutes.mailbox(kClientsId)); + + expect(findCopy(ErrorLexicon.getMessage('MAIL_001')), findsOneWidget); + expect(find.text('제목 c1'), findsNothing); + + h.mail.mailboxesFailure = null; + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + + expect(find.text('제목 c1'), findsOneWidget); + expect(find.text('거래처'), findsOneWidget); + }); + + testWidgets('[찾기]는 아직 준비 중이라 알리고, [메일 쓰기]는 쓰기 화면을 연다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + + await tester.tap(find.text(MailListTexts.compose)); + await tester.pumpAndSettle(); + expect(pushedLocation(tester), MailRoutes.compose); + expect(find.byType(NavigationBar), findsNothing); + }); + + testWidgets('전역 알림(API 실패)은 이 화면에 뜨고 [메일 쓰기] · 알림 줄을 가리지 않는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + + h.container + .read(scaffoldMessengerKeyProvider) + .currentState! + .showSnackBar( + SnackBar(content: Text(ErrorLexicon.getMessage('MAIL_001'))), + ); + await tester.pumpAndSettle(); + + final Rect snack = tester.getRect(find.byType(SnackBar)); + final Rect compose = tester.getRect( + find.widgetWithText(FloatingActionButton, MailListTexts.compose), + ); + expect(snack.bottom, lessThanOrEqualTo(compose.top)); + expect( + find.descendant( + of: find.byType(MailListPage), + matching: find.byType(SnackBar), + ), + findsOneWidget, + ); + }); + }); + + group('새 메일 확인 (1분 · 손 시계)', () { + testWidgets('화면이 보이는 동안 1분마다 묻는다 — 처음 값은 기준, 바뀌면 불러온 쪽을 맞춘다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + expect(h.mail.stateRequests, 1); + expect(h.mail.listRequests, hasLength(1)); + + await elapse(tester, kMailStatePollInterval - const Duration(seconds: 1)); + expect(h.mail.stateRequests, 1); + await elapse(tester, const Duration(seconds: 1)); + expect(h.mail.stateRequests, 2); + expect(h.mail.listRequests, hasLength(1), reason: '값이 같으면 맞추지 않는다'); + + h.mail + ..state = 'state-2' + ..arrive( + kInboxId, + messageJson('new', mailboxId: kInboxId, subject: '새 메일'), + ); + await elapse(tester, kMailStatePollInterval); + + expect(h.mail.stateRequests, 3); + expect(h.mail.listRequests, hasLength(2)); + expect(find.text('새 메일'), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('다른 탭에 있는 동안은 묻지 않고, 돌아오면 10초가 지났으니 바로 묻는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + expect(h.mail.stateRequests, 1); + + await tester.tap(find.text('설정')); + await tester.pumpAndSettle(); + await elapse(tester, kMailStatePollInterval * 2); + expect(h.mail.stateRequests, 1); + + await tester.tap(find.text('메일')); + await tester.pumpAndSettle(); + expect(h.mail.stateRequests, 2); + }); + + testWidgets('앱이 뒤로 가면 멈추고, 앞으로 오면 마지막으로 물은 지 10초가 지났을 때만 바로 묻는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + expect(h.mail.stateRequests, 1); + + void lifecycle(List states) { + for (final AppLifecycleState state in states) { + tester.binding.handleAppLifecycleStateChanged(state); + } + } + + const List toBackground = [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + ]; + const List toForeground = [ + AppLifecycleState.hidden, + AppLifecycleState.inactive, + AppLifecycleState.resumed, + ]; + + lifecycle(toBackground); + await elapse(tester, kMailStatePollInterval * 2); + expect(h.mail.stateRequests, 1); + + lifecycle(toForeground); + await tester.pumpAndSettle(); + expect(h.mail.stateRequests, 2); + + // 5초 만에 다시 왔다 — 바로 묻지 않고 원래 차례(마지막으로 물은 지 1분)에 + lifecycle(toBackground); + await elapse(tester, const Duration(seconds: 5)); + lifecycle(toForeground); + await tester.pumpAndSettle(); + expect(h.mail.stateRequests, 2); + await elapse(tester, kMailStatePollInterval - const Duration(seconds: 5)); + expect(h.mail.stateRequests, 3); + }); + + testWidgets('확인이 실패해도 알리지 않고 다음 차례에 다시 묻는다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + h.mail.stateFailure = FakeResponse.fail(503, 'MAIL_001'); + + await elapse(tester, kMailStatePollInterval); + expect(h.mail.stateRequests, 2); + expect(h.notifier.shownCodes, isEmpty); + + h.mail.stateFailure = null; + await elapse(tester, kMailStatePollInterval); + expect(h.mail.stateRequests, 3); + }); + + testWidgets('아직 보내지 않은 메일은 보낼 시각 + 2초에 목록을 다시 본다(엔진이 풀어도 상태가 바뀌지 않는다)', ( + WidgetTester tester, + ) async { + final DateTime sendAt = h.clock.now.add(const Duration(seconds: 8)); + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + subject: '보내는 메일', + isRead: true, + pendingSend: pendingJson(sendAt), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + expect(find.text(MailListTexts.sending), findsOneWidget); + final int before = h.mail.listRequests.length; + h.mail.messages[kSentId]!.first['pendingSend'] = null; + + await elapse(tester, const Duration(seconds: 9)); + expect(h.mail.listRequests.length, before); + await elapse(tester, const Duration(seconds: 1)); + + expect(h.mail.listRequests.length, before + 1); + expect(find.text(MailListTexts.sending), findsNothing); + }); + }); + + group('아직 보내지 않은 메일 다시 보기 (손 시계)', () { + testWidgets('다시 보다 실패해도(연결 끊김) 5초 뒤 다시 본다', (WidgetTester tester) async { + final DateTime sendAt = h.clock.now.add(const Duration(seconds: 8)); + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + subject: '보내는 메일', + isRead: true, + pendingSend: pendingJson(sendAt), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + final int before = h.mail.listRequests.length; + h.mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + + await elapse(tester, const Duration(seconds: 10)); + expect(h.mail.listRequests.length, before + 1); + expect(find.text(MailListTexts.sending), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty, reason: '맞추기 실패는 알리지 않는다'); + + h.mail.listFailure = null; + h.mail.messages[kSentId]!.first['pendingSend'] = null; + await elapse(tester, kHeldReleaseRecheck); + + expect(h.mail.listRequests.length, before + 2); + expect(find.text(MailListTexts.sending), findsNothing); + }); + + testWidgets('보낼 시각은 서버 시계로 잰다 — 기기 시계가 5분 늦어도 제때 다시 본다', ( + WidgetTester tester, + ) async { + final DateTime serverNow = h.clock.now.add(const Duration(minutes: 5)); + h.server.serverTime = serverNow; + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + subject: '보내는 메일', + isRead: true, + pendingSend: pendingJson(serverNow.add(const Duration(seconds: 8))), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + expect(find.text(MailListTexts.sending), findsOneWidget); + final int before = h.mail.listRequests.length; + h.mail.messages[kSentId]!.first['pendingSend'] = null; + + await elapse(tester, const Duration(seconds: 9)); + expect(h.mail.listRequests.length, before); + await elapse(tester, const Duration(seconds: 1)); + + expect(h.mail.listRequests.length, before + 1); + expect(find.text(MailListTexts.sending), findsNothing); + }); + }); + + testWidgets('세션이 끝나면(로그아웃) 메일 상태가 사라진다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + h.server.on( + 'POST', + '/auth/app/logout', + (RequestOptions _) => FakeResponse.ok(), + ); + h.container + .read(mailToastProvider.notifier) + .offer('알림', actionLabel: MailBulkTexts.undo, onAction: () async {}); + await tester.pumpAndSettle(); + + h.router.go(SettingsRoutes.home); + await tester.pumpAndSettle(); + await tester.tap(find.text('로그아웃')); + await tester.pumpAndSettle(); + + expect(find.byType(MailListPage), findsNothing); + expect(h.container.exists(mailToastProvider), isFalse); + expect(h.container.exists(mailStateWatcherProvider), isFalse); + }); +} diff --git a/flutter/test/features/mail/mail_repository_test.dart b/flutter/test/features/mail/mail_repository_test.dart new file mode 100644 index 0000000..edc67ad --- /dev/null +++ b/flutter/test/features/mail/mail_repository_test.dart @@ -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({ + 'items': standardMailboxes(), + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }), + ); + + final List 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({ + 'items': [messageJson('m1', mailboxId: kInboxId)], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'opaque==', + 'total': 21, + }, + }), + ); + + final MailPage 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, { + 'mailboxId': kInboxId, + 'limit': 50, + 'cursor': 'abc', + }); + }); + + test('목록 — 응답의 Date(서버 시각)를 쪽에 싣는다, 없으면 null', () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [], + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }), + ); + final DateTime serverTime = DateTime.utc(2026, 11, 6, 1, 2, 3); + h.server.serverTime = serverTime; + + final MailPage page = await repository.listMessages( + mailboxId: kInboxId, + ); + expect(page.serverTime, serverTime); + + h.server.serverTime = null; + final MailPage withoutDate = await repository.listMessages( + mailboxId: kInboxId, + ); + expect(withoutDate.serverTime, isNull); + }); + + test('응답 모양이 계약과 다르면 SYS_001', () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [ + {'id': 'm1'}, + ], + 'pageInfo': {'hasNext': false}, + }), + ); + h.server.on( + 'GET', + MailApiPaths.mailState, + (_) => FakeResponse.ok({'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({ + 'updatedIds': ['m1'], + 'failedIds': ['m2'], + }), + ); + h.server.on( + 'POST', + MailApiPaths.messageDeletions, + (_) => FakeResponse.ok({ + 'deletedIds': ['t1'], + 'skippedIds': [], + }), + ); + + final MessageSummary one = await repository.updateMessage( + 'a/b', + const MessageChanges(isRead: true), + ); + final MessagesPatched many = await repository.updateMessages([ + 'm1', + 'm2', + ], const MessageChanges(mailboxId: kTrashId)); + final MessageDeletions deleted = await repository.deleteMessages([ + 't1', + ]); + + expect(MailApiPaths.message('a/b'), '/messages/a%2Fb'); + expect(one.isRead, isTrue); + expect(many.failedIds, ['m2']); + expect(deleted.deletedIds, ['t1']); + expect(h.server.requests[0].data, {'isRead': true}); + expect(h.server.requests[1].data, { + 'ids': ['m1', 'm2'], + 'mailboxId': kTrashId, + }); + expect(h.server.requests[2].data, { + 'ids': ['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([ + 'm1', + ], const MessageChanges(isRead: true)), + ); + expect(h.notifier.shownCodes, ['MAIL_001']); + h.clock.advance(const Duration(seconds: 5)); + await errorOf(repository.mailState()); + expect(h.notifier.shownCodes, ['MAIL_001']); + }); +} diff --git a/flutter/test/features/mail/mail_rules_test.dart b/flutter/test/features/mail/mail_rules_test.dart new file mode 100644 index 0000000..d87ebd7 --- /dev/null +++ b/flutter/test/features/mail/mail_rules_test.dart @@ -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 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: >[ + 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: >[ + 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 json = messageJson('m1', mailboxId: kInboxId); + expect(summary(json).pendingSend, isNull); + expect( + MessageSummary.tryParse({...json, 'id': 1}), + isNull, + ); + expect( + MessageSummary.tryParse({ + ...json, + 'mailboxIds': [1], + }), + isNull, + ); + expect( + MessageSummary.tryParse({ + ...json, + 'receivedAt': 'nope', + }), + isNull, + ); + expect( + summary({ + ...json, + 'pendingSend': {'kind': 'later', 'sendAt': 'x'}, + }).pendingSend, + isNull, + ); + // 9단계 앞 서버 — 칸이 없다 + expect( + summary({...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? page = MailPage.tryParse( + { + 'items': [ + mailboxJson('a', 'A'), + {'id': 2}, + ], + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }, + Mailbox.tryParse, + ); + expect(page, isNull); + }); + + test('다음 쪽은 hasNext 이고 커서가 있을 때만', () { + expect( + MailPageInfo.tryParse({ + 'hasNext': true, + 'nextCursor': 'c:20', + 'total': 40, + })!.hasMore, + isTrue, + ); + expect( + MailPageInfo.tryParse({ + 'hasNext': true, + 'nextCursor': null, + })!.hasMore, + isFalse, + ); + }); + + test('바꿀 것 — 정한 칸만 싣는다', () { + expect(const MessageChanges(isRead: true).toJson(), { + 'isRead': true, + }); + expect( + const MessageChanges(mailboxId: 'mb-trash', isJunk: false).toJson(), + {'mailboxId': 'mb-trash', 'isJunk': false}, + ); + }); + }); + + group('메일함 차례 · 묶음 · 옮길 곳 (웹 mailbox.ts 와 같다)', () { + final List mailboxes = [ + for (final Map json in standardMailboxes()) + Mailbox.tryParse(json)!, + ]; + + test('역할 차례 → 내 메일함(sortOrder · 이름) — 하위는 부모 바로 아래 한 단 깊이', () { + final List nodes = orderMailboxes(mailboxes); + expect( + [for (final MailboxNode node in nodes) node.mailbox.name], + [ + '받은편지함', + '보낸편지함', + '임시보관함', + '스팸메일함', + '휴지통', + '거래처', + '프로젝트', + '설계', + ], + ); + expect(nodes.last.depth, 1); + final ({List system, List mine}) groups = + splitMailboxGroups(nodes); + expect(groups.system, hasLength(5)); + expect(groups.mine, hasLength(3)); + }); + + test('내 메일함 이름은 대소문자를 가리지 않고 글자 값으로 — 영문 · 한글이 섞이면 웹(ko 정렬)과 다를 수 있다', () { + final List mine = [ + for (final (String id, String name) in <(String, String)>[ + ('1', '가나다'), + ('2', 'erp'), + ('3', 'ARA 프로젝트'), + ('4', 'ara'), + ('5', 'Ara'), + ]) + Mailbox.tryParse(mailboxJson(id, name))!, + ]; + + expect( + [ + for (final MailboxNode node in orderMailboxes(mine)) + node.mailbox.name, + ], + // 같은 글(대소문자만 다름)은 원래 글자로 한 번 더 견준다 — 늘 같은 차례 + ['Ara', 'ara', 'ARA 프로젝트', 'erp', '가나다'], + ); + }); + + test('부모를 찾을 수 없거나 서로를 가리키는 메일함도 빠뜨리지 않는다', () { + final List broken = [ + 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( + [ + for (final MailboxNode node in moveTargetsOf(mailboxes, kInboxId)) + node.mailbox.id, + ], + [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: [m('a'), m('b'), m('c'), m('d')], + currentPageInfo: oldInfo, + fetched: [m('new'), m('a'), m('c')], + fetchedPageInfo: newInfo, + ); + expect( + [for (final MessageSummary x in merged.items) x.id], + ['new', 'a', 'c', 'd'], + reason: '다른 곳에서 지운 b 는 빠지고, 범위 밖의 d 는 남는다', + ); + expect(merged.pageInfo.nextCursor, 'c:40'); + expect(merged.pageInfo.total, 51); + }); + + test('다시 받은 쪽이 끝까지였으면 받지 못한 옛 메일은 지워진 것이라 남기지 않는다', () { + final MailListMerge merged = mergeRefreshedList( + current: [m('a'), m('b'), m('c')], + currentPageInfo: const MailPageInfo(hasNext: false), + fetched: [m('new'), m('a'), m('b')], + fetchedPageInfo: const MailPageInfo(hasNext: false, total: 3), + ); + expect( + [for (final MessageSummary x in merged.items) x.id], + ['new', 'a', 'b'], + ); + }); + + test('겹치는 메일이 없으면 다시 받은 것만 쓴다', () { + final MailListMerge merged = mergeRefreshedList( + current: [m('a')], + currentPageInfo: oldInfo, + fetched: [m('x'), m('y')], + fetchedPageInfo: newInfo, + ); + expect( + [for (final MessageSummary x in merged.items) x.id], + ['x', 'y'], + ); + expect(merged.pageInfo, newInfo); + }); + }); + + group('나눠 보내기 · 되돌리기 묶음', () { + test('100통씩 나눈다', () { + final List ids = [for (int i = 0; i < 250; i++) '$i']; + expect(chunked(ids, 100).map((List c) => c.length), [ + 100, + 100, + 50, + ]); + }); + + test('요청이 실패하면 그 묶음과 남은 묶음은 보내지 않은 것으로 두고 멈춘다', () async { + int calls = 0; + final BulkResult result = await sendInChunks( + [for (int i = 0; i < 250; i++) '$i'], + 100, + (List 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, ['0']); + expect(result.unsentIds, hasLength(150)); + expect(result.hasLeftovers, isTrue); + }); + + test('원래 메일함별로 묶는다 — 모르는 메일은 뺀다', () { + final List groups = groupByOrigin( + ['a', 'b', 'c', 'd'], + {'a': kInboxId, 'b': kClientsId, 'c': kInboxId}, + ); + expect(groups, hasLength(2)); + expect(groups.first.mailboxId, kInboxId); + expect(groups.first.ids, ['a', 'c']); + expect(groups.last.ids, ['b']); + }); + }); +} diff --git a/flutter/test/support/mail_fakes.dart b/flutter/test/support/mail_fakes.dart new file mode 100644 index 0000000..5efeed2 --- /dev/null +++ b/flutter/test/support/mail_fakes.dart @@ -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 mailboxJson( + String id, + String name, { + String? role, + String? parentId, + int sortOrder = 0, + int total = 0, + int unread = 0, +}) => { + 'id': id, + 'name': name, + 'role': role, + 'parentId': parentId, + 'sortOrder': sortOrder, + 'totalCount': total, + 'unreadCount': unread, +}; + +/// 메일 한 통 (계약 2절 `MessageSummary`) +Map messageJson( + String id, { + required String mailboxId, + String fromName = '이도윤', + String fromEmail = 'doyun.lee@example.com', + List> to = const >[], + String? subject, + String preview = '미리 보기 글', + DateTime? receivedAt, + bool isRead = false, + bool isFlagged = false, + bool hasAttachment = false, + Map? pendingSend, + bool noSender = false, +}) => { + 'id': id, + 'threadId': 'thread-$id', + 'mailboxIds': [mailboxId], + 'from': noSender + ? null + : {'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 addressJson(String name, String email) => + {'name': name, 'email': email}; + +/// 붙잡아 둔 발송 (9단계) +Map pendingJson(DateTime sendAt, {String kind = 'undo'}) => + {'sendAt': sendAt.toUtc().toIso8601String(), 'kind': kind}; + +/// 기본 메일함 다섯과 내 메일함 셋(프로젝트 아래에 하나) +List> standardMailboxes({ + int inboxUnread = 3, +}) => >[ + 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>? mailboxes}) + : mailboxes = mailboxes ?? standardMailboxes(); + + final FakeServer server; + List> mailboxes; + + /// 메일함마다 메일 — 받은 시각 내림차순(앞이 최신) + final Map>> messages = + >>{}; + + /// 새 메일 확인의 상태 글 + String state = 'state-1'; + + /// 바꾸기 · 지우기가 그대로 둘 메일 id + final Set failIds = {}; + + /// 있으면 PATCH 본문 · 메일 id 마다 그대로 둘지 묻는다(요청마다 다르게 — 읽음만 실패 등) + bool Function(Map body, String id)? failWhen; + + FakeResponse? mailboxesFailure; + FakeResponse? listFailure; + FakeResponse? patchFailure; + FakeResponse? deletionFailure; + FakeResponse? stateFailure; + + /// 있으면 다음 목록 응답들이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다 — [listGateOnce]) + Completer? listGate; + bool listGateOnce = true; + Completer? patchGate; + Completer? mailboxesGate; + + /// 받은 PATCH /messages 본문 + final List> patches = >[]; + + /// 받은 POST /message-deletions 의 ids + final List> deletions = >[]; + + int get stateRequests => + server.received('GET', MailApiPaths.mailState).length; + + List 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({ + 'items': mailboxes, + 'pageInfo': { + 'hasNext': false, + 'nextCursor': null, + 'total': mailboxes.length, + }, + }); + }); + server.on('GET', MailApiPaths.messages, (RequestOptions options) async { + final Completer? gate = listGate; + if (gate != null) { + if (listGateOnce) listGate = null; + await gate.future; + } + final FakeResponse? failure = listFailure; + if (failure != null) return failure; + final Map query = options.queryParameters; + final String mailboxId = query['mailboxId']! as String; + if (!mailboxes.any((Map m) => m['id'] == mailboxId)) { + return FakeResponse.fail(404, 'RES_001'); + } + final List> all = + messages[mailboxId] ?? >[]; + 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({ + 'items': all.sublist(start.clamp(0, all.length), end), + 'pageInfo': { + 'hasNext': hasNext, + 'nextCursor': hasNext ? 'c:$end' : null, + 'total': all.length, + }, + }); + }); + server.on('PATCH', MailApiPaths.messages, (RequestOptions options) async { + final Map body = options.data! as Map; + patches.add(body); + await patchGate?.future; + final FakeResponse? failure = patchFailure; + if (failure != null) return failure; + final List ids = (body['ids']! as List).cast(); + final List updated = []; + final List failed = []; + 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({ + 'updatedIds': updated, + 'failedIds': failed, + }); + }); + server.on('POST', MailApiPaths.messageDeletions, ( + RequestOptions options, + ) async { + final Map body = options.data! as Map; + final List ids = (body['ids']! as List).cast(); + deletions.add(ids); + final FakeResponse? failure = deletionFailure; + if (failure != null) return failure; + final List deleted = []; + final List skipped = []; + final List> trash = + messages[kTrashId] ?? >[]; + for (final String id in ids) { + final int index = trash.indexWhere( + (Map m) => m['id'] == id, + ); + if (failIds.contains(id) || index == -1) { + skipped.add(id); + continue; + } + trash.removeAt(index); + deleted.add(id); + } + return FakeResponse.ok({ + 'deletedIds': deleted, + 'skippedIds': skipped, + }); + }); + server.on('GET', MailApiPaths.mailState, (_) { + final FakeResponse? failure = stateFailure; + if (failure != null) return failure; + return FakeResponse.ok({'state': state}); + }); + } + + /// [mailboxId] 에 메일 [count] 통을 둔다 — id 는 `1` 부터(앞이 최신) + void seed( + String mailboxId, + int count, { + String prefix = 'm', + bool isRead = false, + }) { + messages[mailboxId] = >[ + 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 message) { + (messages[mailboxId] ??= >[]).insert(0, message); + } + + ({String mailboxId, Map message})? _find(String id) { + for (final MapEntry>> entry + in messages.entries) { + for (final Map message in entry.value) { + if (message['id'] == id) { + return (mailboxId: entry.key, message: message); + } + } + } + return null; + } + + void _apply(String id, Map body) { + final ({String mailboxId, Map message})? found = _find(id); + if (found == null) return; + final Map 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'] = [target]; + final List> into = messages[target] ??= + >[]; + into.add(message); + into.sort( + (Map a, Map b) => + (b['receivedAt']! as String).compareTo(a['receivedAt']! as String), + ); + } + } + + /// [mailboxId] 에 있는 메일 id (차례대로) + List idsIn(String mailboxId) => [ + for (final Map message + in messages[mailboxId] ?? >[]) + message['id']! as String, + ]; +} diff --git a/flutter/test/support/mail_harness.dart b/flutter/test/support/mail_harness.dart new file mode 100644 index 0000000..c87e1c2 --- /dev/null +++ b/flutter/test/support/mail_harness.dart @@ -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 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({ + ...sessionJson(), + 'tokens': tokensJson(2, now: clock.now), + }), + ); + server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())); + mail.install(); + await tester.pumpWidget( + ProviderScope( + retry: noAutomaticRetry, + overrides: [...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(); + } + } +}