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
+21 -2
View File
@@ -1,4 +1,4 @@
/// 받은 파일을 기기에 둘 때의 이름 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈).
/// 받은 파일을 기기에 둘 때의 이름 · 고른 파일의 보이는 이름 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈).
///
/// 이름은 보낸 쪽이 정한 글이라 믿지 않는다 — 경로 문자 · 제어 문자 · 방향 제어 문자를 지우고 길이를 줄인다
/// (서버의 첨부 이름 정리 `attachment.policy.ts` 와 같은 결 — 확장자는 남긴다). 저장 경로는 앱이 만든 폴더 안 한 칸뿐이다.
@@ -29,7 +29,7 @@ bool _isInvisibleFormat(int rune) =>
/// 밑줄로 바꾸는 글자 — 경로 구분 · 다른 시스템이 이름에 받지 않는 글자
const String _replacedCharacters = r'/\:*?"<>|';
/// 기기에 둘 파일 이름 — 비거나 `.` · `..` 뿐이면 [fallback]
/// 기기에 둘 파일 이름 — 비거나 `.` · `..` 뿐이면 [fallback]. **짝: `MainActivity.kt` 의 `safeFileName`**(고른 파일의 복사본 이름)
String safeFileName(String name, {String fallback = kFallbackFileName}) {
final StringBuffer cleaned = StringBuffer();
for (final int rune in name.runes) {
@@ -44,6 +44,25 @@ String safeFileName(String name, {String fallback = kFallbackFileName}) {
return _fitBytes(result, kMaxFileNameBytes);
}
/// 고른 파일의 보이는 이름 — 고르는 창의 앱이 알려 준 이름(`OpenableColumns.DISPLAY_NAME`)에서 경로 앞부분 · 제어 문자 ·
/// 방향 제어 · 보이지 않는 글자를 지우고 앞뒤 공백을 지운다. 비면 [kFallbackFileName].
///
/// 다른 글자(`:` 등)와 길이는 그대로 둔다 — 서버가 받은 이름을 같은 결로 다시 정리한다(`shared/utils/file-name.ts` 의 `sanitizeFileName` —
/// 웹이 보내는 파일 이름과 같게). 기기 안 복사본의 이름은 따로 정리한다([safeFileName] · `MainActivity.kt` 의 `safeFileName`).
/// **짝: `MainActivity.kt` 의 `displayFileName`** — 고를 때 기기가 이 이름의 확장자로 형식을 미리 거른다(공백은 Dart `trim()` 의 공백)
String displayFileName(String? name) {
if (name == null) return kFallbackFileName;
final int slash = name.lastIndexOf(RegExp(r'[/\\]'));
final String segment = slash == -1 ? name : name.substring(slash + 1);
final StringBuffer cleaned = StringBuffer();
for (final int rune in segment.runes) {
if (_isControl(rune) || _isInvisibleFormat(rune)) continue;
cleaned.writeCharCode(rune);
}
final String result = cleaned.toString().trim();
return result.isEmpty ? kFallbackFileName : result;
}
/// UTF-8 [maxBytes] 안으로 줄인다 — 확장자(점 뒤 짧은 영문 · 숫자)는 남기고 앞부분을 글자 단위로 자른다
String _fitBytes(String name, int maxBytes) {
if (utf8.encode(name).length <= maxBytes) return name;
+330
View File
@@ -0,0 +1,330 @@
/// 고른 파일 — 안드로이드 고르는 창(`ACTION_GET_CONTENT`)으로 고른 파일을 앱 임시 폴더에 복사해 두고, 거기서 읽어 올린다(모듈 공통: 메일 첨부 · 나중의 파일 모듈).
///
/// 패키지를 더하지 않고 앱 안에 둔 까닭(받은 파일 열기와 같다 — `downloaded_files.dart`): 파일 고르는 플러그인은 저장소 · 사진 권한이나
/// 기기 전체를 가리키는 경로를 더한다. 이 길은 권한 없이 고른 파일만 읽는다(고르는 창이 그 파일의 읽기 권한을 준다 — `content://` 만 읽는다).
/// 고르는 창에는 모든 파일을 보인다(`*/*` — hwp 는 기기마다 형식이 달라 창에서 거르면 숨는다).
/// 고른 뒤 기기가 넘겨받은 한도(남은 개수 · 허용 확장자 · 파일당 · 남은 합계)로 쓰는 쪽의 사전 검사와 **같은 차례**로 미리 걸러 넣을 파일만 복사한다 —
/// 받지 않을 형식 · 합계를 넘는 파일을 내려받거나 쌓아 두지 않게. 판정은 쓰는 쪽(Dart)이 다시 한다.
///
/// 복사본을 두는 까닭: 고른 주소(content://)의 읽기 권한은 그 화면(Activity)이 있는 동안뿐이고, 클라우드 파일은 읽을 때마다 받는다.
/// 복사해 두면 올리기에 실패해도 [다시 시도] · 서버가 찾지 못한 첨부(`MAIL_004`)를 다시 고르지 않고 올린다.
///
/// 정리하는 때: 첨부를 뺄 때 · 쓰기 화면이 끝날 때(그 화면의 복사본) · 앱을 띄울 때(main — 지난 실행이 남긴 것) ·
/// 메일을 보는 직원이 바뀔 때(모두) · 기기가 저장 공간이 모자랄 때(안드로이드가 임시 폴더를 비운다 — 그래서 올리기 전에 복사본이 있는지 본다).
library;
import 'dart:async';
import 'dart:io';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/core/files/file_names.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
/// 고르기 한 번을 기다리는 가장 긴 시간 — 고르는 창을 연 때부터 고른 파일을 다 가져올 때까지(창에 머문 시간도 든다).
/// 파일당 20MB · 합계 25MB 를 느린 망에서 클라우드 파일로 받아도 넉넉하게 10분. 넘으면 그 고르기를 [PickOutcome.timedOut] 으로 끝내고
/// 차례를 넘긴다 — 고른 앱(provider)이 읽기에서 멈춰 기기가 답하지 않을 때 [보내기] · [파일 첨부] · 다음 고르기 · 직원이 바뀔 때의 정리가
/// 그 뒤에 묶이지 않게. 늦게 온 기기의 답은 버린다 — 그 복사본은 다음 정리(앱을 띄울 때 · 직원이 바뀔 때)에 지워진다
const Duration kPickTimeout = Duration(minutes: 10);
/// 고른 파일을 복사해 두는 폴더 이름 — 앱 임시 폴더(Android `cacheDir`) 아래.
/// **짝: `MainActivity.kt` 의 `UPLOADS_DIR`** — 다른 앱에 건네지 않으므로 FileProvider 경로(`downloaded_file_paths.xml`)에는 넣지 않는다
const String kPickedFilesFolder = 'uploads';
/// 고른 파일 하나를 가져온 결과 (짝: `MainActivity.kt` 의 `STATUS_*` — 기기가 거른 차례: 개수 → 형식 → 파일당 → 합계)
enum PickedFileStatus {
/// 다 복사했다 — [PickedFile.path] 가 복사본
ok,
/// 넣을 파일이 `maxCount` 개가 되어 가져오지 않았다
skipped,
/// 허용 확장자(`extensions`)가 아니다 — 읽지 않았다
notAllowed,
/// 한도(`maxBytes`)보다 크다 — 복사하지 않았다(고른 앱이 알려 준 크기로, 또는 한도 + 1바이트를 읽고 멈췄다)
tooLarge,
/// 넣으면 남은 합계(`maxTotalBytes`)를 넘는다 — 알려 준 크기로 읽지 않았거나, 복사한 크기로 보고 복사본을 지웠다
overTotal,
/// 읽지 못했다(클라우드 파일을 받지 못함 · 권한 · 저장 공간 · `content://` 가 아닌 주소)
failed,
}
/// 고른 파일 하나
@immutable
class PickedFile {
const PickedFile({
required this.name,
required this.status,
this.size,
this.path,
});
/// 보이는 이름 — [displayFileName] 으로 정리했다(올릴 때 이 이름을 보낸다)
final String name;
final PickedFileStatus status;
/// [PickedFileStatus.ok] 면 복사한 바이트, 그 밖은 고른 앱이 알려 준 크기(모르면 null)
final int? size;
/// 복사본의 경로 — [PickedFileStatus.ok] 일 때만(로그 · 알림에 싣지 않는다)
final String? path;
/// 채널이 돌려준 항목 하나 — 모양이 틀리면 읽지 못한 파일로 본다(알리기는 한다)
static PickedFile fromChannel(Object? raw) {
if (raw is! Map<Object?, Object?>) {
return const PickedFile(
name: kFallbackFileName,
status: PickedFileStatus.failed,
);
}
final Object? name = raw['name'];
final Object? size = raw['size'];
final Object? path = raw['path'];
final PickedFileStatus? status = switch (raw['status']) {
'ok' => PickedFileStatus.ok,
'skipped' => PickedFileStatus.skipped,
'notAllowed' => PickedFileStatus.notAllowed,
'tooLarge' => PickedFileStatus.tooLarge,
'overTotal' => PickedFileStatus.overTotal,
'failed' => PickedFileStatus.failed,
_ => null,
};
final String shown = displayFileName(name is String ? name : null);
final int? bytes = size is int && size >= 0 ? size : null;
if (status == PickedFileStatus.ok) {
// 다 복사했다는데 경로 · 크기가 없으면 쓸 수 없다
if (path is! String || path.isEmpty || bytes == null) {
return PickedFile(name: shown, status: PickedFileStatus.failed);
}
return PickedFile(
name: shown,
status: PickedFileStatus.ok,
size: bytes,
path: path,
);
}
return PickedFile(
name: shown,
status: status ?? PickedFileStatus.failed,
size: bytes,
);
}
@override
bool operator ==(Object other) =>
other is PickedFile &&
other.name == name &&
other.status == status &&
other.size == size &&
other.path == path;
@override
int get hashCode => Object.hash(name, status, size, path);
}
/// 고르기 한 번의 결과 (짝: `MainActivity.kt` 의 `OUTCOME_*`)
enum PickOutcome {
/// 골랐다 — [PickResult.files]
picked,
/// 고르지 않고 닫았다
canceled,
/// 고르는 창이 이미 열려 있다
busy,
/// 고르는 창을 열 앱이 없다
noApp,
/// 그 밖의 실패(채널이 없음 — iOS 는 아직 없다 · 모르는 답)
failed,
/// [kPickTimeout] 안에 기기가 답하지 않았다 — 고른 파일을 가져오지 못한 것으로 본다(앱에서만 — 기기는 보내지 않는다)
timedOut,
}
/// 고르기 한 번의 결과
@immutable
class PickResult {
const PickResult(this.outcome, [this.files = const <PickedFile>[]]);
final PickOutcome outcome;
/// 고른 차례대로 — [PickOutcome.picked] 일 때만
final List<PickedFile> files;
/// 채널의 답 — 모양이 틀리면 실패
static PickResult parse(Object? raw) {
if (raw is! Map<Object?, Object?>) {
return const PickResult(PickOutcome.failed);
}
final PickOutcome? outcome = switch (raw['outcome']) {
'picked' => PickOutcome.picked,
'canceled' => PickOutcome.canceled,
'busy' => PickOutcome.busy,
'noApp' => PickOutcome.noApp,
'failed' => PickOutcome.failed,
_ => null,
};
if (outcome == null) return const PickResult(PickOutcome.failed);
if (outcome != PickOutcome.picked) return PickResult(outcome);
final Object? files = raw['files'];
if (files is! List<Object?>) return const PickResult(PickOutcome.failed);
return PickResult(
PickOutcome.picked,
List<PickedFile>.unmodifiable(<PickedFile>[
for (final Object? item in files) PickedFile.fromChannel(item),
]),
);
}
}
/// 고른 파일을 가져오고 복사본을 다루는 곳 — 시험은 덮어쓴다(시험 시계 안에서는 기기 파일 입출력이 끝나지 않는다)
abstract class PickedFileStore {
/// 고르는 창을 열고 고른 파일 가운데 넣을 파일만 복사해 둔다 — 고른 차례대로, 쓰는 쪽 사전 검사와 같은 차례로 거른다:
/// 넣을 파일이 [maxCount] 개가 되면 나머지는 가져오지 않고([PickedFileStatus.skipped]), 확장자가 [extensions] 에 없으면 읽지 않고
/// ([PickedFileStatus.notAllowed]), [maxBytes] + 1바이트를 넘으면 복사를 멈추고([PickedFileStatus.tooLarge]),
/// 넣은 파일의 합이 [maxTotalBytes] 를 넘게 되면 넣지 않는다([PickedFileStatus.overTotal]). 거른 파일은 개수 · 합계에 들지 않는다
Future<PickResult> pick({
required int maxBytes,
required int maxCount,
required int maxTotalBytes,
required List<String> extensions,
});
/// 복사본이 아직 있는가 — 복사본 폴더 밖의 경로는 거짓(기기가 임시 폴더를 비웠을 수 있다)
Future<bool> exists(String path);
/// 복사본을 읽는다(올리기 — 부를 때마다 처음부터)
Stream<List<int>> openRead(String path);
/// 복사본 하나를 그 폴더째 지운다 — 복사본 폴더 밖은 지우지 않는다
Future<void> delete(String path);
/// 모두 지운다
Future<void> purge();
}
/// 고른 파일을 두는 곳 — main 이 앱을 띄울 때 정리한 것으로 덮어쓴다
final Provider<PickedFileStore> pickedFileStoreProvider =
Provider<PickedFileStore>((Ref ref) => PlatformPickedFileStore());
/// 안드로이드 고르는 창(`MainActivity.kt` 의 파일 채널 `pick`) + 앱 임시 폴더의 [kPickedFilesFolder].
///
/// 고르기와 모두 지우기는 부른 차례대로 하나씩 한다 — 고르는 동안 직원이 바뀌어 지우기가 들어오면 고른 복사본까지 지운다
/// (앱을 띄울 때의 정리가 막 고른 복사본을 지우지 않게도).
class PlatformPickedFileStore implements PickedFileStore {
PlatformPickedFileStore({
Future<Directory> Function() baseDirectory = getTemporaryDirectory,
MethodChannel channel = PlatformFileOpener.channel,
Duration pickTimeout = kPickTimeout,
}) : _baseDirectory = baseDirectory,
_channel = channel,
_pickTimeout = pickTimeout;
/// 짝: `MainActivity.kt` 의 `PICK_METHOD` (채널은 받은 파일 열기와 같다 — `PlatformFileOpener.channel`)
static const String pickMethod = 'pick';
final Future<Directory> Function() _baseDirectory;
final MethodChannel _channel;
/// 고르기 한 번의 시한 — [kPickTimeout](시험은 짧게)
final Duration _pickTimeout;
Future<void> _queue = Future<void>.value();
Future<T> _serial<T>(Future<T> Function() task) {
final Future<T> next = _queue.then((_) => task());
_queue = next.then<void>((_) {}, onError: (Object _) {});
return next;
}
/// 복사본 폴더 — 임시 폴더를 알 수 없으면(플러그인 · 기기 사정) null
Future<Directory?> _root() async {
try {
final Directory base = await _baseDirectory();
return Directory('${base.path}/$kPickedFilesFolder');
} on Exception {
return null;
}
}
@override
Future<PickResult> pick({
required int maxBytes,
required int maxCount,
required int maxTotalBytes,
required List<String> extensions,
}) => _serial(() async {
try {
// 인자 이름 — 짝: `MainActivity.kt` 의 `ARG_*`. 시한은 차례 안에서 잰다(넘으면 다음 차례로)
final Object? raw = await _channel
.invokeMethod<Object?>(pickMethod, <String, Object?>{
'maxBytes': maxBytes,
'maxCount': maxCount,
'maxTotalBytes': maxTotalBytes,
'extensions': extensions,
})
.timeout(_pickTimeout);
return PickResult.parse(raw);
} on TimeoutException {
// 기기가 답하지 않는다(고른 앱이 읽기에서 멈춤 등) — 이 고르기를 끝내고 차례를 넘긴다.
// 늦게 온 답은 버린다 — 그 복사본은 다음 정리(앱을 띄울 때 · 직원이 바뀔 때)에 지워진다
return const PickResult(PickOutcome.timedOut);
} on PlatformException {
return const PickResult(PickOutcome.failed);
} on MissingPluginException {
return const PickResult(PickOutcome.failed);
}
});
/// 복사본의 폴더 — 복사본 폴더 바로 아래 한 칸(무작위 폴더)일 때만. 기호 링크를 풀어 견준다(Kotlin 이 canonical 로 만든 자리).
/// 없거나 밖이면 null
Future<Directory?> _copyFolderOf(String path) async {
final Directory? root = await _root();
if (root == null) return null;
try {
final String rootPath = await root.resolveSymbolicLinks();
final File file = File(await File(path).resolveSymbolicLinks());
final Directory folder = file.parent;
return folder.parent.path == rootPath ? folder : null;
} on FileSystemException {
return null;
}
}
@override
Future<bool> exists(String path) async {
if (await _copyFolderOf(path) == null) return false;
return File(path).exists();
}
@override
Stream<List<int>> openRead(String path) => File(path).openRead();
@override
Future<void> delete(String path) async {
final Directory? folder = await _copyFolderOf(path);
if (folder == null) return;
try {
await folder.delete(recursive: true);
} on FileSystemException {
// 이미 없다 — 다음 정리 · 기기가 지운다
}
}
@override
Future<void> purge() => _serial(() async {
final Directory? root = await _root();
if (root == null) return;
try {
if (await root.exists()) await root.delete(recursive: true);
} on FileSystemException {
// 다음 정리 · 기기가 지운다
}
});
}
+12 -1
View File
@@ -1,6 +1,7 @@
/// 파일 크기 표기 — 웹 `shared/utils/format.ts` 의 `formatFileSize` 와 같은 글(순수 함수).
/// 파일 크기 표기 — 웹 `shared/utils/format.ts` 의 `formatFileSize` · `formatMegabytes` 와 같은 글(순수 함수).
///
/// "248 KB" · "1.2 MB" · "38 MB" — 1KB 미만은 바이트 그대로, 그 위는 KB · MB · GB · TB. KB 는 정수, MB 이상은 10 미만일 때 소수 한 자리.
/// 한도 글은 "20MB"(붙여 씀 — [formatMegabytes]).
library;
const int _bytesPerUnit = 1024;
@@ -24,6 +25,16 @@ String formatFileSize(int bytes) {
return '$shown ${_units[unit]}';
}
/// 한도 글의 크기 — "20MB" · "25MB" · "20.5MB" (웹 `format.ts` 의 `formatMegabytes` 와 같은 글 — 정수 MB 면 그대로, 아니면 소수 한 자리).
/// 첨부 한도 안내 · 한도를 넘은 알림에 쓴다(서버가 알려 준 바이트 — `GET /api/mail-quota`)
String formatMegabytes(int bytes) {
final double megabytes = bytes / (_bytesPerUnit * _bytesPerUnit);
final String shown = megabytes == megabytes.roundToDouble()
? megabytes.round().toString()
: _oneDecimal(megabytes);
return '${shown}MB';
}
/// 소수 한 자리 — 끝의 ".0" 은 뺀다(웹 `Math.round(value * 10) / 10` 이 내는 글과 같다)
String _oneDecimal(double value) {
final double rounded = (value * 10).round() / 10;
+10 -1
View File
@@ -1,5 +1,5 @@
/// 한국어 글 다듬기 — 줄 나눔(웹 `word-break: keep-all` — 앱 보드 `App1Login` 의 안내 글)과
/// 이름 뒤의 조사(받침에 맞춰 "이/가" · "으로/로" — 웹 `shared/utils/korean.ts` 와 같다).
/// 이름 뒤의 조사(받침에 맞춰 "이/가" · "으로/로" — 웹 `shared/utils/korean.ts` 와 같다. "은/는" 은 앱에서 더했다).
library;
/// WORD JOINER — 보이지 않고, 그 앞뒤에서 줄을 나누지 못하게 한다(유니코드 줄 나눔 규칙 WJ)
@@ -64,6 +64,15 @@ String directionParticle(String word) {
return jongseong == _noJongseong || jongseong == _jongseongRieul ? '로' : '으로';
}
/// 보조사 "은/는" — 받침이 있으면 "은", 없으면 "는"(사진은 · 보고서는 · 외 2개는).
/// 한글로 끝나지 않는 이름(영문 · 숫자 — 확장자로 끝나는 파일 이름)은 소리를 알 수 없어 "은(는)" 이다
/// (앱에서 더함 — 웹 `korean.ts` 에는 없다. [subjectParticle] 과 같은 규칙)
String topicParticle(String word) {
final int? jongseong = _lastJongseong(word);
if (jongseong == null) return '은(는)';
return jongseong == _noJongseong ? '는' : '은';
}
/// 띄어쓰기 · 제어 문자를 뺀 ASCII 글자 — 영문 · 숫자 · 기호
bool _isAsciiGraphic(int rune) => rune >= 0x21 && rune <= 0x7E;
@@ -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,
),
+6 -1
View File
@@ -5,6 +5,7 @@ import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/user_agent.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/core/files/picked_files.dart';
import 'package:arasystem_app/core/storage/first_run_marker.dart';
import 'package:arasystem_app/core/storage/secure_store.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
@@ -41,9 +42,12 @@ Future<void> main() async {
final ({String? appVersion, String userAgent}) device =
await loadAppDeviceInfo();
// 지난 실행이 받아 둔 파일(메일 첨부)을 지운다 — 기다리지 않는다(저장소가 차례를 지켜 새로 받는 파일은 지우지 않는다)
// 지난 실행이 받아 둔 파일(메일 첨부)과 고른 파일의 복사본(쓰기 화면의 첨부)을 지운다 — 기다리지 않는다
// (저장소가 차례를 지켜 새로 받는 파일 · 새로 고른 파일은 지우지 않는다)
final DownloadedFileStore downloadedFiles = CacheDirFileStore();
unawaited(downloadedFiles.purge());
final PickedFileStore pickedFiles = PlatformPickedFileStore();
unawaited(pickedFiles.purge());
runApp(
ProviderScope(
@@ -54,6 +58,7 @@ Future<void> main() async {
userAgentProvider.overrideWithValue(device.userAgent),
appVersionProvider.overrideWithValue(device.appVersion),
downloadedFileStoreProvider.overrideWithValue(downloadedFiles),
pickedFileStoreProvider.overrideWithValue(pickedFiles),
],
child: const App(),
),