feat(flutter): 메일 쓰기 화면 2차 — 첨부 고르기 · 올리기를 더한다

[파일 첨부]가 안드로이드 고르는 창(권한 없음)을 연다. 고른 파일은 앱 임시 폴더에
복사해 두고 그 복사본을 multipart 로 둘씩 올린다 — 올리기에 실패하거나 서버가 첨부를
찾지 못해도(MAIL_004) 다시 고르지 않고 다시 올리려고.

- 사전 검사 · 보내기 막기는 웹과 같은 차례, 한도는 GET /api/mail-quota
- 기기가 같은 차례로 미리 걸러 넣을 파일만 복사한다(content:// 만, 판정은 Dart 가 다시)
- 다 올리면 서버가 정리한 이름 · 크기로 싣는다(이름 255자 — 웹과 같다)
- MAIL_004 뒤 다시 올릴 수 없는 파일은 빼기 전까지 저장 · 보내기를 막는다
- 가져오는 · 올리는 동안 [보내기]를 막고(가져오기는 10분 시한), 올리는 중 나가기는 따로 묻는다
- 화면이 끝나거나 직원이 바뀌거나 앱을 띄울 때 복사본을 지운다

리뷰(M1~M4 · L 다섯 · 다시 본 리뷰의 L 하나)를 고쳤다. 시험 690 → 750.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 04:19:57 +09:00
parent f39f41ed4b
commit 3a126bdf83
27 changed files with 4360 additions and 136 deletions
@@ -1,4 +1,4 @@
/// 쓰기 화면의 응답 모양 — 계약 2절 `Draft` · `DraftSaved` · `SentMessage` · `MailSignature` · `MailSettings`, 4절 `LargeFile` · `MissingLargeFile`.
/// 쓰기 화면의 응답 모양 — 계약 2절 `Draft` · `DraftSaved` · `SentMessage` · `MailSignature` · `MailSettings` · `MailQuota`(첨부 한도), 4절 `LargeFile` · `MissingLargeFile`.
///
/// 모델은 손으로 쓴다(flutter 스킬 "모델 생성기"). 모양이 틀리면 [tryParse] 가 null — 저장소가 `SYS_001` 로 던진다.
/// 나중 단계에 더해진 칸(`largeFiles` · `pendingSend` · `rejectedRecipients`)은 없으면 빈 목록 · null 로 읽는다(웹과 같다).
@@ -331,3 +331,42 @@ int? undoSendSecondsOf(Object? json) {
if (seconds is! num || seconds < 0) return null;
return seconds.toInt();
}
/// 첨부 한도 — `GET /api/mail-quota` 의 `attachmentMaxBytes` · `attachmentTotalMaxBytes` (계약 2절 `MailQuota` — 사용량 칸은 쓰지 않는다).
/// 받지 못하면 대체값(20 · 25MB — `kFallbackAttachmentLimits`)을 쓴다
@immutable
class AttachmentLimits {
const AttachmentLimits({required this.maxBytes, required this.totalMaxBytes});
/// 파일 하나의 한도 — 새로 올리는 파일만 본다(전달하는 원본은 서버도 합계만 본다)
final int maxBytes;
/// 메일 한 통의 첨부 합계 한도 — 전달하는 원본 · 저장본의 첨부까지 함께 센다
final int totalMaxBytes;
/// 모양이 틀리면(없음 · 0 이하) null
static AttachmentLimits? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? maxBytes = json['attachmentMaxBytes'];
final Object? totalMaxBytes = json['attachmentTotalMaxBytes'];
if (maxBytes is! num ||
totalMaxBytes is! num ||
maxBytes <= 0 ||
totalMaxBytes <= 0) {
return null;
}
return AttachmentLimits(
maxBytes: maxBytes.toInt(),
totalMaxBytes: totalMaxBytes.toInt(),
);
}
@override
bool operator ==(Object other) =>
other is AttachmentLimits &&
other.maxBytes == maxBytes &&
other.totalMaxBytes == totalMaxBytes;
@override
int get hashCode => Object.hash(maxBytes, totalMaxBytes);
}
@@ -7,6 +7,7 @@ import 'package:arasystem_app/core/people/recipient_suggestion.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/data/models/compose_payload.dart';
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
import 'package:dio/dio.dart';
@@ -33,6 +34,12 @@ abstract final class ComposeApiPaths {
/// 대용량 파일 하나의 상태 — 발송이 `MAIL_016` 이면 파일마다 다시 묻는다(계약 4절)
static String fileUpload(String id) =>
'/file-uploads/${Uri.encodeComponent(id)}';
/// 첨부 올리기 — multipart `file` 하나(계약 2절 `POST /api/mail-attachments`)
static const String mailAttachments = '/mail-attachments';
/// 메일 사용량 · 첨부 한도
static const String mailQuota = '/mail-quota';
}
/// 발송 · 저장의 받기 한도 — 서버가 55~56초 안에 답한다(계약 2절). 기본 한도(15초)로 끊으면 화면은 실패로 보는데 서버는 이미 보냈을 수 있다
@@ -42,6 +49,15 @@ const Duration kMailSendTimeout = Duration(seconds: 60);
/// 중복 발송 방지 키 머리 (계약 2절)
const String kIdempotencyHeader = 'Idempotency-Key';
/// 첨부 올리기의 보내기 한도 — 본문 전체에 건다(Dio 5.9 는 sendTimeout 을 본문을 다 보낼 때까지 잰다). 20MB 를 느린 망에서도
const Duration kAttachmentUploadSendTimeout = Duration(minutes: 5);
/// 첨부 올리기의 받기 한도 — 다 보낸 뒤 서버가 메일 엔진에 넘기고 답한다(발송과 같은 60초)
const Duration kAttachmentUploadReceiveTimeout = Duration(seconds: 60);
/// 첨부 올리기의 multipart 칸 이름 (백엔드 `FileInterceptor('file')`)
const String kAttachmentFileField = 'file';
/// 처음 받아 두는 추천 수 — 계약의 최대 limit (웹 `RECIPIENT_SUGGESTION_PRELOAD`)
const int kRecipientSuggestionPreload = 100;
@@ -57,7 +73,7 @@ typedef RecipientSuggestionPage = ({
bool hasNext,
});
/// 쓰기 화면의 API — 계약 2절(임시 저장 · 발송 · 서명 · 받는 사람 추천 · 보내기 설정) · 3절(그룹 · 구성원) · 4절(대용량 파일 상태).
/// 쓰기 화면의 API — 계약 2절(임시 저장 · 발송 · 첨부 올리기 · 첨부 한도 · 서명 · 받는 사람 추천 · 보내기 설정) · 3절(그룹 · 구성원) · 4절(대용량 파일 상태).
///
/// 응답 언래핑 · 에러 코드 · 알림은 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다.
/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 `SYS_001`.
@@ -249,6 +265,58 @@ class ComposeRepository {
return file;
}
/// 첨부 하나를 올린다 — multipart `file`, 형식은 확장자의 정식 형식([type] — 서버가 확장자와 형식을 함께 본다), 이름은 고른 파일의 보이는 이름.
/// [read] 는 부를 때마다 처음부터 읽는다(401 뒤 갱신해 다시 보낼 때 — 인증 인터셉터가 FormData 를 복제한다).
/// 실패는 화면이 그 줄에 사전 문구로 보인다 — 알리지 않는다
Future<MessageAttachment> uploadAttachment({
required String name,
required String type,
required int length,
required Stream<List<int>> Function() read,
required CancelToken cancelToken,
required void Function(int sent, int total) onProgress,
}) async {
final FormData form = FormData()
..files.add(
MapEntry<String, MultipartFile>(
kAttachmentFileField,
MultipartFile.fromStream(
read,
length,
filename: name,
contentType: DioMediaType.parse(type),
),
),
);
final Response<Object?> response = await _dio.post<Object?>(
ComposeApiPaths.mailAttachments,
data: form,
options: Options(
sendTimeout: kAttachmentUploadSendTimeout,
receiveTimeout: kAttachmentUploadReceiveTimeout,
extra: <String, Object?>{kSilentExtraKey: true},
),
cancelToken: cancelToken,
onSendProgress: onProgress,
);
final MessageAttachment? attachment = MessageAttachment.tryParse(
response.data,
);
if (attachment == null) throw _malformed(response);
return attachment;
}
/// 첨부 한도(파일당 · 합계) — 알리지 않는다(받지 못하면 대체값으로 쓴다)
Future<AttachmentLimits> getAttachmentLimits() async {
final Response<Object?> response = await _dio.get<Object?>(
ComposeApiPaths.mailQuota,
options: ApiOptions.silent(),
);
final AttachmentLimits? limits = AttachmentLimits.tryParse(response.data);
if (limits == null) throw _malformed(response);
return limits;
}
Options _longOptions({required bool silent}) => Options(
receiveTimeout: kMailSendTimeout,
extra: <String, Object?>{if (silent) kSilentExtraKey: true},
@@ -0,0 +1,136 @@
/// 첨부의 규칙 — 형식 표 · 한도 · 사전 검사 (순수 함수, 계약 2절 "첨부 업로드 한도").
///
/// 짝: 백엔드 `modules/mail/attachment.policy.ts` 의 `ATTACHMENT_TYPES`(확장자 → 정식 형식 — 시험이 그 파일과 맞춰 본다) ·
/// `MAX_ATTACHMENTS_PER_MESSAGE`, 웹 `shared/utils/mail-attachment.ts`(`precheckAttachment` — 개수 → 형식 → 파일당 → 합계 차례) ·
/// `shared/constants/mail.ts`(대체 한도). 한쪽을 고치면 둘 다.
library;
import 'package:arasystem_app/core/files/picked_files.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
/// 메일 한 통의 첨부 개수 한도 — 올린 파일과 전달하는 원본을 함께 센다 (백엔드 `MAX_ATTACHMENTS_PER_MESSAGE` · 웹 `MAIL_MAX_ATTACHMENTS`)
const int kMailMaxAttachments = 20;
/// 파일당 크기 대체값 — 서버 기본값과 같다(`GET /api/mail-quota` 를 받지 못했을 때 — 웹 `MAIL_ATTACHMENT_FALLBACK_MAX_BYTES`)
const int kMailAttachmentMaxBytes = 20 * 1024 * 1024;
/// 첨부 합계 대체값 — 서버 기본값과 같다(웹 `MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES`)
const int kMailAttachmentTotalMaxBytes = 25 * 1024 * 1024;
/// 한도를 받지 못했을 때 쓰는 값
const AttachmentLimits kFallbackAttachmentLimits = AttachmentLimits(
maxBytes: kMailAttachmentMaxBytes,
totalMaxBytes: kMailAttachmentTotalMaxBytes,
);
/// 허용 형식 — 확장자(소문자, 점 없이) → 올릴 때 보내는 정식 형식.
///
/// 서버는 올린 파일의 확장자와 형식을 **둘 다** 본다(multer `isAllowedAttachment` — 그 확장자가 받는 형식이어야 한다).
/// 안드로이드가 알려 주는 형식은 기기 · 앱마다 달라(hwp 가 octet-stream 이거나 한컴 형식) 쓰지 않고, 확장자의 정식 형식을 보낸다.
/// **짝: 백엔드 `ATTACHMENT_TYPES` 의 `type`** (`compose_attachments_test.dart` 가 그 파일을 읽어 맞춰 본다)
const Map<String, String> kMailAttachmentTypes = <String, String>{
'pdf': 'application/pdf',
'doc': 'application/msword',
'docx':
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls': 'application/vnd.ms-excel',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt': 'application/vnd.ms-powerpoint',
'pptx':
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'hwp': 'application/x-hwp',
'hwpx': 'application/hwp+zip',
'txt': 'text/plain',
'csv': 'text/csv',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp',
'zip': 'application/zip',
};
/// 확장자 — 소문자, 없으면 빈 글 (웹 `extensionOf` 와 같다 — 마지막 점 뒤).
/// **짝: `MainActivity.kt` 의 `extensionOf`** — 고를 때 기기가 같은 규칙으로 형식을 미리 거른다(보이는 이름 `displayFileName` 도 짝)
String extensionOf(String name) {
final int dot = name.lastIndexOf('.');
return dot == -1 ? '' : name.substring(dot + 1).toLowerCase();
}
/// 올릴 때 보내는 형식 — 허용 목록에 없으면(HEIC 등) null
String? attachmentUploadTypeOf(String name) =>
kMailAttachmentTypes[extensionOf(name)];
/// 그림 첨부인가 — 줄 아이콘(보드: 그림은 그림 아이콘, 그 밖은 문서 아이콘)
bool isImageAttachment(String name) =>
attachmentUploadTypeOf(name)?.startsWith('image/') ?? false;
/// 이미 붙은 첨부(전달하는 원본 · 저장본의 첨부)를 보낼 수 있는가 — 개수(`MAIL_006`) → 형식(`FILE_001`) → 합계(`FILE_002`) 차례(서버와 같다).
/// 전달하는 원본 첨부는 파일당 한도를 보지 않는다(서버도 확장자와 합계만 본다). 걸리면 그 코드, 아니면 null
String? precheckCarriedAttachment({
required String name,
required int size,
required int attachedCount,
required int attachedBytes,
int totalMaxBytes = kMailAttachmentTotalMaxBytes,
}) {
if (attachedCount + 1 > kMailMaxAttachments) return 'MAIL_006';
if (attachmentUploadTypeOf(name) == null) return 'FILE_001';
if (attachedBytes + size > totalMaxBytes) return 'FILE_002';
return null;
}
/// 고른 파일을 넣지 못한 까닭
enum AttachmentRejection {
/// 개수 한도(`MAIL_006`) — 복사본 수 한도로 가져오지 않은 파일도
tooMany,
/// 허용하지 않는 형식(`FILE_001`) — HEIC 도
notAllowed,
/// 파일당 한도보다 크다 — 앱은 아직 대용량 첨부(링크)로 보내지 못한다
tooLarge,
/// 고른 파일을 읽지 못했다
unreadable,
/// 합계 한도를 넘게 만든다
overTotal,
}
/// 고른 파일을 붙이기 전 사전 검사 — 걸리면 붙이지 않는다. 웹 `precheckAttachment` 와 같은 차례:
/// 1) 개수(서버도 형식보다 먼저 본다 — 허용 밖 형식이어도 한도를 넘으면 개수) 2) 형식 3) 파일당 크기 4) (앱에서 더함) 읽지 못함 5) 합계 크기.
/// [attachedCount] · [attachedBytes] 는 이미 붙은 것(올리는 중 · 올림 · 전달 원본) — 앞에서 넣은 파일이 늘린다.
///
/// 기기(`MainActivity.kt` 의 `copyPicked`)가 같은 차례로 미리 걸러 넘긴 까닭(`skipped` · `notAllowed` · `tooLarge` · `overTotal`)은
/// 그 차례의 까닭으로 옮기고, 다 복사한 파일(`ok`)은 여기서 다시 판정한다(기기는 미리 거르기만 한다)
AttachmentRejection? precheckPickedFile(
PickedFile file, {
required int attachedCount,
required int attachedBytes,
required AttachmentLimits limits,
}) {
if (file.status == PickedFileStatus.skipped ||
attachedCount + 1 > kMailMaxAttachments) {
return AttachmentRejection.tooMany;
}
if (file.status == PickedFileStatus.notAllowed ||
attachmentUploadTypeOf(file.name) == null) {
return AttachmentRejection.notAllowed;
}
final int? size = file.size;
if (file.status == PickedFileStatus.tooLarge ||
(size != null && size > limits.maxBytes)) {
return AttachmentRejection.tooLarge;
}
if (file.status == PickedFileStatus.overTotal) {
return AttachmentRejection.overTotal;
}
if (file.status != PickedFileStatus.ok || size == null) {
return AttachmentRejection.unreadable;
}
if (attachedBytes + size > limits.totalMaxBytes) {
return AttachmentRejection.overTotal;
}
return null;
}
@@ -1,7 +1,8 @@
/// 보내기의 규칙 — 중복 발송 방지 키 · 받지 못한 사람 문장 · 보내기 취소 알림의 마감 · 첨부 사전 검사 (순수 함수).
/// 보내기의 규칙 — 중복 발송 방지 키 · 받지 못한 사람 문장 · 보내기 취소 알림의 마감 · 대용량 파일 덩이 몫 (순수 함수).
/// 첨부의 형식 · 한도 · 사전 검사는 `compose_attachments.dart`.
///
/// 짝: 웹 `shared/utils/idempotency-key.ts`(`shouldRenewSendKey`) · `uuid.ts` · `mail-send-result.ts`(`partialSendMessage`) ·
/// `pending-send.ts`(`undoToastDeadline`) · `mail-attachment.ts`(`precheckAttachment`) — 같은 규칙을 옮겼다(한쪽을 고치면 둘 다).
/// `pending-send.ts`(`undoToastDeadline`) · `large-file.ts`(`largeFileBlockTextReserve`) — 같은 규칙을 옮겼다(한쪽을 고치면 둘 다).
library;
import 'dart:math' as math;
@@ -99,44 +100,6 @@ Duration? undoSendRemaining({
return remaining > kUndoSendMax ? kUndoSendMax : remaining;
}
// ── 첨부 사전 검사 (계약 2절 "첨부 업로드 한도" — 전달하는 원본 · 저장본의 첨부) ──
/// 메일 한 통의 첨부 개수 한도 (웹 `MAIL_MAX_ATTACHMENTS`)
const int kMailMaxAttachments = 20;
/// 파일당 크기 대체값 — 서버 기본값과 같다(앱은 첫 판에서 올리지 않아 안내에만 쓴다 — 웹 `MAIL_ATTACHMENT_FALLBACK_MAX_BYTES`)
const int kMailAttachmentMaxBytes = 20 * 1024 * 1024;
/// 첨부 합계 대체값 — 서버 기본값과 같다(웹 `MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES`)
const int kMailAttachmentTotalMaxBytes = 25 * 1024 * 1024;
/// 허용 형식(확장자) — 계약 2절 (웹 `MAIL_ATTACHMENT_EXTENSIONS`)
const Set<String> kMailAttachmentExtensions = <String>{
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'hwp', 'hwpx', 'txt', //
'csv', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'zip',
};
/// 확장자 — 소문자, 없으면 빈 글
String extensionOf(String name) {
final int dot = name.lastIndexOf('.');
return dot == -1 ? '' : name.substring(dot + 1).toLowerCase();
}
/// 이미 붙은 첨부를 보낼 수 있는가 — 개수(`MAIL_006`) → 형식(`FILE_001`) → 합계(`FILE_002`) 차례(서버와 같다).
/// 전달하는 원본 첨부는 파일당 한도를 보지 않는다(서버도 확장자와 합계만 본다). 걸리면 그 코드, 아니면 null
String? precheckCarriedAttachment({
required String name,
required int size,
required int attachedCount,
required int attachedBytes,
int totalMaxBytes = kMailAttachmentTotalMaxBytes,
}) {
if (attachedCount + 1 > kMailMaxAttachments) return 'MAIL_006';
if (!kMailAttachmentExtensions.contains(extensionOf(name))) return 'FILE_001';
if (attachedBytes + size > totalMaxBytes) return 'FILE_002';
return null;
}
/// 서버가 덩이를 넣은 뒤 다시 재므로 보내기 직전에 텍스트 한도에서 남기는 몫 — 파일마다 이름 + 24, 머리 600
/// (웹 `largeFileBlockTextReserve` · `LARGE_FILE_BLOCK_TEXT_PER_FILE` · `_BASE`)
int largeFileBlockTextReserve(Iterable<String> names) {
@@ -29,9 +29,11 @@ abstract final class MailComposeTexts {
/// 보내는 동안의 [보내기] — 화면 낭독기 (앱에서 새로 씀)
static const String sending = '보내는 중입니다.';
/// 앱은 첫 판에서 파일을 올리지 않는다 — [파일 첨부]를 누르면 (앱에서 새로 씀 · 메일 찾기의 "아직 준비 중"과 같은 결)
static const String attachNotReady =
'앱에서 파일 첨부는 아직 준비 중입니다. 지금은 웹메일에서 첨부해 주세요.';
/// 고르는 창을 열었거나 고른 파일을 가져오는 동안 위의 진행 막대 — 화면 낭독기 (앱에서 새로 씀)
static const String picking = '파일을 가져오는 중';
/// 고르는 창을 열지 못했다(열 앱이 없음 · 이 기기에서 아직 고를 수 없음) (앱에서 새로 씀)
static const String pickFailed = '파일을 고르지 못했습니다. 다시 시도해 주세요.';
// ── 칸 ──
static const String to = '받는 사람';
@@ -55,6 +57,54 @@ abstract final class MailComposeTexts {
// ── 첨부 ──
static String attachmentsTitle(int count) => '첨부 ${formatCount(count)}개';
/// 첨부 머리 오른쪽 — "파일당 최대 20MB · 합계 25MB" (보드 — 한도는 서버 값 `GET /api/mail-quota`)
static String attachmentLimits(String perFile, String total) =>
'파일당 최대 $perFile · 합계 $total';
/// 올리는 줄 — "올리는 중 40%" (보드)
static String uploadProgress(int percent) => '올리는 중 $percent%';
/// 올리는 줄의 진행 막대 — 화면 낭독기 "현장사진_1104.jpg 올리는 중" (보드)
static String uploading(String name) => '$name 올리는 중';
/// 올리는 줄의 ✕ — 화면 낭독기 "‘현장사진_1104.jpg’ 올리기 취소" (보드 · 계획)
static String cancelUpload(String name) => '‘$name’ 올리기 취소';
/// 올리지 못한 줄 — 그 아래에 사전 문구 (계획)
static const String uploadFailed = '올리지 못했습니다.';
/// 올리지 못한 줄의 [다시 시도] — 고른 파일의 복사본으로 다시 올린다
static const String retryUpload = '다시 시도';
/// [다시 시도] — 화면 낭독기 "‘현장사진_1104.jpg’ 다시 올리기" (앱에서 새로 씀)
static String retryUploadLabel(String name) => '‘$name’ 다시 올리기';
/// 올리려던 복사본이 없어졌다(기기가 임시 폴더를 비웠다) — 다시 올릴 수 없다 (앱에서 새로 씀)
static const String uploadCopyGone = '올리려던 파일을 찾지 못했습니다. 빼고 다시 골라 주세요.';
/// 파일당 한도보다 큰 파일 — "20MB보다 큰 …" (계획 — 한도는 서버 값)
static String fileTooLarge(String limit) =>
'$limit보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.';
/// 합계 한도를 넘게 만드는 파일 — "첨부를 모두 합치면 25MB를 넘어 ‘사진.jpg’은(는) 넣지 못했습니다. …" (계획).
/// 여럿이면 "‘사진.jpg’ 외 2개는"(앱에서 더함). 조사는 `topicParticle` — 확장자로 끝나는 이름은 소리를 몰라 "은(는)"
static String overTotal(String total, String name, {int others = 0}) {
final String target = others > 0
? '‘$name’ 외 ${formatCount(others)}개'
: '‘$name’';
final String particle = topicParticle(others > 0 ? '개' : name);
return '첨부를 모두 합치면 $total를 넘어 $target$particle 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.';
}
/// 고른 파일을 읽지 못했다(클라우드 파일을 받지 못함 등) · 가져오다 시한(10분 — `kPickTimeout`)을 넘겼다 (앱에서 새로 씀)
static const String pickReadFailed = '파일을 가져오지 못했습니다. 다시 골라 주세요.';
/// 올리는 동안 [보내기] (계획)
static const String attachmentsUploading = '올리는 중인 첨부가 있습니다. 다 올린 뒤 보내 주세요.';
/// 고른 파일을 가져오는 동안 [보내기] (앱에서 새로 씀 — 리뷰에서 정함)
static const String attachmentsPicking = '첨부할 파일을 가져오는 중입니다. 다 가져온 뒤 보내 주세요.';
/// 함께 보낼 수 없는 첨부(형식 · 개수 · 합계) (앱에서 새로 씀)
static const String unsendable = '이 첨부는 함께 보낼 수 없습니다. 빼고 보내 주세요.';
@@ -106,6 +156,10 @@ abstract final class MailComposeTexts {
static const String leaveMessage = '이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다.';
static const String stay = '계속 쓰기';
static const String leave = '나가기';
// 올리는 중 나가기 확인 (계획 — 버튼 · 초점은 위 나가기 확인과 같다)
static const String uploadLeaveTitle = '올리는 중인 첨부가 있습니다';
static const String uploadLeaveMessage = '이 화면을 나가면 올리던 파일은 빠집니다.';
}
/// 읽기 화면 (보드 `App1Reader`). 보드에 없는 상태(붙잡힌 메일 · 본문 안내 · 받지 못함 · 첨부 열기 · 삭제 확인)의 글은
@@ -34,7 +34,8 @@ const double _bodyLineHeight = 1.7;
/// 메일 쓰기 — 앱 보드 `App1Compose` (앱 디자인 README 「쓰기」). 탭 막대 없는 전체 화면이다. 규칙은 [ComposeController] 머리말.
///
/// - 위 막대: ✕(나가기) · 제목(새 메일 · 답장 · 전체 답장 · 전달 — 저장본을 받기 전에는 "메일 쓰기") · [파일 첨부](첫 판은 준비 중 알림) · [보내기].
/// - 위 막대: ✕(나가기) · 제목(새 메일 · 답장 · 전체 답장 · 전달 — 저장본을 받기 전에는 "메일 쓰기") · [파일 첨부](고르는 창 — 고른 파일을 가져오는 동안
/// 위에 진행 막대) · [보내기].
/// - 칸: 받는 사람(오른쪽에 참조 · 숨은참조 펼치기) · 참조 · 숨은참조 · 제목 · (서식이 있는 남의 저장본 안내) · 첨부 · 본문 · 서명 · 원본 메일(접힘 — 누르면 펼친다).
/// 열면 비어 있는 첫 칸(받는 사람 → 제목 → 본문)에 초점을 둔다.
/// - 보내기 전 검사 · 서버의 칸 안 실패(받는 사람 · 제목 · 본문 길이)는 그 칸 아래 띠로 알리고 그 칸으로 초점을 옮긴다.
@@ -43,6 +44,8 @@ const double _bodyLineHeight = 1.7;
/// - 나가기(✕ · 뒤로 · 몸짓): 잃을 것이 없으면 그대로 닫는다(예측 뒤로 가기가 산다 — 이 화면이 저장한 적이 있으면 "임시보관함에 저장했습니다.").
/// 있으면 쓰다 만 주소를 칩으로 만들고 저장(5초까지)한 뒤 닫고 같은 알림을, 저장하지 못했으면 묻는다 — [계속 쓰기]가 처음 초점이자 주 단추이고
/// 묻는 사이 저장이 끝나면 그대로 닫는다. 보내는 중에는 묻지 않고 떠나게 둔다(결과는 알림으로).
/// 올리는 중인 첨부가 있으면 먼저 따로 묻는다("올리는 중인 첨부가 있습니다") — [나가기]면 올리던 파일을 빼고 위 차례대로,
/// 묻는 사이 모두 다 올렸으면 그대로 떠난다(올리지 못한 파일이 생겼으면 답을 기다린다).
/// - 앱이 안 보이게 되면(홈 · 다른 앱 · 화면 끄기) 바꾼 것을 바로 저장한다.
class MessageComposePage extends ConsumerStatefulWidget {
const MessageComposePage({required this.intent, this.prefill, super.key});
@@ -95,6 +98,12 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
/// 창을 열 때의 저장 차례 — 이보다 오르면 그사이 저장이 끝난 것이다
int _askedSaveCount = 0;
/// 올리는 중 나가기 확인 창이 열려 있다 — 그사이 모두 다 올렸으면 창을 닫고 떠난다
bool _askingUpload = false;
/// 창을 열 때 올리지 못한 고른 파일의 수 — 묻는 사이 늘었으면(마지막 올리기가 실패) 저절로 떠나지 않고 답을 기다린다
int _askedUploadFailures = 0;
/// 펼친 원본 메일의 줄 — 인용이 바뀔 때만 다시 만든다
ComposeQuote? _linesQuote;
List<QuoteDisplayLine> _lines = const <QuoteDisplayLine>[];
@@ -127,6 +136,21 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
next.status == ComposeStatus.ready) {
_scheduleAutofocus();
}
// 올리는 중 나가기 확인 창이 떠 있다 — 세션이 끝나면 창만 닫고, 모두 다 올렸으면 창을 닫고 떠난다.
// 묻는 사이 올리지 못한 파일이 생겼으면 닫지 않고 답을 기다린다(그 파일 없이 저장하고 떠나며 알리지 않는 일이 없게)
if (_askingUpload &&
mounted &&
!(ModalRoute.of(context)?.isCurrent ?? true)) {
if (next.ended) {
_askingUpload = false;
Navigator.of(context, rootNavigator: true).pop(false);
} else if (!next.uploading &&
failedUploadsOf(next) <= _askedUploadFailures) {
_askingUpload = false;
Navigator.of(context, rootNavigator: true).pop(true);
}
return;
}
// 나가기 확인 창이 떠 있다(창이 이미 닫히는 중이면 건드리지 않는다)
if (!_askingLeave ||
!mounted ||
@@ -178,7 +202,18 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
/// ✕ · 막힌 뒤로 가기 — 위 머리말의 차례. 떠나기 전 저장을 기다리는 동안은 [보내기]를 막는다
Future<void> _leave() async {
if (_leaving || _closing || !mounted) return;
final ComposeState state = ref.read(composeProvider(_args));
ComposeState state = ref.read(composeProvider(_args));
if (state.uploading && !state.sending && !state.ended) {
// 올리는 중인 첨부가 있다 — 먼저 따로 묻는다([나가기]면 올리던 파일을 빼고 아래 차례대로)
setState(() => _leaving = true);
final bool leave = await _askLeaveUploading();
if (!mounted) return;
setState(() => _leaving = false);
if (!leave) return;
_controller.dropUploadingAttachments();
state = ref.read(composeProvider(_args));
if (state.ended) return;
}
if (!state.unsaved || state.sending) {
_close(announceSaved: _controller.savedForLeave);
return;
@@ -219,6 +254,22 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
}
}
/// 올리는 중 나가기 확인 — [계속 쓰기]가 처음 초점이자 주 단추. 묻는 동안 모두 다 올리면 저절로 [나가기]로 닫힌다([_onStateChanged])
Future<bool> _askLeaveUploading() async {
_askingUpload = true;
_askedUploadFailures = failedUploadsOf(ref.read(composeProvider(_args)));
final bool leave = await showConfirmDialog(
context,
title: MailComposeTexts.uploadLeaveTitle,
message: MailComposeTexts.uploadLeaveMessage,
confirmLabel: MailComposeTexts.leave,
cancelLabel: MailComposeTexts.stay,
stayIsPrimary: true,
);
_askingUpload = false;
return leave;
}
/// 닫는다 — 쌓아 연 화면이면 뒤로, 주소로 바로 열었으면 메일 첫 화면으로
void _close({required bool announceSaved}) {
if (_closing || !mounted) return;
@@ -318,8 +369,8 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
appBar: ComposeAppBar(
title: composeTitleOf(state),
onClose: () => unawaited(_leave()),
onAttach: editable
? () => _notice(MailComposeTexts.attachNotReady)
onAttach: editable && !state.picking && !_leaving
? () => unawaited(_controller.pickAttachments())
: null,
onSend: ready && !_leaving ? () => unawaited(_send()) : null,
sending: state.sending,
@@ -335,7 +386,23 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
onRetry: () => unawaited(_controller.retry()),
onLeave: () => unawaited(_leave()),
),
ComposeStatus.ready => _form(context, state),
ComposeStatus.ready => Stack(
fit: StackFit.expand,
children: <Widget>[
_form(context, state),
// 고르는 창을 열었거나 고른 파일을 가져오는 동안 — 위에 진행 막대(칸을 밀지 않게 겹친다)
if (state.picking)
const Positioned(
top: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
minHeight: AppSize.progressBarHeight,
semanticsLabel: MailComposeTexts.picking,
),
),
],
),
},
),
);
@@ -394,7 +461,9 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
ComposeAttachmentsSection(
attachments: state.attachments,
largeFiles: state.largeFiles,
limits: state.limits,
onRemoveAttachment: controller.removeAttachment,
onRetryAttachment: controller.retryAttachment,
onRemoveLargeFile: controller.removeLargeFile,
enabled: enabled,
),
@@ -470,6 +539,13 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
}
}
/// 올리지 못한 고른 파일의 수
int failedUploadsOf(ComposeState state) => state.attachments
.where(
(ComposeAttachment item) => item.upload == ComposeUploadStatus.failed,
)
.length;
/// 위 막대의 제목 — 쓰기 종류(저장본을 받기 전에는 "메일 쓰기")
String composeTitleOf(ComposeState state) {
if (!state.titleKnown) return MailComposeTexts.loadingTitle;
@@ -0,0 +1,214 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/files/picked_files.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/domain/compose_attachments.dart';
import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart';
import 'package:dio/dio.dart';
/// 한 번에 올리는 첨부 수 — 나머지는 고른 차례대로 기다린다(웹은 고른 만큼 한꺼번에 — 휴대폰 망에서는 둘씩)
const int kAttachmentUploadConcurrency = 2;
/// 첨부 하나 올리기 — 저장소의 `uploadAttachment`(시험은 가짜 서버 · 가짜 함수)
typedef AttachmentUploader =
Future<MessageAttachment> Function({
required String name,
required String type,
required int length,
required Stream<List<int>> Function() read,
required CancelToken cancelToken,
required void Function(int sent, int total) onProgress,
});
/// 올릴 파일 하나 — 복사본과 보이는 이름 · 크기
class _UploadJob {
const _UploadJob({
required this.path,
required this.name,
required this.size,
});
final String path;
final String name;
final int size;
}
/// 쓰기 화면에서 고른 파일 올리기 — 계약 2절 `POST /api/mail-attachments` (웹 `useAttachmentUploads` 와 같은 결).
///
/// - 고른 차례대로 [kAttachmentUploadConcurrency] 개씩 올린다. 줄의 상태는 쓰기 화면이 소식([onStarted] · [onProgress] · [onDone] ·
/// [onFailed])으로 바꾼다 — 이 줄은 차례 · 복사본 · 취소 표만 쥔다.
/// - 복사본(`PickedFileStore`)이 있는 동안은 다시 올릴 수 있다([requeue] — [다시 시도] · 서버가 찾지 못한 첨부 `MAIL_004`).
/// 올리기 전마다 복사본이 아직 있는지 본다(기기가 임시 폴더를 비웠으면 다시 골라야 한다).
/// - 뺀 파일([remove])은 올리던 것을 끊고 복사본을 지운다 — 그 뒤 소식은 오지 않는다. [close] 는 모두 그만두고 복사본을 모두 지운다.
class AttachmentUploadQueue {
AttachmentUploadQueue({
required AttachmentUploader upload,
required PickedFileStore files,
required this.onStarted,
required this.onProgress,
required this.onDone,
required this.onFailed,
}) : _upload = upload,
_files = files;
final AttachmentUploader _upload;
final PickedFileStore _files;
/// 차례가 와 올리기 시작했다
final void Function(int key) onStarted;
/// 보낸 비율(0 ~ 100) — 보이는 비율이 바뀔 때만
final void Function(int key, int percent) onProgress;
/// 다 올렸다 — 서버의 첨부
final void Function(int key, MessageAttachment attachment) onDone;
/// 올리지 못했다
final void Function(int key, ComposeUploadFailure failure) onFailed;
/// 복사본이 있는 파일 — 화면의 줄 이름(key)마다
final Map<int, _UploadJob> _jobs = <int, _UploadJob>{};
/// 차례를 기다리는 파일 — 고른 차례대로
final List<int> _waiting = <int>[];
/// 올리는 중인 파일과 그 취소 표
final Map<int, CancelToken> _running = <int, CancelToken>{};
/// 마지막으로 알린 비율 — 같은 비율을 거듭 알리지 않는다
final Map<int, int> _percents = <int, int>{};
bool _closed = false;
/// 복사본이 있다 — 다시 올릴 수 있다
bool hasCopy(int key) => _jobs.containsKey(key);
/// 올리는 중이거나 기다리는 파일이 있다
bool get busy => _waiting.isNotEmpty || _running.isNotEmpty;
/// 고른 파일 하나를 차례에 넣는다 — 그만둔 뒤면 복사본만 지운다
void add(
int key, {
required String path,
required String name,
required int size,
}) {
if (_closed) {
unawaited(_files.delete(path));
return;
}
_jobs[key] = _UploadJob(path: path, name: name, size: size);
_waiting.add(key);
_pump();
}
/// 복사본으로 다시 올린다([다시 시도] · `MAIL_004` 뒤) — 차례의 끝에 넣는다.
/// 복사본이 없거나 이미 올리는 중 · 기다리는 중이면 거짓
bool requeue(int key) {
if (_closed ||
!_jobs.containsKey(key) ||
_waiting.contains(key) ||
_running.containsKey(key)) {
return false;
}
_waiting.add(key);
_pump();
return true;
}
/// 뺀다 — 올리던 것을 끊고 복사본을 지운다(그 파일의 소식은 더 오지 않는다)
void remove(int key) => removeAll(<int>[key]);
/// 여럿을 뺀다 — 다 뺀 뒤에 다음 차례를 시작한다(뺄 파일이 그사이 시작하지 않게)
void removeAll(Iterable<int> keys) {
for (final int key in keys) {
_waiting.remove(key);
_running.remove(key)?.cancel();
_percents.remove(key);
final _UploadJob? job = _jobs.remove(key);
if (job != null) unawaited(_files.delete(job.path));
}
_pump();
}
/// 모두 그만둔다 — 올리던 것을 끊고 복사본을 모두 지운다(쓰기 화면이 끝날 때 · 세션이 끝날 때). 그 뒤의 [add] 는 복사본만 지운다
void close() {
if (_closed) return;
_closed = true;
_waiting.clear();
for (final CancelToken token in _running.values) {
token.cancel();
}
_running.clear();
_percents.clear();
for (final _UploadJob job in _jobs.values) {
unawaited(_files.delete(job.path));
}
_jobs.clear();
}
void _pump() {
while (!_closed &&
_running.length < kAttachmentUploadConcurrency &&
_waiting.isNotEmpty) {
final int key = _waiting.removeAt(0);
final _UploadJob? job = _jobs[key];
if (job == null) continue;
final CancelToken cancel = CancelToken();
_running[key] = cancel;
_percents[key] = 0;
onStarted(key);
unawaited(_run(key, job, cancel));
}
}
/// 그 올리기가 아직 이 파일의 올리기인가 — 뺐거나 그만뒀으면 거짓(그 소식은 버린다)
bool _owns(int key, CancelToken cancel) =>
!_closed && identical(_running[key], cancel);
Future<void> _run(int key, _UploadJob job, CancelToken cancel) async {
MessageAttachment? uploaded;
ComposeUploadFailure? failure;
final String? type = attachmentUploadTypeOf(job.name);
if (type == null) {
// 사전 검사를 지났으면 오지 않는다 — 서버도 같은 까닭으로 거절한다
failure = ComposeUploadFailure.of('FILE_001');
} else if (!await _files.exists(job.path)) {
failure = ComposeUploadFailure.copyGone;
} else if (_owns(key, cancel)) {
try {
uploaded = await _upload(
name: job.name,
type: type,
length: job.size,
read: () => _files.openRead(job.path),
cancelToken: cancel,
onProgress: (int sent, int total) {
if (!_owns(key, cancel) || total <= 0) return;
final int percent = (sent * 100 ~/ total).clamp(0, 100);
if (_percents[key] == percent) return;
_percents[key] = percent;
onProgress(key, percent);
},
);
} on DioException catch (error) {
failure = error.type == DioExceptionType.cancel
? null
: ComposeUploadFailure.of(apiErrorCodeOf(error) ?? kFallbackCode);
} on Object {
// 그 밖의 실패(뜻밖의 예외)도 줄을 "올리는 중"에 두지 않는다 — [다시 시도]
failure = ComposeUploadFailure.of(kFallbackCode);
}
}
if (!_owns(key, cancel)) return;
_running.remove(key);
_percents.remove(key);
final MessageAttachment? done = uploaded;
if (done != null) {
onDone(key, done);
} else if (failure != null) {
onFailed(key, failure);
}
_pump();
}
}
@@ -1,9 +1,11 @@
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/api/error_notifier.dart';
import 'package:arasystem_app/core/files/picked_files.dart';
import 'package:arasystem_app/core/people/address_input.dart';
import 'package:arasystem_app/core/people/people_texts.dart';
import 'package:arasystem_app/core/people/person_address.dart';
@@ -12,6 +14,7 @@ import 'package:arasystem_app/core/people/recipient_suggestion.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/core/utils/file_size.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/data/models/compose_payload.dart';
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
@@ -20,6 +23,7 @@ import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:arasystem_app/features/mail/data/repositories/compose_repository.dart';
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
import 'package:arasystem_app/features/mail/domain/compose_attachments.dart';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_fit.dart';
@@ -29,6 +33,7 @@ import 'package:arasystem_app/features/mail/domain/compose_send.dart';
import 'package:arasystem_app/features/mail/domain/compose_signature.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/providers/attachment_uploads.dart';
import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart';
import 'package:arasystem_app/features/mail/presentation/providers/draft_autosave.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
@@ -73,6 +78,20 @@ final Provider<DraftSuccessors> draftSuccessorsProvider =
return DraftSuccessors();
});
/// 고른 파일의 복사본을 두는 곳 — 메일을 보는 직원이 바뀌면(로그아웃 · 세션 끝 · 다른 직원) 모두 지운다(받은 파일과 같은 결 — `mailDownloadedFilesProvider`).
/// 앱이 살아 있는 동안 둔다. 지난 실행이 남긴 복사본은 main 이 앱을 띄울 때 지운다(`PickedFileStore.purge`)
final Provider<PickedFileStore> composePickedFilesProvider =
Provider<PickedFileStore>((Ref ref) {
final PickedFileStore store = ref.watch(pickedFileStoreProvider);
ref.listen<String?>(mailAccountProvider, (
String? previous,
String? next,
) {
if (previous != next) unawaited(store.purge());
});
return store;
});
/// 옛 저장본 id → 새 id (웹 `mail.store` 의 `recordDraftSuccessor` · `latestDraftId`)
class DraftSuccessors {
final Map<String, String> _next = <String, String>{};
@@ -120,6 +139,10 @@ const Set<String> kComposeInlineCodes = <String>{
'MAIL_016',
};
/// 전달 원본 · 저장본의 첨부를 검사하기 전에 첨부 한도를 기다리는 가장 긴 시간 — 넘으면 대체값(20 · 25MB)으로 잰다
/// (웹은 기다리지 않고 받아 둔 값을 쓴다 — 앱은 열 때 한 번 받으므로 잠깐 기다린다. 한도 응답이 늦어도 화면이 그만큼 늦게 열리지 않게)
const Duration kAttachmentLimitsWait = Duration(seconds: 2);
/// 서버에 다시 묻기 전에 기다리는 시간 (웹 `RECIPIENT_SEARCH_DELAY_MS`)
const Duration kRecipientSearchDelay = Duration(milliseconds: 250);
@@ -189,6 +212,10 @@ class _FormSnapshot {
/// 원본 · 저장본을 받지 못하면 [다시 시도] 화면이다. 처음 채운 내용은 "바꿈"이 아니다.
/// - 본문은 글만(서식 편집은 다음 판) — 보낼 때 서식 본문 + 텍스트를 함께 만든다(`compose_body.dart`). 원문 인용은 글로 만든 덩이이고
/// 처음에는 한도의 95%, 보내기 · 저장 직전에 100% 로 맞춘다(`compose_fit.dart`).
/// - 첨부: [파일 첨부]는 고르는 창(`PickedFileStore`)으로 고른 파일을 복사해 사전 검사(개수 → 형식 → 파일당 → 합계 — 한도는 `GET /api/mail-quota`,
/// 받지 못하면 20 · 25MB)하고 [AttachmentUploadQueue] 로 둘씩 올린다. 저장 · 보내기에는 다 올린 첨부만 싣고, 올리는 동안은 보내기를 막는다.
/// 서버가 첨부를 찾지 못하면(`MAIL_004`) 고른 파일은 복사본으로 다시 올리고 원본 · 저장본의 첨부는 "찾지 못함"으로 둔다.
/// 복사본은 뺄 때 · 화면이 끝날 때 · 세션이 끝날 때 지운다.
/// - 임시 저장은 [DraftAutosave] — 쓰는 동안은 보이지 않고, 저장한 채 나가면 화면이 "임시보관함에 저장했습니다."를 알린다.
/// - 보내기: 칸에 쓰다 만 주소 확정 → 사전 검사 → 진행 중인 저장 기다리기 → 길이 다시 재기 → POST(같은 키 — 키를 바꾸는 것은 성공과 `MAIL_005` 뿐).
/// 보내는 동안 화면을 떠나도 응답까지 산다 — 결과는 알림으로만, 실패하면 같은 세션일 때만 쓰던 내용을 저장본으로 남긴다.
@@ -220,6 +247,20 @@ class ComposeController extends Notifier<ComposeState> {
/// 웹 · 다른 프로그램이 만든 저장본 — 본문을 고치지 않으면 원래 HTML 그대로 보낸다
ForeignDraftBody? _foreign;
int _nextAttachmentKey = 0;
/// 고른 파일의 복사본 · 올리기 차례
late final PickedFileStore _files;
late final AttachmentUploadQueue _uploads;
/// 첨부 한도를 받는 일 — 전달 원본 · 저장본의 첨부를 검사하기 전에 잠깐 기다린다(받지 못하거나 늦으면 대체값)
Future<void> _limitsLoaded = Future<void>.value();
/// `MAIL_004` 로 저절로 다시 올린 줄 — 그것을 실은 저장이 성공하기 전에 또 `MAIL_004` 면 다시 올리지 않는다(되풀이를 막는다)
final Set<int> _reuploaded = <int>{};
/// 서버가 잃은(`MAIL_004`) 고른 파일 — 다시 올리기에 성공하거나 뺄 때까지, 올리지 못하면 "찾지 못함"으로 보내기 · 저장을 막는다.
/// 이미 이 메일에 실렸던 첨부라 모르는 사이 빠진 채 나가지 않게(웹 `markMissing` — 웹은 다시 올리지 않고 빼기만)
final Set<int> _lostOnServer = <int>{};
int? _undoSendSeconds;
bool _savedOnce = false;
@@ -262,6 +303,16 @@ class ComposeController extends Notifier<ComposeState> {
_account = ref.read(mailAccountProvider);
_session = _tokens.sessionGeneration;
_successors = ref.read(draftSuccessorsProvider);
_files = ref.read(composePickedFilesProvider);
_uploads = AttachmentUploadQueue(
upload: _repository.uploadAttachment,
files: _files,
onStarted: (int key) => _setUpload(key, ComposeUploadStatus.uploading),
onProgress: (int key, int percent) =>
_setUpload(key, ComposeUploadStatus.uploading, progress: percent),
onDone: _onUploaded,
onFailed: _onUploadFailed,
);
_autosave = DraftAutosave(
canSave: _canSave,
hasChanges: _hasDraftChanges,
@@ -291,6 +342,7 @@ class ComposeController extends Notifier<ComposeState> {
_preloadSuggestions();
_preloadGroups();
unawaited(_loadUndoSeconds());
_limitsLoaded = _loadLimits();
final ComposeState initial;
switch (intent) {
@@ -374,6 +426,7 @@ class ComposeController extends Notifier<ComposeState> {
state = ComposeState(
kind: state.kind,
titleKnown: args.intent is! DraftComposeIntent,
limits: state.limits,
);
await _loadSource(_formGeneration);
}
@@ -429,6 +482,8 @@ class ComposeController extends Notifier<ComposeState> {
signatureText: prepared?.text,
),
);
// 전달하는 원본 첨부의 합계는 서버 한도로 잰다(받지 못하거나 늦으면 대체값) — 잴 첨부가 있을 때만 기다린다(답장은 기다리지 않는다)
if (forward && detail.attachments.isNotEmpty) await _waitLimits();
if (!_alive(generation)) return;
List<RecipientToken> to = const <RecipientToken>[];
List<RecipientToken> cc = const <RecipientToken>[];
@@ -491,6 +546,8 @@ class ComposeController extends Notifier<ComposeState> {
bodyHtml: draft.bodyHtml,
body: draft.body,
);
// 저장본의 첨부 합계도 서버 한도로 잰다 — 첨부가 있을 때만 기다린다
if (draft.attachments.isNotEmpty) await _waitLimits();
if (!_alive(generation)) return;
ComposeSignature? signature;
ComposeQuote? quote;
@@ -560,6 +617,7 @@ class ComposeController extends Notifier<ComposeState> {
size: attachment.size,
attachedCount: count,
attachedBytes: bytes,
totalMaxBytes: state.limits.totalMaxBytes,
);
if (rejected == null) {
count++;
@@ -598,6 +656,21 @@ class ComposeController extends Notifier<ComposeState> {
}
}
/// 첨부 한도를 [kAttachmentLimitsWait] 까지 기다린다 — 넘으면 그대로 간다(받으면 뒤에 바뀐다)
Future<void> _waitLimits() =>
_limitsLoaded.timeout(kAttachmentLimitsWait, onTimeout: () {});
/// 첨부 한도(파일당 · 합계) — 받지 못하면 대체값(20 · 25MB)으로 쓴다
Future<void> _loadLimits() async {
try {
final AttachmentLimits limits = await _repository.getAttachmentLimits();
if (!ref.mounted || _ended) return;
if (limits != state.limits) state = state.copyWith(limits: limits);
} on Object {
// 대체값으로 쓴다 — 알리지 않는다(뜻밖의 실패도 열기를 막거나 처리되지 않은 오류로 남지 않게)
}
}
// ── 받는 사람 ───────────────────────────────────────
/// 칸의 글이 바뀌었다 — 구분자(쉼표 · 세미콜론 · 줄바꿈, 따옴표 · 꺾쇠 밖)까지는 칩으로, 남은 글은 그대로. 모자라면 서버에 묻는다
@@ -886,8 +959,12 @@ class ComposeController extends Notifier<ComposeState> {
_emitUnsaved();
}
/// 첨부를 뺀다 — 고른 파일이면 올리던 것을 끊고(✕ = 올리기 취소) 복사본을 지운다
void removeAttachment(int key) {
if (!ref.mounted) return;
_uploads.remove(key);
_reuploaded.remove(key);
_lostOnServer.remove(key);
state = state.copyWith(
attachments: <ComposeAttachment>[
for (final ComposeAttachment item in state.attachments)
@@ -898,6 +975,264 @@ class ComposeController extends Notifier<ComposeState> {
_emitUnsaved();
}
// ── 첨부 고르기 · 올리기 ─────────────────────────────
/// 한도에 세는 첨부의 수 · 바이트 — 보낼 첨부와 올리는 중 · 다시 올릴 수 있는 고른 파일(`ComposeAttachment.countsTowardLimits`)
({int count, int bytes}) get _attachedTotals {
int count = 0;
int bytes = 0;
for (final ComposeAttachment item in state.attachments) {
if (!item.countsTowardLimits) continue;
count++;
bytes += item.size;
}
return (count: count, bytes: bytes);
}
/// [파일 첨부] — 고르는 창을 열고, 고른 파일을 사전 검사한 뒤 올린다(차례: 개수 `MAIL_006` → 형식 `FILE_001` → 파일당 → 합계).
///
/// 이미 20개면 창을 열지 않고 알린다. 기기는 넘겨받은 한도(남은 개수 · 허용 확장자 · 파일당 · 남은 합계 — 열 때의 한도)로 같은 차례로 미리 걸러
/// 넣을 파일만 복사하고(받지 않을 형식 · 합계를 넘는 파일은 읽지 않는다), 여기서 다시 판정한다.
/// 넣지 못한 파일은 줄로 남기지 않고 알림 하나로 까닭을 모아 알린다. 고르는 · 가져오는 동안은 [보내기]를 막는다([sendBlockReason]).
/// 고른 결과가 온 때 세션이 끝났거나 보내는 중 · 보냄 · 화면이 떠났으면 복사본만 지운다
Future<void> pickAttachments() async {
if (!ref.mounted ||
_ended ||
state.status != ComposeStatus.ready ||
state.sending ||
state.picking) {
return;
}
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
final ({int count, int bytes}) attached = _attachedTotals;
if (attached.count >= kMailMaxAttachments) {
notice(ApiException.fromCode('MAIL_006').message);
return;
}
final AttachmentLimits limits = state.limits;
final int generation = _formGeneration;
final PickedFileStore files = _files;
state = state.copyWith(picking: true);
PickResult result;
try {
result = await files.pick(
maxBytes: limits.maxBytes,
maxCount: kMailMaxAttachments - attached.count,
maxTotalBytes: math.max(0, limits.totalMaxBytes - attached.bytes),
extensions: kMailAttachmentTypes.keys.toList(),
);
} on Object {
// 뜻밖의 실패도 [파일 첨부]를 막힌 채 두지 않는다
result = const PickResult(PickOutcome.failed);
}
// 고르는 사이 세션이 끝났거나, 보내는 중 · 보냄 · 화면이 떠났다 — 붙이지 않고 복사본만 지운다
if (!_alive(generation) ||
!_sameSession() ||
_sent ||
state.sending ||
!_attached) {
for (final PickedFile file in result.files) {
final String? path = file.path;
if (path != null) unawaited(files.delete(path));
}
if (ref.mounted && !_ended) state = state.copyWith(picking: false);
return;
}
state = state.copyWith(picking: false);
switch (result.outcome) {
case PickOutcome.picked:
_addPicked(result.files, limits, notice);
case PickOutcome.noApp:
case PickOutcome.failed:
notice(MailComposeTexts.pickFailed);
case PickOutcome.timedOut:
// 고른 파일을 가져오다 시한을 넘겼다(고른 앱이 멈춤) — 고르는 창을 열지 못한 것과 가른다
notice(MailComposeTexts.pickReadFailed);
case PickOutcome.canceled:
case PickOutcome.busy:
break;
}
}
/// 고른 파일을 고른 차례대로 검사해 붙이고 올리기 차례에 넣는다 — 앞에서 넣은 파일이 개수 · 합계를 늘린다(웹과 같다)
void _addPicked(
List<PickedFile> files,
AttachmentLimits limits,
void Function(String text) notice,
) {
final ({int count, int bytes}) attached = _attachedTotals;
int count = attached.count;
int bytes = attached.bytes;
final Map<AttachmentRejection, List<String>> rejected =
<AttachmentRejection, List<String>>{};
final List<ComposeAttachment> added = <ComposeAttachment>[];
final List<({int key, String path, int size, String name})> queued =
<({int key, String path, int size, String name})>[];
for (final PickedFile file in files) {
final String? path = file.path;
final int? size = file.size;
final AttachmentRejection? rejection = precheckPickedFile(
file,
attachedCount: count,
attachedBytes: bytes,
limits: limits,
);
if (rejection != null || path == null || size == null) {
(rejected[rejection ?? AttachmentRejection.unreadable] ??= <String>[])
.add(file.name);
if (path != null) unawaited(_files.delete(path));
continue;
}
count++;
bytes += size;
final int key = _nextAttachmentKey++;
added.add(
ComposeAttachment(
key: key,
id: '',
name: file.name,
size: size,
upload: ComposeUploadStatus.waiting,
),
);
queued.add((key: key, path: path, size: size, name: file.name));
}
if (added.isNotEmpty) {
state = state.copyWith(
attachments: List<ComposeAttachment>.unmodifiable(<ComposeAttachment>[
...state.attachments,
...added,
]),
);
for (final ({int key, String path, int size, String name}) item
in queued) {
_uploads.add(
item.key,
path: item.path,
name: item.name,
size: item.size,
);
}
_emitUnsaved();
}
if (rejected.isNotEmpty) notice(pickRejectionNotice(rejected, limits));
}
/// 올리지 못한 첨부의 [다시 시도] — 복사본으로 다시 올린다(다시 고르지 않는다). 복사본이 없으면 그 까닭으로 바꾼다
void retryAttachment(int key) {
if (!ref.mounted || _ended || state.sending) return;
final ComposeAttachment? item = _attachmentOf(key);
if (item == null ||
item.upload != ComposeUploadStatus.failed ||
!(item.failure?.canRetry ?? false)) {
return;
}
_reuploaded.remove(key);
_setUpload(key, ComposeUploadStatus.waiting);
if (!_uploads.requeue(key)) {
// 다시 넣지 못했다(복사본 차례가 없음) — 서버가 잃은 줄이면 "찾지 못함"도 붙어 막힌다(다른 실패와 같은 규칙)
_onUploadFailed(key, ComposeUploadFailure.copyGone);
}
}
/// 올리는 중 나가기 확인의 [나가기] — 올리던 · 기다리던 첨부를 빼고 그만둔다(다 올린 첨부는 그대로 저장한다)
void dropUploadingAttachments() {
if (!ref.mounted) return;
final Set<int> dropped = <int>{
for (final ComposeAttachment item in state.attachments)
if (item.uploading) item.key,
};
if (dropped.isEmpty) return;
_uploads.removeAll(dropped);
_reuploaded.removeAll(dropped);
_lostOnServer.removeAll(dropped);
state = state.copyWith(
attachments: <ComposeAttachment>[
for (final ComposeAttachment item in state.attachments)
if (!dropped.contains(item.key)) item,
],
);
_emitUnsaved();
}
ComposeAttachment? _attachmentOf(int key) {
for (final ComposeAttachment item in state.attachments) {
if (item.key == key) return item;
}
return null;
}
/// 그 줄만 바꾼다 — 이미 뺀 줄이면 아무것도 하지 않는다
void _replaceAttachment(
int key,
ComposeAttachment Function(ComposeAttachment item) change,
) {
final List<ComposeAttachment> current = state.attachments;
final int index = current.indexWhere(
(ComposeAttachment item) => item.key == key,
);
if (index == -1) return;
final ComposeAttachment next = change(current[index]);
if (next == current[index]) return;
state = state.copyWith(
attachments: List<ComposeAttachment>.unmodifiable(
List<ComposeAttachment>.of(current)..[index] = next,
),
);
}
/// 올리기 소식 — 그 줄의 상태를 바꾼다(다 올리기 전에는 id 를 비운다)
void _setUpload(
int key,
ComposeUploadStatus status, {
int progress = 0,
ComposeUploadFailure? failure,
}) {
if (!ref.mounted || _ended) return;
_replaceAttachment(
key,
(ComposeAttachment item) =>
item.withUpload(status, progress: progress, failure: failure),
);
_emitUnsaved();
}
/// 올리지 못했다 — 서버가 잃은(`MAIL_004`) 파일을 다시 올리다 실패했으면 "찾지 못함"도 붙여 빼거나 다시 올리기 전까지 막는다
/// (복사본이 없어졌으면 [다시 시도]도 없다 — 빼기만)
void _onUploadFailed(int key, ComposeUploadFailure failure) {
if (!ref.mounted || _ended) return;
final bool lost = _lostOnServer.contains(key);
_replaceAttachment(key, (ComposeAttachment item) {
final ComposeAttachment failed = item.withUpload(
ComposeUploadStatus.failed,
failure: failure,
);
return lost
? failed.copyWith(problem: ComposeAttachmentProblem.missing)
: failed;
});
_emitUnsaved();
}
/// 다 올렸다 — 서버의 첨부(id · 서버가 정리한 이름 · 크기)로 저장 · 보내기에 싣고 줄에도 그 이름을 보인다(바꿈 — 자동 저장).
/// 고른 앱이 255자를 넘는 이름을 주면 서버가 줄여 저장한다 — 줄이기 전 이름을 실으면 저장 · 발송이 `VAL_001` 이다(웹도 응답의 이름을 싣는다)
void _onUploaded(int key, MessageAttachment attachment) {
if (!ref.mounted || _ended) return;
_lostOnServer.remove(key);
_replaceAttachment(
key,
(ComposeAttachment item) => item.withUpload(
ComposeUploadStatus.done,
id: attachment.id,
progress: 100,
name: attachment.name,
size: attachment.size,
),
);
_autosave.touch();
_emitUnsaved();
}
void removeLargeFile(String id) {
if (!ref.mounted) return;
state = state.copyWith(
@@ -910,7 +1245,8 @@ class ComposeController extends Notifier<ComposeState> {
_emitUnsaved();
}
/// [보내기]를 막는 까닭 — 빼야 하는 파일이 먼저(위험 · 지움 · 찾지 못한 첨부), 그다음 웹에서 올리다 멈춘 파일. 막지 않으면 null
/// [보내기]를 막는 까닭 — 빼야 하는 파일이 먼저(위험 · 지움 · 찾지 못한 첨부), 그다음 웹에서 올리다 멈춘 파일, 마지막으로 올리는 중 · 가져오는 중인 첨부
/// (웹 `sendBlockOf` 와 같은 차례 — 가져오는 중은 앱에서 더함: 드라이브 파일을 받는 사이 누르면 그 파일 없이 나가지 않게). 막지 않으면 null
String? get sendBlockReason {
final List<DraftLargeFile> large = state.largeFiles;
if (large.any((DraftLargeFile f) => f.infected)) {
@@ -929,6 +1265,8 @@ class ComposeController extends Notifier<ComposeState> {
)) {
return MailComposeTexts.largeUploading;
}
if (state.uploading) return MailComposeTexts.attachmentsUploading;
if (state.picking) return MailComposeTexts.attachmentsPicking;
return null;
}
@@ -940,16 +1278,40 @@ class ComposeController extends Notifier<ComposeState> {
(DraftLargeFile f) => f.status == DraftLargeFileStatus.missing,
);
/// 서버가 첨부를 찾지 못했다(`MAIL_004`) — 어느 것인지 모르니 싣던 첨부를 모두 "찾지 못함"으로(웹과 같다)
/// 서버가 첨부를 찾지 못했다(`MAIL_004`) — 어느 것인지 모르니 싣던 첨부를 모두 손본다(웹 `markMissing` 과 같은 결):
/// 이 화면에서 고른 파일은 복사본으로 한 번 다시 올리고, 다시 올릴 수 없으면(이미 한 번 다시 올림 · 화면이 닫혀 차례가 닫힘)
/// 원본 · 저장본의 첨부처럼 "찾지 못함"으로 둔다 — 빼기 전까지 저장 · 보내기를 막는다(모르는 사이 첨부 없이 나가지 않게).
/// 다시 올리다 실패하면(복사본이 없어짐 등) 그때 "찾지 못함"을 붙인다([_onUploadFailed])
void _markAttachmentsMissing() {
// 다시 올릴 줄 — 그 전의 모습(다시 올리지 못하면 그 모습에 "찾지 못함"을 붙인다)
final Map<int, ComposeAttachment> reupload = <int, ComposeAttachment>{};
final List<ComposeAttachment> next = <ComposeAttachment>[];
for (final ComposeAttachment item in state.attachments) {
if (!item.sendable) {
next.add(item);
} else if (item.upload != null &&
_uploads.hasCopy(item.key) &&
!_reuploaded.contains(item.key)) {
reupload[item.key] = item;
next.add(item.withUpload(ComposeUploadStatus.waiting));
} else {
next.add(item.copyWith(problem: ComposeAttachmentProblem.missing));
}
}
state = state.copyWith(
attachments: <ComposeAttachment>[
for (final ComposeAttachment item in state.attachments)
item.sendable
? item.copyWith(problem: ComposeAttachmentProblem.missing)
: item,
],
attachments: List<ComposeAttachment>.unmodifiable(next),
);
for (final MapEntry<int, ComposeAttachment> entry in reupload.entries) {
_reuploaded.add(entry.key);
_lostOnServer.add(entry.key);
if (!_uploads.requeue(entry.key)) {
_replaceAttachment(
entry.key,
(ComposeAttachment _) =>
entry.value.copyWith(problem: ComposeAttachmentProblem.missing),
);
}
}
_emitUnsaved();
}
@@ -1011,7 +1373,7 @@ class ComposeController extends Notifier<ComposeState> {
(TextEditingController input) => input.text.trim().isNotEmpty,
);
/// 떠나면 잃는 것이 있다 — 저장하지 않은 변경 · 칸에 쓰다 만 주소 · 찾지 못한 첨부(그동안은 저장하지 않는다)
/// 떠나면 잃는 것이 있다 — 저장하지 않은 변경 · 칸에 쓰다 만 주소 · 찾지 못한 첨부(그동안은 저장하지 않는다) · 올리는 중인 첨부
bool get hasUnsavedChanges {
if (!ref.mounted ||
_ended ||
@@ -1022,7 +1384,8 @@ class ComposeController extends Notifier<ComposeState> {
return _hasDraftChanges() ||
_typingRecipient ||
_hasMissingAttachments ||
_hasMissingLargeFiles;
_hasMissingLargeFiles ||
state.uploading;
}
void _emitUnsaved() {
@@ -1146,6 +1509,9 @@ class ComposeController extends Notifier<ComposeState> {
if (!_alive(generation)) return;
_baseline = snapshot;
_savedOnce = true;
_reuploaded.removeAll(<int>[
for (final ComposeAttachment item in carried) item.key,
]);
_applySavedAttachments(carried, saved.attachments);
// 저장한 차례 — 나가기 확인 창이 "그사이 저장이 끝났다"를 이것으로 안다
state = state.copyWith(saveCount: state.saveCount + 1);
@@ -1231,6 +1597,8 @@ class ComposeController extends Notifier<ComposeState> {
if (!_attached) return;
_attached = false;
_cancelSearch();
// 올리던 첨부는 그만두고 복사본을 지운다 — 떠나기 전 저장은 다 올린 첨부만 실었다(보내기는 올리는 동안 막혀 있다)
_uploads.close();
if (!_sent && !_sendingNow) _autosave.stop();
final Set<String> removed = <String>{..._replacedIds};
if ((removed.isNotEmpty || _savedOnce) && !_ended && ref.mounted) {
@@ -1536,6 +1904,7 @@ class ComposeController extends Notifier<ComposeState> {
_ended = true;
_autosave.stop();
_cancelSearch();
_uploads.close();
if (!ref.mounted) return;
// 잃을 것이 없다고 본다 — 화면이 묻지 않고 닫히게(다른 직원의 임시보관함에 남기지 않는다). 나가기 확인 창도 알리지 않고 닫는다
state = state.copyWith(ended: true);
@@ -1545,6 +1914,7 @@ class ComposeController extends Notifier<ComposeState> {
void _dispose() {
_autosave.dispose();
_cancelSearch();
_uploads.close();
subject.dispose();
body.dispose();
for (final TextEditingController input in inputs.values) {
@@ -1578,6 +1948,31 @@ Future<void> _undoSend(
}
}
/// 고른 파일 가운데 넣지 못한 것의 알림 — 까닭마다 한 문장, 처음 나온 차례대로 줄을 바꿔 잇는다(알림은 한 번에 하나만 보인다).
/// 개수 · 형식은 사전 문구(`MAIL_006` · `FILE_001`), 크기는 서버 한도로 쓴 글, 합계는 넣지 못한 첫 파일 이름(여럿이면 "외 N개")
String pickRejectionNotice(
Map<AttachmentRejection, List<String>> rejected,
AttachmentLimits limits,
) => <String>[
for (final MapEntry<AttachmentRejection, List<String>> entry
in rejected.entries)
switch (entry.key) {
AttachmentRejection.tooMany => ApiException.fromCode('MAIL_006').message,
AttachmentRejection.notAllowed => ApiException.fromCode(
'FILE_001',
).message,
AttachmentRejection.tooLarge => MailComposeTexts.fileTooLarge(
formatMegabytes(limits.maxBytes),
),
AttachmentRejection.unreadable => MailComposeTexts.pickReadFailed,
AttachmentRejection.overTotal => MailComposeTexts.overTotal(
formatMegabytes(limits.totalMaxBytes),
entry.value.first,
others: entry.value.length - 1,
),
},
].join('\n');
/// 받는 사람 칸의 이름 — "받는 사람" · "참조" · "숨은참조"
String fieldLabelOf(RecipientFieldKind kind) => switch (kind) {
RecipientFieldKind.to => MailComposeTexts.to,
@@ -1,6 +1,7 @@
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/people/address_input.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/domain/compose_attachments.dart';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_reply.dart';
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
@@ -39,7 +40,7 @@ enum RecipientFieldKind { to, cc, bcc }
/// 길이가 한도를 넘은 칸 — 제목 255자 · 본문(인용을 다 줄여도 넘음)
enum ComposeLengthError { subject, body }
/// 첨부 줄의 문제
/// 첨부 줄의 문제 — 원본 · 저장본에 있던 첨부
enum ComposeAttachmentProblem {
/// 함께 보낼 수 없는 형식 · 개수 · 합계(사전 검사) — 싣지 않는다
unsendable,
@@ -48,7 +49,57 @@ enum ComposeAttachmentProblem {
missing,
}
/// 쓰기 화면의 일반 첨부 하나 — 전달하는 원본 · 저장본에 이미 있던 첨부(앱은 첫 판에서 새로 올리지 않는다)
/// 이 화면에서 고른 파일의 올리기 상태 (보드 `App1Compose` 「올리는 중 40%」)
enum ComposeUploadStatus {
/// 차례를 기다린다(한 번에 2개씩 올린다)
waiting,
/// 올리는 중 — 진행 %
uploading,
/// 다 올렸다 — 저장 · 보내기에 싣는다
done,
/// 올리지 못했다 — 사전 문구 + [다시 시도](다시 해도 같은 실패면 빼기만)
failed,
}
/// 올리지 못한 까닭
@immutable
class ComposeUploadFailure {
const ComposeUploadFailure({required this.code, required this.canRetry});
/// 서버 · 연결 실패 — 형식 · 크기 거절(`FILE_001` · `FILE_002`)과 요청 거절(`VAL_001`)은 다시 올려도 같아 [다시 시도]가 없다
factory ComposeUploadFailure.of(String code) =>
ComposeUploadFailure(code: code, canRetry: !_finalCodes.contains(code));
/// 복사본이 없어졌다(기기가 임시 폴더를 비웠다) — 다시 골라야 한다
static const ComposeUploadFailure copyGone = ComposeUploadFailure(
code: null,
canRetry: false,
);
static const Set<String> _finalCodes = <String>{
'FILE_001',
'FILE_002',
'VAL_001',
};
/// 사전 문구의 코드 — null 이면 복사본이 없어졌다
final String? code;
final bool canRetry;
@override
bool operator ==(Object other) =>
other is ComposeUploadFailure &&
other.code == code &&
other.canRetry == canRetry;
@override
int get hashCode => Object.hash(code, canRetry);
}
/// 쓰기 화면의 일반 첨부 하나 — 전달하는 원본 · 저장본에 있던 첨부([upload] 가 null), 또는 이 화면에서 고른 파일([upload] 가 있다)
@immutable
class ComposeAttachment {
const ComposeAttachment({
@@ -57,17 +108,44 @@ class ComposeAttachment {
required this.name,
required this.size,
this.problem,
this.upload,
this.progress = 0,
this.failure,
});
/// 화면 안의 이름 — 저장할 때마다 바뀌는 [id] 와 달리 그대로다
final int key;
/// 서버의 첨부 id — 고른 파일은 다 올리기 전에는 빈 글
final String id;
final String name;
final int size;
final ComposeAttachmentProblem? problem;
/// 저장 · 보내기에 싣는다
bool get sendable => problem == null;
/// 이 화면에서 고른 파일의 올리기 상태 — 원본 · 저장본의 첨부는 null
final ComposeUploadStatus? upload;
/// 올리는 중의 비율(0 ~ 100)
final int progress;
/// 올리지 못한 까닭 — [ComposeUploadStatus.failed] 일 때만
final ComposeUploadFailure? failure;
/// 저장 · 보내기에 싣는다 — 문제가 없고, 고른 파일이면 다 올렸다
bool get sendable =>
problem == null && (upload == null || upload == ComposeUploadStatus.done);
/// 올리는 중이거나 차례를 기다린다 — 보내기를 막고, 나가기는 따로 묻는다
bool get uploading =>
upload == ComposeUploadStatus.waiting ||
upload == ComposeUploadStatus.uploading;
/// 개수 · 합계 한도에 센다 — 보낼 첨부와, 고른 파일 가운데 다시 올릴 수 있는 것(올리지 못했어도 [다시 시도]가 있으면).
/// 함께 보낼 수 없음 · 찾지 못함(원본 · 다시 올릴 수 없는 고른 파일) · 다시 올릴 수 없는 실패는 세지 않는다
bool get countsTowardLimits {
if (upload == ComposeUploadStatus.failed) return failure?.canRetry ?? false;
return problem == null;
}
ComposeAttachment copyWith({String? id, ComposeAttachmentProblem? problem}) =>
ComposeAttachment(
@@ -76,8 +154,30 @@ class ComposeAttachment {
name: name,
size: size,
problem: problem ?? this.problem,
upload: upload,
progress: progress,
failure: failure,
);
/// 고른 파일의 올리기 상태를 바꾼다 — 다 올리기 전에는 id 를 비운다(옛 id 를 싣지 않게).
/// 다 올리면 서버가 정리한 이름 · 크기([name] · [size])로 바꾼다. "찾지 못함"은 지운다 — 다시 올리기 · 실패가 새 상태다(실패에 다시 붙이는 것은 쓰기 화면)
ComposeAttachment withUpload(
ComposeUploadStatus status, {
String id = '',
int progress = 0,
ComposeUploadFailure? failure,
String? name,
int? size,
}) => ComposeAttachment(
key: key,
id: id,
name: name ?? this.name,
size: size ?? this.size,
upload: status,
progress: progress,
failure: failure,
);
@override
bool operator ==(Object other) =>
other is ComposeAttachment &&
@@ -85,10 +185,14 @@ class ComposeAttachment {
other.id == id &&
other.name == name &&
other.size == size &&
other.problem == problem;
other.problem == problem &&
other.upload == upload &&
other.progress == progress &&
other.failure == failure;
@override
int get hashCode => Object.hash(key, id, name, size, problem);
int get hashCode =>
Object.hash(key, id, name, size, problem, upload, progress, failure);
}
/// 쓰기 화면의 상태 — 받는 사람 칩 · 첨부 · 서명 · 인용 · 칸 안내. 제목 · 본문 · 칸에 쓰는 글은 컨트롤러의 글 칸(TextEditingController)에 있다
@@ -118,6 +222,8 @@ class ComposeState {
this.unsaved = false,
this.saveCount = 0,
this.ended = false,
this.picking = false,
this.limits = kFallbackAttachmentLimits,
});
final ComposeStatus status;
@@ -170,6 +276,16 @@ class ComposeState {
/// 메일을 보는 직원이 바뀌었다(로그아웃 · 세션 끝 · 다른 직원) — 묻지도 저장하지도 알리지도 않는다
final bool ended;
/// 고르는 창을 열었거나 고른 파일을 가져오는 중 — [파일 첨부]를 막고 위에 진행 막대
final bool picking;
/// 첨부 한도 — `GET /api/mail-quota`(받기 전 · 받지 못하면 20 · 25MB). 첨부 머리의 한도 글
final AttachmentLimits limits;
/// 고른 파일을 올리는 중이거나 차례를 기다린다
bool get uploading =>
attachments.any((ComposeAttachment item) => item.uploading);
List<RecipientToken> tokensOf(RecipientFieldKind kind) => switch (kind) {
RecipientFieldKind.to => to,
RecipientFieldKind.cc => cc,
@@ -228,6 +344,8 @@ class ComposeState {
bool? unsaved,
int? saveCount,
bool? ended,
bool? picking,
AttachmentLimits? limits,
}) => ComposeState(
status: status ?? this.status,
error: error ?? this.error,
@@ -254,5 +372,7 @@ class ComposeState {
unsaved: unsaved ?? this.unsaved,
saveCount: saveCount ?? this.saveCount,
ended: ended ?? this.ended,
picking: picking ?? this.picking,
limits: limits ?? this.limits,
);
}
@@ -26,7 +26,7 @@ class ComposeAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final VoidCallback onClose;
/// [파일 첨부] — null 이면 꺼진 단추(받는 중 · 받지 못함 · 보내는 중)
/// [파일 첨부](고르는 창) — null 이면 꺼진 단추(받는 중 · 받지 못함 · 보내는 중 · 고르는 중 · 나가는 중)
final VoidCallback? onAttach;
/// [보내기] — null 이면 꺼진 단추(받는 중 · 받지 못함)
@@ -6,6 +6,7 @@ import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/core/utils/file_size.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/domain/compose_attachments.dart';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_signature.dart';
import 'package:arasystem_app/features/mail/domain/mail_quote.dart';
@@ -28,15 +29,19 @@ const double _quoteLineHeight = 1.7;
// ── 첨부 ─────────────────────────────────────────────
/// 첨부 칸 — "첨부 N개" · 줄마다 파일 아이콘 · 이름 · 크기 · ✕ (보드 `App1Compose` 「답장 · 첨부」).
/// 첨부 칸 — "첨부 N개" · 오른쪽에 한도 글 · 줄마다 파일 아이콘 · 이름 · 크기(또는 올리는 진행) · ✕ (보드 `App1Compose` 「답장 · 첨부」).
///
/// 앱은 첫 판에서 새로 올리지 않는다 — 전달하는 원본 · 이어 쓰는 저장본에 이미 있던 첨부만 싣고 뺀다.
/// - 이 화면에서 고른 파일: 올리는 동안 크기 자리에 진행 막대 · "올리는 중 N%" 이고 ✕ 는 올리기 취소다.
/// 올리지 못했으면 "올리지 못했습니다." · 사전 문구 · [다시 시도](다시 해도 같은 실패면 빼기만).
/// - 전달하는 원본 · 이어 쓰는 저장본의 첨부 · 대용량 파일은 싣고 빼기만 한다.
/// 실을 수 없는 첨부 · 서버가 찾지 못한 첨부(`MAIL_004`) · 웹에서 올리다 멈췄거나 지웠거나 위험한 대용량 파일은 줄 아래에 빨갛게 까닭을 적는다
class ComposeAttachmentsSection extends StatelessWidget {
const ComposeAttachmentsSection({
required this.attachments,
required this.largeFiles,
required this.limits,
required this.onRemoveAttachment,
required this.onRetryAttachment,
required this.onRemoveLargeFile,
this.enabled = true,
super.key,
@@ -44,10 +49,16 @@ class ComposeAttachmentsSection extends StatelessWidget {
final List<ComposeAttachment> attachments;
final List<DraftLargeFile> largeFiles;
/// 머리 오른쪽의 한도 글 — 서버 값(받지 못하면 20 · 25MB)
final AttachmentLimits limits;
final ValueChanged<int> onRemoveAttachment;
/// 올리지 못한 첨부의 [다시 시도]
final ValueChanged<int> onRetryAttachment;
final ValueChanged<String> onRemoveLargeFile;
/// 거짓이면 ✕ 를 막는다(보내는 중)
/// 거짓이면 ✕ · [다시 시도]를 막는다(보내는 중)
final bool enabled;
@override
@@ -73,35 +84,42 @@ class ComposeAttachmentsSection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Semantics(
header: true,
child: Text(
MailComposeTexts.attachmentsTitle(
attachments.length + largeFiles.length,
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: <Widget>[
Semantics(
header: true,
child: Text(
MailComposeTexts.attachmentsTitle(
attachments.length + largeFiles.length,
),
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: AppTypography.semiBold,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: AppTypography.semiBold,
color: theme.colorScheme.onSurfaceVariant,
const SizedBox(width: AppSpacing.s8),
Expanded(
child: Text(
MailComposeTexts.attachmentLimits(
formatMegabytes(limits.maxBytes),
formatMegabytes(limits.totalMaxBytes),
),
key: const ValueKey<String>('compose-attachment-limits'),
textAlign: TextAlign.end,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.regular,
color: extra.textMuted,
),
),
),
),
],
),
for (final ComposeAttachment item in attachments) ...<Widget>[
const SizedBox(height: AppSpacing.s8),
_ComposeFileRow(
key: ValueKey<String>('compose-attachment-${item.key}'),
icon: Icons.insert_drive_file_outlined,
name: item.name,
detail: formatFileSize(item.size),
problem: switch (item.problem) {
ComposeAttachmentProblem.unsendable =>
MailComposeTexts.unsendable,
ComposeAttachmentProblem.missing => ApiException.fromCode(
'MAIL_004',
).message,
null => null,
},
onRemove: enabled ? () => onRemoveAttachment(item.key) : null,
),
_attachmentRow(item),
],
for (final DraftLargeFile file in largeFiles) ...<Widget>[
const SizedBox(height: AppSpacing.s8),
@@ -109,11 +127,16 @@ class ComposeAttachmentsSection extends StatelessWidget {
key: ValueKey<String>('compose-large-file-${file.id}'),
icon: Icons.cloud_outlined,
name: file.name ?? MailComposeTexts.largeUnknownName,
detail: <String>[
if (file.size case final int size) formatFileSize(size),
MailReaderTexts.largeFile,
].join(' · '),
below: _DetailText(
<String>[
if (file.size case final int size) formatFileSize(size),
MailReaderTexts.largeFile,
].join(' · '),
),
problem: largeFileProblemOf(file),
removeTooltip: MailComposeTexts.remove(
file.name ?? MailComposeTexts.largeUnknownName,
),
onRemove: enabled ? () => onRemoveLargeFile(file.id) : null,
),
],
@@ -121,6 +144,66 @@ class ComposeAttachmentsSection extends StatelessWidget {
),
);
}
Widget _attachmentRow(ComposeAttachment item) {
final IconData icon = isImageAttachment(item.name)
? Icons.image_outlined
: Icons.insert_drive_file_outlined;
final VoidCallback? remove = enabled
? () => onRemoveAttachment(item.key)
: null;
final Key key = ValueKey<String>('compose-attachment-${item.key}');
if (item.uploading) {
return _ComposeFileRow(
key: key,
icon: icon,
name: item.name,
below: _UploadProgress(
name: item.name,
percent: item.upload == ComposeUploadStatus.uploading
? item.progress
: 0,
),
removeTooltip: MailComposeTexts.cancelUpload(item.name),
onRemove: remove,
);
}
if (item.upload == ComposeUploadStatus.failed) {
final ComposeUploadFailure? failure = item.failure;
final String? code = failure?.code;
return _ComposeFileRow(
key: key,
icon: icon,
name: item.name,
below: const _DetailText(MailComposeTexts.uploadFailed, error: true),
problem: code == null
? MailComposeTexts.uploadCopyGone
: ApiException.fromCode(code).message,
retryLabel: MailComposeTexts.retryUploadLabel(item.name),
onRetry: (failure?.canRetry ?? false) && enabled
? () => onRetryAttachment(item.key)
: null,
showRetry: failure?.canRetry ?? false,
removeTooltip: MailComposeTexts.remove(item.name),
onRemove: remove,
);
}
return _ComposeFileRow(
key: key,
icon: icon,
name: item.name,
below: _DetailText(formatFileSize(item.size)),
problem: switch (item.problem) {
ComposeAttachmentProblem.unsendable => MailComposeTexts.unsendable,
ComposeAttachmentProblem.missing => ApiException.fromCode(
'MAIL_004',
).message,
null => null,
},
removeTooltip: MailComposeTexts.remove(item.name),
onRemove: remove,
);
}
}
/// 대용량 파일 줄 아래의 까닭 — 보낼 수 있으면 null (차례: 위험 → 지움 → 올리다 멈춤)
@@ -133,30 +216,116 @@ String? largeFileProblemOf(DraftLargeFile file) {
};
}
/// 쓰기 화면의 파일 줄 — 테두리 상자(모서리 12) · 아이콘 칸(36) · 이름 · 크기 · ✕(48), 문제가 있으면 그 아래 빨간 글
class _ComposeFileRow extends StatelessWidget {
const _ComposeFileRow({
required this.icon,
required this.name,
required this.detail,
required this.onRemove,
this.problem,
super.key,
});
/// 이름 아래 한 줄 — 크기 · "대용량 첨부" · "올리지 못했습니다."(빨강)
class _DetailText extends StatelessWidget {
const _DetailText(this.text, {this.error = false});
final String text;
final bool error;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return Text(
text,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: error ? AppTypography.semiBold : AppTypography.regular,
color: error ? theme.colorScheme.error : extra.textMuted,
),
);
}
}
/// 올리는 줄의 진행 — 막대(4) · "올리는 중 40%" (보드). 막대는 화면 낭독기에 "{이름} 올리는 중" 과 비율로 읽힌다
class _UploadProgress extends StatelessWidget {
const _UploadProgress({required this.name, required this.percent});
final IconData icon;
final String name;
final String detail;
final String? problem;
final int percent;
/// ✕ — null 이면 꺼진 단추
final VoidCallback? onRemove;
static const List<FontFeature> _tabular = <FontFeature>[
FontFeature.tabularFigures(),
];
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.s4),
child: Row(
children: <Widget>[
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.xs),
child: LinearProgressIndicator(
value: percent / 100,
minHeight: AppSize.progressBarHeight,
backgroundColor: scheme.surfaceContainerHighest,
color: scheme.primary,
semanticsLabel: MailComposeTexts.uploading(name),
),
),
),
const SizedBox(width: AppSpacing.s8),
ExcludeSemantics(
child: Text(
MailComposeTexts.uploadProgress(percent),
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.regular,
color: scheme.onPrimaryContainer,
fontFeatures: _tabular,
),
),
),
],
),
);
}
}
/// 쓰기 화면의 파일 줄 — 테두리 상자(모서리 12) · 아이콘 칸(36) · 이름 · 아래 줄 · ✕(48), 문제가 있으면 그 아래 빨간 글과 [다시 시도]
class _ComposeFileRow extends StatelessWidget {
const _ComposeFileRow({
required this.icon,
required this.name,
required this.below,
required this.removeTooltip,
required this.onRemove,
this.problem,
this.retryLabel,
this.onRetry,
this.showRetry = false,
super.key,
});
final IconData icon;
final String name;
/// 이름 아래 — 크기 · 올리는 진행 · "올리지 못했습니다."
final Widget below;
final String? problem;
/// ✕ 의 이름(화면 낭독기) — "‘이름’ 빼기" · "‘이름’ 올리기 취소"
final String removeTooltip;
/// ✕ — null 이면 꺼진 단추
final VoidCallback? onRemove;
/// [다시 시도]의 화면 낭독기 이름
final String? retryLabel;
/// [다시 시도] — null 이면 꺼진 단추(보내는 중)
final VoidCallback? onRetry;
/// [다시 시도]를 보인다
final bool showRetry;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final String? reason = problem;
final bool failed = reason != null;
return Container(
@@ -200,7 +369,7 @@ class _ComposeFileRow extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
@@ -211,13 +380,7 @@ class _ComposeFileRow extends StatelessWidget {
fontWeight: AppTypography.medium,
),
),
Text(
detail,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.regular,
color: extra.textMuted,
),
),
below,
if (reason != null)
Padding(
padding: const EdgeInsets.only(top: AppSpacing.s4),
@@ -228,13 +391,33 @@ class _ComposeFileRow extends StatelessWidget {
),
),
),
if (showRetry)
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: scheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s8,
),
textStyle: theme.textTheme.labelLarge?.copyWith(
fontWeight: AppTypography.bold,
),
),
onPressed: onRetry,
child: Text(
MailComposeTexts.retryUpload,
semanticsLabel: retryLabel,
),
),
),
],
),
),
),
IconButton(
icon: const Icon(Icons.close, size: AppSize.iconSmall),
tooltip: MailComposeTexts.remove(name),
tooltip: removeTooltip,
color: theme.colorScheme.onSurfaceVariant,
onPressed: onRemove,
),