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