generated from ttipo/project-base
feat(flutter): 메일 읽기 화면을 만든다 (App1Reader)
머리는 앱 화면, 본문은 스크립트를 끈 WebView 에 앱이 감싼 CSP 문서로 보인다. 받은 HTML 은 서버 정화 뒤 앱이 한 번 더 허용 목록으로 다시 쓰고, 외부 이미지는 [이미지 보기] 뒤 앱이 Dio 로 받아 data: 로 넣는다(토큰이 WebView 에 가지 않는다). - 읽음 · 중요 · 이동 · 스팸 · 삭제 · 영구 삭제, 붙잡힌 메일의 보내기 취소 · 예약 취소는 목록과 같은 규칙. 응답은 그 요청이 바꾼 칸만 지금 값에 덮는다 - 첨부는 앱 임시 폴더(cacheDir/downloads)에 받아 FileProvider + ACTION_VIEW 로 연다(파일 여는 플러그인 없이). 열 형식은 확장자 먼저, 웹 문서 · svg · 설치 파일 · 인증서는 받지 않는다 - 응답 인터셉터가 바이트 · 스트림 실패 본문(64KB 까지)에서 코드를 읽고, 뜻밖의 실패에도 handler.next 로 끝낸다 - 리뷰 H1: 같은 그림을 수백 번 쓴 메일이 문서를 GB 단위로 키우던 것 — 문서에 싣는 그림 글을 나온 횟수까지 세어 10M 자에서 멈춘다 - 리뷰 M1: 짧고 깊은 본문을 화면 isolate 에서 정화하던 것 — 길이와 중첩 비용으로 고르고(6,144자 · 비용 5,000), 그 밖은 Isolate.run - 새 의존성: webview_flutter 4.14.1 · webview_flutter_android 4.12.0 · html 0.15.7(+ csslib 1.0.2) — 배포 2주 넘음. path_provider · mime 은 lock 에 있던 판을 직접 의존으로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// 본문의 http · https 링크를 바깥 앱(브라우저 · 그 주소를 받는 앱)으로 여는 함수 — 시험은 덮어쓴다
|
||||
typedef MailLinkLauncher = Future<bool> Function(Uri uri);
|
||||
|
||||
final Provider<MailLinkLauncher> mailLinkLauncherProvider =
|
||||
Provider<MailLinkLauncher>((Ref ref) => openMailLinkExternally);
|
||||
|
||||
/// 바깥 앱으로 연다 — 열었으면 true.
|
||||
///
|
||||
/// `canLaunchUrl` 은 쓰지 않는다(debug 판 안드로이드가 묻는 주소를 로그에 남긴다 — flutter 스킬). 못 열면 플러그인이 던지는
|
||||
/// 예외의 문장에 주소째 들어 있다 — 잡아서 버린다(메일 속 주소에는 추적 번호 · 개인 정보가 들어 있을 수 있다)
|
||||
Future<bool> openMailLinkExternally(Uri uri) async {
|
||||
try {
|
||||
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} on Object {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 메일 한 통의 첨부 — 계약 2절 `MessageAttachment`
|
||||
@immutable
|
||||
class MessageAttachment {
|
||||
const MessageAttachment({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.size,
|
||||
this.isInline = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
/// 보낸 쪽이 붙인 이름 — 저장할 때는 다시 정리한다(`safeFileName`)
|
||||
final String name;
|
||||
|
||||
/// 서버가 알려 준 형식(`application/pdf` 등) — 다른 앱으로 열 때 쓴다
|
||||
final String type;
|
||||
|
||||
/// 바이트
|
||||
final int size;
|
||||
|
||||
/// 본문 안에 그림으로 들어갔다(cid → data:) — 첨부 목록에서 뺀다
|
||||
final bool isInline;
|
||||
|
||||
/// 모양이 틀리면 null — `isInline` 이 없으면(옛 서버) 거짓으로 본다
|
||||
static MessageAttachment? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? id = json['id'];
|
||||
final Object? name = json['name'];
|
||||
final Object? type = json['type'];
|
||||
final Object? size = json['size'];
|
||||
final Object? isInline = json['isInline'];
|
||||
if (id is! String ||
|
||||
id.isEmpty ||
|
||||
name is! String ||
|
||||
type is! String ||
|
||||
size is! num ||
|
||||
(isInline != null && isInline is! bool)) {
|
||||
return null;
|
||||
}
|
||||
return MessageAttachment(
|
||||
id: id,
|
||||
name: name,
|
||||
type: type,
|
||||
size: size.toInt(),
|
||||
isInline: isInline == true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MessageAttachment &&
|
||||
other.id == id &&
|
||||
other.name == name &&
|
||||
other.type == type &&
|
||||
other.size == size &&
|
||||
other.isInline == isInline;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, name, type, size, isInline);
|
||||
}
|
||||
|
||||
/// 우리 서버가 넣은 대용량 첨부 하나 — 계약 4절 `MessageLargeFile` (첫 판은 받지 않고 이름 · 크기만 보인다)
|
||||
@immutable
|
||||
class MessageLargeFile {
|
||||
const MessageLargeFile({
|
||||
required this.fileId,
|
||||
required this.name,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
final String fileId;
|
||||
|
||||
/// 서버 기록의 이름
|
||||
final String name;
|
||||
|
||||
/// 바이트
|
||||
final int size;
|
||||
|
||||
static MessageLargeFile? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? fileId = json['fileId'];
|
||||
final Object? name = json['name'];
|
||||
final Object? size = json['size'];
|
||||
if (fileId is! String || name is! String || size is! num) return null;
|
||||
return MessageLargeFile(fileId: fileId, name: name, size: size.toInt());
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MessageLargeFile &&
|
||||
other.fileId == fileId &&
|
||||
other.name == name &&
|
||||
other.size == size;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(fileId, name, size);
|
||||
}
|
||||
|
||||
/// 메일 한 통 — 계약 2절 `MessageDetail` (`GET /api/messages/{id}`)
|
||||
///
|
||||
/// 목록 칸([summary])과 읽기 칸을 함께 둔다. 9단계 뒤에 더해진 칸(`imagesShown` · `isHtmlBodyOmitted` · `isBodyTruncated` ·
|
||||
/// `largeFiles` · 첨부의 `isInline`)은 없으면 거짓 · 빈 목록으로 읽는다(웹과 같다). 대용량 첨부는 하나가 틀려도 그것만 뺀다.
|
||||
@immutable
|
||||
class MessageDetail {
|
||||
const MessageDetail({
|
||||
required this.summary,
|
||||
this.cc = const <MailAddress>[],
|
||||
this.bcc = const <MailAddress>[],
|
||||
this.sentAt,
|
||||
this.htmlBody,
|
||||
this.textBody,
|
||||
this.hasBlockedImages = false,
|
||||
this.imagesShown = false,
|
||||
this.isHtmlBodyOmitted = false,
|
||||
this.isBodyTruncated = false,
|
||||
this.attachments = const <MessageAttachment>[],
|
||||
this.largeFiles = const <MessageLargeFile>[],
|
||||
});
|
||||
|
||||
/// 목록과 같은 칸 — 읽음 · 중요 · 메일함 · 붙잡힌 발송
|
||||
final MessageSummary summary;
|
||||
final List<MailAddress> cc;
|
||||
|
||||
/// 숨은참조 — 내가 보낸 메일에만 온다
|
||||
final List<MailAddress> bcc;
|
||||
final DateTime? sentAt;
|
||||
|
||||
/// 서버가 정화한 HTML — 앱은 한 번 더 정화한다(`mail_html.dart`)
|
||||
final String? htmlBody;
|
||||
final String? textBody;
|
||||
|
||||
/// 외부 이미지를 지웠다 — "외부 이미지 차단" 줄
|
||||
final bool hasBlockedImages;
|
||||
|
||||
/// 외부 이미지를 중계 주소로 바꿔 넣었다(`images=show` 또는 설정) — 앱이 그 그림을 받아 넣는다
|
||||
final bool imagesShown;
|
||||
|
||||
/// 서식 본문이 한도를 넘어 비웠다 — 글자 본문으로 보인다
|
||||
final bool isHtmlBodyOmitted;
|
||||
|
||||
/// 보여 줄 본문이 읽기 한도에서 잘렸다
|
||||
final bool isBodyTruncated;
|
||||
final List<MessageAttachment> attachments;
|
||||
final List<MessageLargeFile> largeFiles;
|
||||
|
||||
String get id => summary.id;
|
||||
|
||||
/// 첨부 목록에 보일 것 — 본문 안에 그림으로 들어간 것은 뺀다(웹 `listedAttachments`)
|
||||
List<MessageAttachment> get listedAttachments => <MessageAttachment>[
|
||||
for (final MessageAttachment attachment in attachments)
|
||||
if (!attachment.isInline) attachment,
|
||||
];
|
||||
|
||||
/// 목록 칸만 바꾼다 — 읽음 · 중요 · 메일함 · 붙잡힌 발송(본문은 그대로)
|
||||
MessageDetail withSummary(MessageSummary next) => MessageDetail(
|
||||
summary: next,
|
||||
cc: cc,
|
||||
bcc: bcc,
|
||||
sentAt: sentAt,
|
||||
htmlBody: htmlBody,
|
||||
textBody: textBody,
|
||||
hasBlockedImages: hasBlockedImages,
|
||||
imagesShown: imagesShown,
|
||||
isHtmlBodyOmitted: isHtmlBodyOmitted,
|
||||
isBodyTruncated: isBodyTruncated,
|
||||
attachments: attachments,
|
||||
largeFiles: largeFiles,
|
||||
);
|
||||
|
||||
/// 본문 칸만 [other] 의 것으로 — [이미지 보기]로 다시 받은 본문(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹 `showImages`)
|
||||
MessageDetail withBodyOf(MessageDetail other) => MessageDetail(
|
||||
summary: summary,
|
||||
cc: cc,
|
||||
bcc: bcc,
|
||||
sentAt: sentAt,
|
||||
htmlBody: other.htmlBody,
|
||||
textBody: other.textBody,
|
||||
hasBlockedImages: other.hasBlockedImages,
|
||||
imagesShown: other.imagesShown,
|
||||
isHtmlBodyOmitted: other.isHtmlBodyOmitted,
|
||||
isBodyTruncated: other.isBodyTruncated,
|
||||
attachments: other.attachments,
|
||||
largeFiles: other.largeFiles,
|
||||
);
|
||||
|
||||
/// 모양이 틀리면 null — 저장소가 `SYS_001` 로 던진다
|
||||
static MessageDetail? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final MessageSummary? summary = MessageSummary.tryParse(json);
|
||||
if (summary == null) return null;
|
||||
final List<MailAddress>? cc = _addresses(json['cc']);
|
||||
final List<MailAddress>? bcc = _addresses(json['bcc']);
|
||||
final Object? sentAt = json['sentAt'];
|
||||
final Object? htmlBody = json['htmlBody'];
|
||||
final Object? textBody = json['textBody'];
|
||||
final List<MessageAttachment>? attachments = _attachments(
|
||||
json['attachments'],
|
||||
);
|
||||
final bool? hasBlockedImages = _flag(json['hasBlockedImages']);
|
||||
final bool? imagesShown = _flag(json['imagesShown']);
|
||||
final bool? omitted = _flag(json['isHtmlBodyOmitted']);
|
||||
final bool? truncated = _flag(json['isBodyTruncated']);
|
||||
final DateTime? sent = sentAt is String ? DateTime.tryParse(sentAt) : null;
|
||||
if (cc == null ||
|
||||
bcc == null ||
|
||||
(sentAt != null && sent == null) ||
|
||||
(htmlBody != null && htmlBody is! String) ||
|
||||
(textBody != null && textBody is! String) ||
|
||||
attachments == null ||
|
||||
hasBlockedImages == null ||
|
||||
imagesShown == null ||
|
||||
omitted == null ||
|
||||
truncated == null) {
|
||||
return null;
|
||||
}
|
||||
return MessageDetail(
|
||||
summary: summary,
|
||||
cc: cc,
|
||||
bcc: bcc,
|
||||
sentAt: sent?.toUtc(),
|
||||
htmlBody: htmlBody as String?,
|
||||
textBody: textBody as String?,
|
||||
hasBlockedImages: hasBlockedImages,
|
||||
imagesShown: imagesShown,
|
||||
isHtmlBodyOmitted: omitted,
|
||||
isBodyTruncated: truncated,
|
||||
attachments: attachments,
|
||||
largeFiles: _largeFiles(json['largeFiles']),
|
||||
);
|
||||
}
|
||||
|
||||
/// 주소 칸 — 없으면 빈 목록, 모양이 틀리면 null
|
||||
static List<MailAddress>? _addresses(Object? value) =>
|
||||
value == null ? const <MailAddress>[] : MailAddress.tryParseList(value);
|
||||
|
||||
/// 참 · 거짓 칸 — 없으면 거짓, 모양이 틀리면 null
|
||||
static bool? _flag(Object? value) {
|
||||
if (value == null) return false;
|
||||
return value is bool ? value : null;
|
||||
}
|
||||
|
||||
static List<MessageAttachment>? _attachments(Object? value) {
|
||||
if (value == null) return const <MessageAttachment>[];
|
||||
if (value is! List<Object?>) return null;
|
||||
final List<MessageAttachment> items = <MessageAttachment>[];
|
||||
for (final Object? item in value) {
|
||||
final MessageAttachment? attachment = MessageAttachment.tryParse(item);
|
||||
if (attachment == null) return null;
|
||||
items.add(attachment);
|
||||
}
|
||||
return List<MessageAttachment>.unmodifiable(items);
|
||||
}
|
||||
|
||||
/// 대용량 첨부 — 11단계 칸이라 모양이 틀린 것은 빼고 읽는다(메일 전체를 열지 못하게 하지 않는다)
|
||||
static List<MessageLargeFile> _largeFiles(Object? value) {
|
||||
if (value is! List<Object?>) return const <MessageLargeFile>[];
|
||||
return List<MessageLargeFile>.unmodifiable(<MessageLargeFile>[
|
||||
for (final Object? item in value) ?MessageLargeFile.tryParse(item),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
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/request_flags.dart';
|
||||
import 'package:arasystem_app/core/api/server_time.dart';
|
||||
import 'package:arasystem_app/core/files/downloaded_files.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
@@ -20,8 +26,24 @@ abstract final class MailApiPaths {
|
||||
|
||||
/// 새 메일 확인 — 불투명한 상태 글
|
||||
static const String mailState = '/mail-state';
|
||||
|
||||
/// 첨부 받기 — 표준 래퍼 없는 파일
|
||||
static String attachment(String messageId, String attachmentId) =>
|
||||
'${message(messageId)}/attachments/${Uri.encodeComponent(attachmentId)}';
|
||||
|
||||
/// 붙잡아 둔 발송 취소(보내기 취소 · 예약 취소) — id 는 보낸 사본의 메일 id (9단계)
|
||||
static String scheduledMessage(String id) =>
|
||||
'/scheduled-messages/${Uri.encodeComponent(id)}';
|
||||
|
||||
/// 외부 이미지 중계 — 서버가 본문에 넣은 주소(`/api/mail-images/<표>`)의 표 (9단계)
|
||||
static String mailImage(String token) =>
|
||||
'/mail-images/${Uri.encodeComponent(token)}';
|
||||
}
|
||||
|
||||
/// 메일 한 통을 외부 이미지가 보이는 본문으로 받는 쿼리 — `?images=show` (계약 2절 "외부 이미지 보기")
|
||||
const String kMailImagesQuery = 'images';
|
||||
const String kMailImagesShow = 'show';
|
||||
|
||||
/// 목록 한 쪽의 기본 수 — 계약 기본값(20)과 같다
|
||||
const int kMailPageSize = 20;
|
||||
|
||||
@@ -76,14 +98,35 @@ class MailRepository {
|
||||
return page.withServerTime(serverTimeOf(response));
|
||||
}
|
||||
|
||||
/// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다)
|
||||
/// 메일 한 통 — 조회만으로는 읽음 처리하지 않는다(계약 2절). [showImages] 면 외부 이미지를 보이는 본문(`images=show`).
|
||||
/// 알림은 [silent] 로 끈다 — 처음 여는 메일은 화면이 받지 못함을 직접 그린다. 응답의 서버 시각(`Date`)도 함께 준다
|
||||
Future<({MessageDetail detail, DateTime? serverTime})> getMessage(
|
||||
String id, {
|
||||
bool showImages = false,
|
||||
bool silent = true,
|
||||
}) async {
|
||||
final Response<Object?> response = await _dio.get<Object?>(
|
||||
MailApiPaths.message(id),
|
||||
queryParameters: showImages
|
||||
? <String, Object?>{kMailImagesQuery: kMailImagesShow}
|
||||
: null,
|
||||
options: silent ? ApiOptions.silent() : null,
|
||||
);
|
||||
final MessageDetail? detail = MessageDetail.tryParse(response.data);
|
||||
if (detail == null) throw _malformed(response);
|
||||
return (detail: detail, serverTime: serverTimeOf(response));
|
||||
}
|
||||
|
||||
/// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다). [silent] 면 실패를 알리지 않는다(연 메일의 읽음 처리)
|
||||
Future<MessageSummary> updateMessage(
|
||||
String id,
|
||||
MessageChanges changes,
|
||||
) async {
|
||||
MessageChanges changes, {
|
||||
bool silent = false,
|
||||
}) async {
|
||||
final Response<Object?> response = await _dio.patch<Object?>(
|
||||
MailApiPaths.message(id),
|
||||
data: changes.toJson(),
|
||||
options: silent ? ApiOptions.silent() : null,
|
||||
);
|
||||
final MessageSummary? summary = MessageSummary.tryParse(response.data);
|
||||
if (summary == null) throw _malformed(response);
|
||||
@@ -127,6 +170,130 @@ class MailRepository {
|
||||
return state;
|
||||
}
|
||||
|
||||
/// 붙잡아 둔 발송을 취소한다(보내기 취소 · 예약 취소) — 메일은 보내지 않고 임시보관함으로 간다. 이어 쓸 초안 id(같은 메일 id)를 준다.
|
||||
/// 이미 보냈으면 409 `MAIL_014`, 없으면 404 `RES_001` — 알림은 인터셉터가 띄운다 (계약 2절 "보내기 취소 · 예약 발송")
|
||||
Future<String> cancelPendingSend(String id) async {
|
||||
final Response<Object?> response = await _dio.delete<Object?>(
|
||||
MailApiPaths.scheduledMessage(id),
|
||||
);
|
||||
final Object? data = response.data;
|
||||
final Object? draftId = data is Map<String, Object?>
|
||||
? data['draftId']
|
||||
: null;
|
||||
if (draftId is! String || draftId.isEmpty) throw _malformed(response);
|
||||
return draftId;
|
||||
}
|
||||
|
||||
/// 외부 이미지 하나 — 서버가 바깥에서 대신 받아 준 그림(바이트). 실패는 알리지 않는다(그 자리를 비운다).
|
||||
/// [maxBytes] 를 넘게 오면 받기를 끊는다(취소 — `DioExceptionType.cancel`). [cancelToken] 을 그만두면(화면을 떠남 · 본문이 바뀜) 이 받기도 끊는다 —
|
||||
/// 한도로 끊는 토큰은 그림마다 따로 둔다(한 그림의 한도가 다른 그림을 끊지 않게)
|
||||
Future<Uint8List> fetchMailImage(
|
||||
String token, {
|
||||
required int maxBytes,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
final CancelToken cancel = CancelToken();
|
||||
if (cancelToken != null) {
|
||||
if (cancelToken.isCancelled) cancel.cancel();
|
||||
unawaited(
|
||||
cancelToken.whenCancel.then((DioException _) {
|
||||
if (!cancel.isCancelled) cancel.cancel();
|
||||
}),
|
||||
);
|
||||
}
|
||||
final Response<List<int>> response = await _dio.get<List<int>>(
|
||||
MailApiPaths.mailImage(token),
|
||||
options: Options(
|
||||
responseType: ResponseType.bytes,
|
||||
extra: <String, Object?>{kSilentExtraKey: true},
|
||||
),
|
||||
cancelToken: cancel,
|
||||
onReceiveProgress: (int received, int total) {
|
||||
if (received > maxBytes || total > maxBytes) cancel.cancel();
|
||||
},
|
||||
);
|
||||
final List<int>? data = response.data;
|
||||
if (data == null) {
|
||||
throw apiFailure(response.requestOptions, kFallbackCode);
|
||||
}
|
||||
return data is Uint8List ? data : Uint8List.fromList(data);
|
||||
}
|
||||
|
||||
/// 첨부 하나를 [target] 에 받는다 — 조각마다 [onProgress](받은 바이트 · 전체 바이트 — 모르면 null).
|
||||
///
|
||||
/// 실패는 늘 `DioException` 이다 — 서버가 거절했으면 인터셉터가 코드로 알렸고(실패 본문도 코드로 읽는다),
|
||||
/// 받는 도중 끊기면 `SYS_001`(이때는 응답이 이미 지나가 인터셉터가 알리지 않는다 — [DownloadInterrupted]).
|
||||
/// [cancelToken] 으로 그만두면 `DioExceptionType.cancel` 이다. 쓰다 만 [target] 은 부르는 쪽이 지운다
|
||||
Future<void> downloadAttachment({
|
||||
required String messageId,
|
||||
required String attachmentId,
|
||||
required DownloadTarget target,
|
||||
void Function(int received, int? total)? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
final Response<ResponseBody> response = await _dio.get<ResponseBody>(
|
||||
MailApiPaths.attachment(messageId, attachmentId),
|
||||
options: Options(responseType: ResponseType.stream),
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
final ResponseBody? body = response.data;
|
||||
if (body == null) {
|
||||
throw apiFailure(response.requestOptions, kFallbackCode);
|
||||
}
|
||||
final int? length = int.tryParse(
|
||||
response.headers.value(Headers.contentLengthHeader) ?? '',
|
||||
);
|
||||
final int? total = length != null && length > 0 ? length : null;
|
||||
final RequestOptions request = response.requestOptions;
|
||||
final Completer<void> done = Completer<void>();
|
||||
int received = 0;
|
||||
final StreamSubscription<Uint8List> subscription = body.stream.listen(
|
||||
(Uint8List chunk) {
|
||||
target.add(chunk);
|
||||
received += chunk.length;
|
||||
onProgress?.call(received, total);
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (done.isCompleted) return;
|
||||
// 받는 도중의 실패(끊김 · 조각 사이 받기 한도 `receiveTimeout`)는 인터셉터를 지나지 않는다 — 그만둔 것만 그대로 둔다
|
||||
final bool cancelled =
|
||||
error is DioException && error.type == DioExceptionType.cancel;
|
||||
done.completeError(
|
||||
cancelled ? error : DownloadInterrupted(request),
|
||||
stackTrace,
|
||||
);
|
||||
},
|
||||
onDone: () {
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
// 그만두면 다음 조각을 기다리지 않고 바로 끊는다
|
||||
unawaited(
|
||||
cancelToken?.whenCancel.then((DioException _) async {
|
||||
if (done.isCompleted) return;
|
||||
done.completeError(
|
||||
DioException.requestCancelled(
|
||||
requestOptions: request,
|
||||
reason: cancelToken.cancelError?.error,
|
||||
),
|
||||
);
|
||||
await subscription.cancel();
|
||||
}),
|
||||
);
|
||||
await done.future;
|
||||
}
|
||||
|
||||
DioException _malformed(Response<Object?> response) =>
|
||||
apiFailure(response.requestOptions, kFallbackCode);
|
||||
}
|
||||
|
||||
/// 첨부를 받는 도중 끊겼다 — 응답은 이미 인터셉터를 지나서 아무도 알리지 않았다(부르는 쪽이 `SYS_001` 로 알린다)
|
||||
class DownloadInterrupted extends DioException {
|
||||
DownloadInterrupted(RequestOptions requestOptions)
|
||||
: super(
|
||||
requestOptions: requestOptions,
|
||||
type: DioExceptionType.connectionError,
|
||||
error: ApiException.fromCode(kFallbackCode),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/// 쓰기 화면에 넘길 값 — 읽기 화면의 [답장] · [전체 답장] · [전달] · 본문의 mailto 링크 (순수 함수).
|
||||
///
|
||||
/// 쓰기 화면(`App1Compose`)은 다음 차례다. 지금은 가는 길(주소 · 쿼리)과 넘길 값만 정해 둔다 —
|
||||
/// 주소는 웹과 같은 쿼리(`/mail/compose?replyTo=<id>` · `&all=1` · `?forward=<id>` · `?draft=<id>`)이고,
|
||||
/// mailto 의 받는 사람 · 제목 · 본문은 주소에 싣지 않고 라우트의 `extra`([ComposePrefill])로 넘긴다
|
||||
/// (웹 `compose-prefill.ts` 처럼 — 주소가 기록 · 로그에 남지 않게).
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 쓰기 화면 주소의 쿼리 이름 — 웹 `/mail/compose` 와 같다
|
||||
abstract final class ComposeQuery {
|
||||
static const String draft = 'draft';
|
||||
static const String replyTo = 'replyTo';
|
||||
|
||||
/// [replyTo] 와 함께 `1` 이면 전체 답장
|
||||
static const String replyAll = 'all';
|
||||
static const String replyAllValue = '1';
|
||||
static const String forward = 'forward';
|
||||
}
|
||||
|
||||
/// 쓰기 화면을 여는 까닭
|
||||
sealed class ComposeIntent {
|
||||
const ComposeIntent();
|
||||
}
|
||||
|
||||
/// 새 메일 (mailto 로 채운 것 포함 — 채울 값은 [ComposePrefill])
|
||||
final class NewComposeIntent extends ComposeIntent {
|
||||
const NewComposeIntent();
|
||||
}
|
||||
|
||||
/// 임시 저장본 이어 쓰기
|
||||
final class DraftComposeIntent extends ComposeIntent {
|
||||
const DraftComposeIntent(this.draftId);
|
||||
|
||||
final String draftId;
|
||||
}
|
||||
|
||||
/// 답장 · 전체 답장
|
||||
final class ReplyComposeIntent extends ComposeIntent {
|
||||
const ReplyComposeIntent(this.messageId, {required this.all});
|
||||
|
||||
final String messageId;
|
||||
final bool all;
|
||||
}
|
||||
|
||||
/// 전달
|
||||
final class ForwardComposeIntent extends ComposeIntent {
|
||||
const ForwardComposeIntent(this.messageId);
|
||||
|
||||
final String messageId;
|
||||
}
|
||||
|
||||
/// 쓰기 화면 주소의 쿼리 → 여는 까닭 (모르는 값은 새 메일 — 웹과 같은 차례: 이어 쓰기 → 답장 → 전달)
|
||||
ComposeIntent composeIntentOf(Map<String, String> query) {
|
||||
final String? draft = query[ComposeQuery.draft];
|
||||
if (draft != null && draft.isNotEmpty) return DraftComposeIntent(draft);
|
||||
final String? replyTo = query[ComposeQuery.replyTo];
|
||||
if (replyTo != null && replyTo.isNotEmpty) {
|
||||
return ReplyComposeIntent(
|
||||
replyTo,
|
||||
all: query[ComposeQuery.replyAll] == ComposeQuery.replyAllValue,
|
||||
);
|
||||
}
|
||||
final String? forward = query[ComposeQuery.forward];
|
||||
if (forward != null && forward.isNotEmpty) {
|
||||
return ForwardComposeIntent(forward);
|
||||
}
|
||||
return const NewComposeIntent();
|
||||
}
|
||||
|
||||
/// mailto 링크로 채울 값 — 쓰기 화면이 받는 사람 · 제목 · 본문 칸을 채운다
|
||||
@immutable
|
||||
class ComposePrefill {
|
||||
const ComposePrefill({
|
||||
this.to = const <String>[],
|
||||
this.cc = const <String>[],
|
||||
this.bcc = const <String>[],
|
||||
this.subject = '',
|
||||
this.body = '',
|
||||
});
|
||||
|
||||
final List<String> to;
|
||||
final List<String> cc;
|
||||
final List<String> bcc;
|
||||
final String subject;
|
||||
final String body;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is ComposePrefill &&
|
||||
listEquals(other.to, to) &&
|
||||
listEquals(other.cc, cc) &&
|
||||
listEquals(other.bcc, bcc) &&
|
||||
other.subject == subject &&
|
||||
other.body == body;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
Object.hashAll(to),
|
||||
Object.hashAll(cc),
|
||||
Object.hashAll(bcc),
|
||||
subject,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
/// 받는 사람 합계 한도 — 발송 한도와 같다(계약 2절 — 100명)
|
||||
const int kComposeMaxRecipients = 100;
|
||||
|
||||
/// 제목 한도 — 발송 한도와 같다(255자)
|
||||
const int kComposeMaxSubject = 255;
|
||||
|
||||
/// mailto 로 채우는 본문 한도 — 링크에 실린 긴 글로 화면이 무거워지지 않게(발송 한도 100,000자보다 짧게 둔다)
|
||||
const int kComposeMaxPrefillBody = 10000;
|
||||
|
||||
/// 주소 한 칸의 최대 길이 (RFC 5321)
|
||||
const int _maxAddressLength = 254;
|
||||
|
||||
/// mailto 링크 → 채울 값 — mailto 가 아니면 null.
|
||||
///
|
||||
/// `mailto:a@x,b@x?cc=…&bcc=…&subject=…&body=…`(RFC 6068). 받는 사람은 `@` 가 있고 공백 · 제어 문자가 없는 것만,
|
||||
/// 겹치면 하나, 합계 [kComposeMaxRecipients] 까지. 제목 · 본문은 한도에서 자른다. 받는 사람이 없어도 된다(빈 쓰기 화면).
|
||||
ComposePrefill? composePrefillOfMailto(Uri uri) {
|
||||
if (uri.scheme.toLowerCase() != 'mailto') return null;
|
||||
final Map<String, String> query = <String, String>{};
|
||||
final String rawQuery = uri.hasQuery ? uri.query : '';
|
||||
if (rawQuery.isNotEmpty) {
|
||||
for (final String part in rawQuery.split('&')) {
|
||||
final int equals = part.indexOf('=');
|
||||
final String key = _decode(
|
||||
equals == -1 ? part : part.substring(0, equals),
|
||||
).toLowerCase();
|
||||
final String value = equals == -1
|
||||
? ''
|
||||
: _decode(part.substring(equals + 1));
|
||||
// 같은 이름이 여럿이면 앞의 것 — 받는 사람 칸은 이어 붙인다
|
||||
if (key == 'to' || key == 'cc' || key == 'bcc') {
|
||||
query[key] = query[key] == null ? value : '${query[key]},$value';
|
||||
} else {
|
||||
query.putIfAbsent(key, () => value);
|
||||
}
|
||||
}
|
||||
}
|
||||
final Set<String> seen = <String>{};
|
||||
List<String> addresses(String raw) {
|
||||
final List<String> out = <String>[];
|
||||
for (final String piece in raw.split(',')) {
|
||||
final String address = piece.trim();
|
||||
if (!_isPlausibleAddress(address)) continue;
|
||||
if (seen.length >= kComposeMaxRecipients) break;
|
||||
if (!seen.add(address.toLowerCase())) continue;
|
||||
out.add(address);
|
||||
}
|
||||
return List<String>.unmodifiable(out);
|
||||
}
|
||||
|
||||
return ComposePrefill(
|
||||
to: addresses(<String>[_decode(uri.path), query['to'] ?? ''].join(',')),
|
||||
cc: addresses(query['cc'] ?? ''),
|
||||
bcc: addresses(query['bcc'] ?? ''),
|
||||
subject: _limit(_singleLine(query['subject'] ?? ''), kComposeMaxSubject),
|
||||
body: _limit(query['body'] ?? '', kComposeMaxPrefillBody),
|
||||
);
|
||||
}
|
||||
|
||||
/// 퍼센트 인코딩을 푼다 — 깨진 인코딩은 그대로 둔다
|
||||
String _decode(String value) {
|
||||
try {
|
||||
return Uri.decodeComponent(value);
|
||||
} on ArgumentError {
|
||||
return value;
|
||||
} on FormatException {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isPlausibleAddress(String address) {
|
||||
if (address.isEmpty || address.length > _maxAddressLength) return false;
|
||||
final int at = address.indexOf('@');
|
||||
if (at <= 0 || at != address.lastIndexOf('@') || at == address.length - 1) {
|
||||
return false;
|
||||
}
|
||||
return !address.runes.any(
|
||||
(int rune) => rune <= 0x20 || (rune >= 0x7F && rune <= 0x9F),
|
||||
);
|
||||
}
|
||||
|
||||
/// 제목은 한 줄 — 줄바꿈 · 제어 문자는 띄어쓰기로
|
||||
String _singleLine(String value) =>
|
||||
value.replaceAll(RegExp(r'[\u0000-\u001F\u007F]+'), ' ').trim();
|
||||
|
||||
/// [max] 자(UTF-16)에서 자른다 — 이모지 같은 짝 글자(서로게이트)를 가르지 않는다
|
||||
String _limit(String value, int max) {
|
||||
if (value.length <= max) return value;
|
||||
final int end = _isHighSurrogate(value.codeUnitAt(max - 1)) ? max - 1 : max;
|
||||
return value.substring(0, end);
|
||||
}
|
||||
|
||||
bool _isHighSurrogate(int unit) => unit >= 0xD800 && unit <= 0xDBFF;
|
||||
@@ -0,0 +1,32 @@
|
||||
/// 읽기 화면의 본문 안내 — 서버가 본문을 다 주지 못했을 때 (계약 2절 "읽기 한도" · 웹 `shared/utils/mail-body-notices.ts` 와 같다).
|
||||
///
|
||||
/// 차례는 생략(서식 없이 · 표시할 수 없음) → 잘림이다. 보여 줄 본문이 없으면("표시할 수 없음") 잘림 안내는 맞지 않아 뺀다.
|
||||
/// 글자 본문은 공백뿐이면 없는 것으로 본다.
|
||||
library;
|
||||
|
||||
/// 본문 안내의 종류
|
||||
enum MailBodyNotice {
|
||||
/// 서식 본문을 한도 때문에 비웠다 — 글자 본문을 보인다
|
||||
htmlOmitted,
|
||||
|
||||
/// 서식 본문을 비웠고 글자 본문도 없다
|
||||
unavailable,
|
||||
|
||||
/// 보여 주는 본문이 읽기 한도에서 잘렸다
|
||||
truncated,
|
||||
}
|
||||
|
||||
/// 본문 위에 둘 안내 — 없으면 빈 목록
|
||||
List<MailBodyNotice> mailBodyNoticesOf({
|
||||
required String? textBody,
|
||||
required bool isHtmlBodyOmitted,
|
||||
required bool isBodyTruncated,
|
||||
}) {
|
||||
final bool hasText = (textBody ?? '').trim().isNotEmpty;
|
||||
return <MailBodyNotice>[
|
||||
if (isHtmlBodyOmitted)
|
||||
hasText ? MailBodyNotice.htmlOmitted : MailBodyNotice.unavailable,
|
||||
if (isBodyTruncated && !(isHtmlBodyOmitted && !hasText))
|
||||
MailBodyNotice.truncated,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
/// 받은 메일 본문 → WebView 에 넣을 문서 — 보안 핵심 (순수 함수 · 웹 `shared/utils/mail-html.ts` 의 결을 따른다).
|
||||
///
|
||||
/// 방어는 세 겹이다. 어느 한 겹이 뚫려도 나머지가 막도록 셋 다 유지한다.
|
||||
/// 1) 서버가 정화한 htmlBody (계약 2절 "htmlBody 정화 규칙")
|
||||
/// 2) 여기서 한 번 더 정화 — HTML5 파서(`package:html`)로 읽어 **허용 목록의 태그 · 속성만** 다시 쓴다.
|
||||
/// 서버 정화에 빈틈이 있어도 스크립트 · 폼 · 외부 자원 · 이벤트 처리기를 걷어 낸다. 결과 HTML 은 파서의 직렬화가 아니라
|
||||
/// 여기서 직접 쓴다(속성 값의 `<` `>` `"` `&` 를 늘 이스케이프 — 아래 그림 주소 바꿔 끼우기가 그 모양에 기댄다).
|
||||
/// 3) WebView — 스크립트를 끄고(`JavaScriptMode.disabled`), 이 문서 안의 CSP 로 모든 바깥 요청 · 아래 틀을 막고(그림은 `data:` 만),
|
||||
/// 주 틀의 탐색은 모두 막는다(링크는 앱이 바깥 브라우저 · 쓰기 화면으로 — `mail_links.dart`). 아래 틀(iframe)의 탐색은 안드로이드가
|
||||
/// 판정에 넘기지 않으므로 정화(iframe 을 내용째 버림)와 CSP(frame-src)만 막는다.
|
||||
///
|
||||
/// 외부 이미지를 보일 때(서버가 중계 주소 `/api/mail-images/<표>` 로 바꿔 넣은 본문 — imagesShown)도 WebView 는 바깥에 나가지 않는다 —
|
||||
/// 앱이 Dio(Bearer)로 그림을 받아 `data:` 로 바꿔 넣는다([resolveProxiedImages]). 토큰이 WebView 에 가지 않는다.
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
|
||||
/// WebView 문서 안의 CSP — 스크립트 · 글꼴 · 네트워크 요청을 모두 막고 `data:` 그림과 인라인 스타일만 연다.
|
||||
/// 웹(`MAIL_FRAME_CSP`)과 같고, `<base>` · 폼 보내기도 막는다(둘은 `default-src` 를 따르지 않는다).
|
||||
/// 외부 이미지를 보일 때도 같다 — 그림은 앱이 받아 `data:` 로 넣는다(웹처럼 중계 경로를 열지 않는다)
|
||||
const String kMailDocumentCsp =
|
||||
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'";
|
||||
|
||||
/// 서버의 중계 주소 — `/api/mail-images/<표>`. 표는 서버가 암호화한 불투명 글(base64url)이라 한 칸뿐이다
|
||||
/// (짝: backend `modules/mail/images/mail-image-token.ts` 의 `TOKEN_PATTERN` · 웹 `PROXIED_IMAGE_PATTERN`)
|
||||
final RegExp kProxiedImagePattern = RegExp(
|
||||
r'^/api/mail-images/([A-Za-z0-9_-]+)$',
|
||||
);
|
||||
|
||||
/// 한 통에서 받아 넣을 중계 그림의 최대 수 — 서버가 한 통에 바꿔 넣는 수와 같다(계약 2절 "외부 이미지 보기" — 100개)
|
||||
const int kMaxProxiedImages = 100;
|
||||
|
||||
/// 남길 링크 — 계약과 같다(http · https · mailto). 상대 경로 · javascript: 등은 지운다
|
||||
final RegExp _safeLink = RegExp(r'^(?:https?|mailto):', caseSensitive: false);
|
||||
|
||||
/// 남길 `data:` 그림 — 서버가 본문에 넣어 주는 그림 형식(PNG · JPEG · GIF · WebP)만. SVG 는 받지 않는다
|
||||
final RegExp _dataImage = RegExp(
|
||||
r'^data:image/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+/=\s]*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// 남기는 태그 — 메일 본문에 흔한 글 · 표 · 목록 · 인용 태그(DOMPurify 의 HTML 목록에서 폼 · 미디어 · 틀을 뺐다)
|
||||
const Set<String> _allowedTags = <String>{
|
||||
'a', 'abbr', 'acronym', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', //
|
||||
'big', 'blockquote', 'br', 'caption', 'center', 'cite', 'code', 'col', //
|
||||
'colgroup', 'data', 'dd', 'del', 'details', 'dfn', 'dir', 'div', 'dl', //
|
||||
'dt', 'em', 'figcaption', 'figure', 'font', 'footer', 'h1', 'h2', 'h3', //
|
||||
'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'i', 'img', 'ins', 'kbd', //
|
||||
'li', 'main', 'mark', 'menu', 'nav', 'nobr', 'ol', 'p', 'pre', 'q', 'rp', //
|
||||
'rt', 'ruby', 's', 'samp', 'section', 'small', 'span', 'strike', 'strong', //
|
||||
'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', //
|
||||
'time', 'tr', 'tt', 'u', 'ul', 'var', 'wbr',
|
||||
};
|
||||
|
||||
/// 내용째 버리는 태그 — 스크립트 · 스타일 · 틀 · 미디어 · SVG · 수식 · 폼의 숨은 값(웹 `FORBID_CONTENTS` 와 서버 제거 목록)
|
||||
const Set<String> _droppedWithContent = <String>{
|
||||
'script', 'style', 'template', 'iframe', 'frame', 'frameset', 'object', //
|
||||
'embed', 'applet', 'param', 'noscript', 'noembed', 'noframes', 'svg', //
|
||||
'math', 'datalist', 'title', 'head', 'xmp', 'plaintext', 'audio', //
|
||||
'video', 'source', 'track', 'canvas', 'map', 'area', 'link', 'meta', //
|
||||
'base', 'input', 'select', 'dialog', 'slot',
|
||||
};
|
||||
|
||||
/// 내용이 없는 태그 — 닫는 태그를 쓰지 않는다
|
||||
const Set<String> _voidTags = <String>{'br', 'col', 'hr', 'img', 'wbr'};
|
||||
|
||||
/// 어느 태그에나 남기는 속성 — 모양(style) · 표 · 글꼴의 옛 속성. 이벤트 처리기 · data-* · id · target · 외부 자원 속성은 없다
|
||||
const Set<String> _globalAttributes = <String>{
|
||||
'style', 'class', 'title', 'dir', 'lang', 'align', 'valign', 'width', //
|
||||
'height', 'bgcolor', 'color', 'border', 'cellpadding', 'cellspacing', //
|
||||
'colspan', 'rowspan', 'span', 'face', 'size', 'nowrap', 'frame', 'rules', //
|
||||
'summary', 'abbr', 'headers', 'scope', 'hspace', 'vspace', 'clear', //
|
||||
'noshade', 'datetime',
|
||||
};
|
||||
|
||||
/// 태그마다 더 남기는 속성
|
||||
const Map<String, Set<String>> _tagAttributes = <String, Set<String>>{
|
||||
'a': <String>{'href'},
|
||||
'img': <String>{'src', 'alt'},
|
||||
'ol': <String>{'start', 'type', 'reversed'},
|
||||
'ul': <String>{'type'},
|
||||
'li': <String>{'type', 'value'},
|
||||
'details': <String>{'open'},
|
||||
};
|
||||
|
||||
/// 속성 값의 최대 길이 — 넘으면 그 속성을 뺀다(`style` 은 표 꾸밈이 길 수 있어 넉넉히 · 주소는 따로)
|
||||
const int _maxAttributeLength = 1000;
|
||||
const int _maxStyleLength = 4000;
|
||||
|
||||
/// 링크 주소의 최대 길이 — 중계 주소 한도(2,048자)와 같다
|
||||
const int _maxHrefLength = 2048;
|
||||
|
||||
/// 앱이 정화하는 서식 본문의 최대 길이(글자) — 서버의 읽기 한도와 같다(계약 2절 "읽기 한도" — 2,000,000)
|
||||
const int kMaxSanitizedHtmlLength = 2000000;
|
||||
|
||||
/// 앱이 정화하는 서식 본문의 최대 중첩 비용 — 태그마다 그때의 깊이를 더한 값([htmlNestingCost]).
|
||||
///
|
||||
/// HTML5 파서(`package:html`)는 태그마다 열린 요소를 훑어(범위 검사) 깊이에 비례해 일하므로 깊은 본문은 제곱으로 느려진다
|
||||
/// (2026-11-06 이 PC 의 시험 판: 깊이 200 · 2MB 2.2초, 깊이 600 · 2MB 6.4초, div 5,000단 55KB 3.8초 — 서버는 2만 단까지 넘긴다).
|
||||
/// 이 값이면 정화가 2MB 표 한 통(1.5초)보다 2초 남짓 더 걸리는 데서 멈춘다. 넘으면 서식 없이 글자 본문을 보인다
|
||||
/// (서버의 `isHtmlBodyOmitted` 와 같은 안내 — 50단 안팎의 보통 답장 스레드는 한참 아래다)
|
||||
const int kMaxHtmlNestingCost = 8000000;
|
||||
|
||||
/// 정화한 본문 조각 — `<body>` 안에 넣는다
|
||||
class SanitizedMailBody {
|
||||
const SanitizedMailBody({
|
||||
required this.fragment,
|
||||
required this.proxiedTokens,
|
||||
this.htmlOmitted = false,
|
||||
});
|
||||
|
||||
/// 정화한 HTML 조각 — 중계 그림은 `<img src="/api/mail-images/<표>"` 꼴로 남아 있다([resolveProxiedImages] 가 바꾼다)
|
||||
final String fragment;
|
||||
|
||||
/// 받아 넣을 중계 그림의 표 — 문서 차례 · 겹치지 않게 · [kMaxProxiedImages] 까지(외부 이미지를 보일 때만)
|
||||
final List<String> proxiedTokens;
|
||||
|
||||
/// 서식 본문이 앱의 정화 한도(길이 · 중첩)를 넘어 글자 본문을 보인다 — 화면은 서버의 생략과 같은 안내를 보인다
|
||||
final bool htmlOmitted;
|
||||
}
|
||||
|
||||
/// 보여 줄 서식 본문 — null 이거나 공백뿐이면 없다(null). 그때는 textBody 를 보인다(웹 `displayedHtmlBody` · 서버와 같은 규칙)
|
||||
String? displayedHtmlBody(String? htmlBody) =>
|
||||
htmlBody != null && htmlBody.trim().isNotEmpty ? htmlBody : null;
|
||||
|
||||
/// 본문 조각을 만든다 — HTML 이 있으면 정화하고, 없으면(공백뿐 포함) 텍스트를 이스케이프한다.
|
||||
/// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다).
|
||||
/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다
|
||||
SanitizedMailBody mailBodyFragment({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
required bool imagesShown,
|
||||
}) {
|
||||
final String? html = displayedHtmlBody(htmlBody);
|
||||
if (html == null) return _textFragment(textBody);
|
||||
final String trimmed = html.trim();
|
||||
if (trimmed.length > kMaxSanitizedHtmlLength ||
|
||||
htmlNestingCost(trimmed, limit: kMaxHtmlNestingCost) >
|
||||
kMaxHtmlNestingCost) {
|
||||
return _textFragment(textBody, htmlOmitted: true);
|
||||
}
|
||||
return sanitizeMailHtml(trimmed, imagesShown: imagesShown);
|
||||
}
|
||||
|
||||
SanitizedMailBody _textFragment(String? textBody, {bool htmlOmitted = false}) =>
|
||||
SanitizedMailBody(
|
||||
fragment:
|
||||
'<div class="mail-text">${escapeHtmlText(textBody ?? '')}</div>',
|
||||
proxiedTokens: const <String>[],
|
||||
htmlOmitted: htmlOmitted,
|
||||
);
|
||||
|
||||
/// 정화 전 비용 — 태그(여는 · 닫는)마다 그때의 깊이를 더한다. 글자를 한 번만 훑고 [limit] 을 넘으면 그 자리에서 멈춘다.
|
||||
///
|
||||
/// 파서보다 **깊게** 센다(넉넉한 쪽 — 덜 세면 깊은 본문이 정화를 오래 붙잡는다):
|
||||
/// - 암묵 닫힘(`<p>` 뒤의 `<p>` 등)을 보지 않고, 원시 글(`<textarea>` · `<script>` 등) 안의 태그도 센다.
|
||||
/// - **주석을 건너뛰지 않는다** — 주석 안의 태그도 센다. 주석의 끝을 파서와 다르게 읽으면 그 사이를 덜 센다(서버가 주석을 지우므로 정상 메일과는 상관없다).
|
||||
/// 문서 형식 선언 · 엉뚱한 주석(`<!x …>` · `<?…>`)만 파서처럼 다음 `>` 까지 건너뛴다.
|
||||
/// - 빈 요소(`br` · `img` 등)만 깊이를 늘리지 않는다 — `<div/>` 의 `/` 는 파서가 무시하므로 여는 태그로 센다.
|
||||
/// - 닫는 태그는 맨 위에 열린 요소와 이름이 같을 때만 깊이를 줄인다 — 파서는 맞지 않는 닫는 태그(`<div>` 안의 `</span>`)를 무시하므로,
|
||||
/// 닫는 태그마다 줄이면 `<div>` 1,000 · `</span>` 1,000 · `<div>` 1,000 의 깊이를 2,000 이 아니라 1,000 으로 덜 센다.
|
||||
/// 서버가 정화한 본문은 태그를 모두 맞게 닫아 실제 깊이와 같다
|
||||
int htmlNestingCost(String html, {required int limit}) {
|
||||
final int length = html.length;
|
||||
// 열린 요소의 이름 — 깊이는 그 수다(한도를 넘으면 멈추므로 깊이도 한도의 제곱근쯤에서 그친다)
|
||||
final List<String> open = <String>[];
|
||||
int cost = 0;
|
||||
int index = 0;
|
||||
while (true) {
|
||||
index = html.indexOf('<', index);
|
||||
if (index == -1 || index + 1 >= length) break;
|
||||
final int next = html.codeUnitAt(index + 1);
|
||||
if (next == _bang || next == _question) {
|
||||
if (html.startsWith('<!--', index)) {
|
||||
index += 4;
|
||||
continue;
|
||||
}
|
||||
final int end = html.indexOf('>', index + 2);
|
||||
if (end == -1) break;
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
if (next == _slash) {
|
||||
if (index + 2 < length && _isAsciiLetter(html.codeUnitAt(index + 2))) {
|
||||
cost += open.length;
|
||||
final int nameEnd = _tagNameEnd(html, index + 2);
|
||||
if (open.isNotEmpty &&
|
||||
open.last == html.substring(index + 2, nameEnd).toLowerCase()) {
|
||||
open.removeLast();
|
||||
}
|
||||
}
|
||||
index += 2;
|
||||
} else if (_isAsciiLetter(next)) {
|
||||
final int nameEnd = _tagNameEnd(html, index + 1);
|
||||
final int close = html.indexOf('>', nameEnd);
|
||||
if (close == -1) break;
|
||||
final String name = html.substring(index + 1, nameEnd).toLowerCase();
|
||||
cost += open.length;
|
||||
if (!_htmlVoidTags.contains(name)) open.add(name);
|
||||
index = close + 1;
|
||||
} else {
|
||||
index++;
|
||||
}
|
||||
if (cost > limit) return cost;
|
||||
}
|
||||
return cost;
|
||||
}
|
||||
|
||||
/// 태그 이름이 끝나는 자리 — [start] 부터 이름 글자([_isTagNameChar])가 이어지는 데까지
|
||||
int _tagNameEnd(String html, int start) {
|
||||
int end = start;
|
||||
while (end < html.length && _isTagNameChar(html.codeUnitAt(end))) {
|
||||
end++;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
/// 화면 isolate 에서 그 자리 정화하는 서식 본문의 최대 길이(글자) — 이 길이부터는 다른 isolate 에서 정화한다.
|
||||
///
|
||||
/// 그 자리 정화가 2ms 안쪽이 되게 잡았다(2026-11-06 개발 PC · AOT 로 잰 중앙값 — 휴대폰은 몇 배 느리다):
|
||||
/// 태그가 촘촘한 평평한 본문 6,000자 1.3ms · 8,000자 1.7ms · 10,000자 2.0~2.5ms · 32,000자 7ms, 인라인 style 표 32,000자 4ms.
|
||||
/// 다른 isolate 로 보내는 값은 작은 본문 한 통에 0.8ms(결과까지의 시간 — 화면 isolate 는 그동안 비어 있다)
|
||||
const int kMailBodyInlineMaxLength = 6 * 1024;
|
||||
|
||||
/// 그 자리 정화의 최대 중첩 비용([htmlNestingCost]) — 짧아도 깊은 본문은 파서가 깊이의 제곱으로 일한다.
|
||||
///
|
||||
/// 같은 PC 에서: `div` 중첩 비용 10,000 0.6ms · 25,600 1.3ms · 40,000 1.9ms · 90,000 4.3ms · 200,000 남짓 10~14ms,
|
||||
/// 인용(`blockquote` + 문단) 중첩 20,200 1.4ms · 29,040 2.0ms. 길이 한도(6,000자 1.3ms)와 함께 2ms 안쪽이 되게 5,000 으로 둔다
|
||||
const int kMailBodyInlineMaxCost = 5000;
|
||||
|
||||
/// 화면 isolate 에서 그 자리 만드는 글자 본문(이스케이프만)의 최대 길이 — 같은 PC 에서 64,000자 1.0ms · 128,000자 2.4ms
|
||||
const int kMailBodyInlineMaxTextLength = 64 * 1024;
|
||||
|
||||
/// 본문 조각([mailBodyFragment])을 만들 곳
|
||||
enum MailBodyBuildPlace {
|
||||
/// 화면 isolate 에서 그 자리 — 짧고 얕은 본문
|
||||
inline,
|
||||
|
||||
/// 다른 isolate 에서(`Isolate.run`) — 길거나 깊은 본문
|
||||
isolate,
|
||||
}
|
||||
|
||||
/// 본문 조각을 만들 곳 — 서식 본문은 [kMailBodyInlineMaxLength] 보다 짧고 **중첩 비용도** [kMailBodyInlineMaxCost] 안일 때만 그 자리,
|
||||
/// 글자 본문은 [kMailBodyInlineMaxTextLength] 보다 짧을 때만 그 자리. 비용은 한도를 넘는 순간 멈춰 세므로 고르는 일 자체는 짧다
|
||||
MailBodyBuildPlace mailBodyBuildPlaceOf({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
}) {
|
||||
final String? html = displayedHtmlBody(htmlBody);
|
||||
if (html == null) {
|
||||
return (textBody?.length ?? 0) < kMailBodyInlineMaxTextLength
|
||||
? MailBodyBuildPlace.inline
|
||||
: MailBodyBuildPlace.isolate;
|
||||
}
|
||||
if (html.length >= kMailBodyInlineMaxLength) {
|
||||
return MailBodyBuildPlace.isolate;
|
||||
}
|
||||
return htmlNestingCost(html, limit: kMailBodyInlineMaxCost) >
|
||||
kMailBodyInlineMaxCost
|
||||
? MailBodyBuildPlace.isolate
|
||||
: MailBodyBuildPlace.inline;
|
||||
}
|
||||
|
||||
const int _bang = 0x21;
|
||||
const int _question = 0x3F;
|
||||
const int _slash = 0x2F;
|
||||
|
||||
bool _isAsciiLetter(int unit) =>
|
||||
(unit >= 0x41 && unit <= 0x5A) || (unit >= 0x61 && unit <= 0x7A);
|
||||
|
||||
/// 태그 이름의 글자 — 공백 · `/` · `>` 가 아닌 것
|
||||
bool _isTagNameChar(int unit) =>
|
||||
unit != 0x20 &&
|
||||
unit != 0x09 &&
|
||||
unit != 0x0A &&
|
||||
unit != 0x0C &&
|
||||
unit != 0x0D &&
|
||||
unit != _slash &&
|
||||
unit != 0x3E;
|
||||
|
||||
/// HTML 의 빈 요소 — 닫는 태그가 없어 깊이를 늘리지 않는다
|
||||
const Set<String> _htmlVoidTags = <String>{
|
||||
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', //
|
||||
'meta', 'source', 'track', 'wbr', 'param', 'keygen',
|
||||
};
|
||||
|
||||
/// 메일 HTML 정화 — 결과는 `<body>` 안에 넣을 조각.
|
||||
///
|
||||
/// 허용 목록 밖의 태그는 태그만 지우고 글은 남긴다(`<o:p>` · `<form>` · `<button>` 등), [_droppedWithContent] 는 내용째 버린다.
|
||||
/// HTML 이 아닌 이름공간(SVG · MathML)은 내용째 버린다. 주석은 지운다. 깊은 중첩에도 스택이 넘치지 않게 되풀이로 돈다.
|
||||
/// 문서 전체로 읽어 `<body>` 아래만 쓴다 — 조각으로 읽으면(`parseFragment`) 맨 위 요소가 많을 때 파서가 옮기며 제곱으로 느려진다
|
||||
/// (2026-11-06 측정: 맨 위 span 4만 개 10초 → 문서로 0.3초). 머리에 들어가는 태그(meta · title · style 등)는 어차피 버린다
|
||||
SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
|
||||
final dom.Element? body = html_parser.parse(html).body;
|
||||
final StringBuffer out = StringBuffer();
|
||||
final List<String> tokens = <String>[];
|
||||
final Set<String> seen = <String>{};
|
||||
if (body == null) {
|
||||
return const SanitizedMailBody(fragment: '', proxiedTokens: <String>[]);
|
||||
}
|
||||
|
||||
// 되풀이 깊이 우선 — 닫는 태그는 글로 쌓는다
|
||||
final List<Object> stack = <Object>[..._reversed(body.nodes)];
|
||||
while (stack.isNotEmpty) {
|
||||
final Object item = stack.removeLast();
|
||||
if (item is String) {
|
||||
out.write(item);
|
||||
continue;
|
||||
}
|
||||
final dom.Node node = item as dom.Node;
|
||||
if (node is dom.Text) {
|
||||
out.write(escapeHtmlText(node.data));
|
||||
continue;
|
||||
}
|
||||
if (node is! dom.Element) continue;
|
||||
final String? namespace = node.namespaceUri;
|
||||
if (namespace != null && namespace != _htmlNamespace) continue;
|
||||
final String name = (node.localName ?? '').toLowerCase();
|
||||
if (_droppedWithContent.contains(name)) continue;
|
||||
if (!_allowedTags.contains(name)) {
|
||||
stack.addAll(_reversed(node.nodes));
|
||||
continue;
|
||||
}
|
||||
out
|
||||
..write('<')
|
||||
..write(name)
|
||||
..write(
|
||||
_attributesOf(
|
||||
node,
|
||||
name,
|
||||
imagesShown: imagesShown,
|
||||
onProxied: (String token) {
|
||||
if (seen.contains(token)) return true;
|
||||
if (tokens.length >= kMaxProxiedImages) return false;
|
||||
seen.add(token);
|
||||
tokens.add(token);
|
||||
return true;
|
||||
},
|
||||
),
|
||||
)
|
||||
..write('>');
|
||||
if (_voidTags.contains(name)) continue;
|
||||
stack
|
||||
..add('</$name>')
|
||||
..addAll(_reversed(node.nodes));
|
||||
}
|
||||
return SanitizedMailBody(
|
||||
fragment: out.toString(),
|
||||
proxiedTokens: List<String>.unmodifiable(tokens),
|
||||
);
|
||||
}
|
||||
|
||||
const String _htmlNamespace = 'http://www.w3.org/1999/xhtml';
|
||||
|
||||
Iterable<dom.Node> _reversed(List<dom.Node> nodes) => nodes.reversed;
|
||||
|
||||
/// 남길 속성을 ` 이름="값"` 꼴로 — 그림은 `src` 를, 링크는 `href` 를 맨 앞에 쓴다.
|
||||
/// [onProxied] 는 남길 중계 그림이면 참(한 통의 한도를 넘으면 거짓 — 그 그림은 주소를 지운다)
|
||||
String _attributesOf(
|
||||
dom.Element element,
|
||||
String tag, {
|
||||
required bool imagesShown,
|
||||
required bool Function(String token) onProxied,
|
||||
}) {
|
||||
final Set<String> tagSpecific = _tagAttributes[tag] ?? const <String>{};
|
||||
final StringBuffer out = StringBuffer();
|
||||
final Map<String, String> attributes = <String, String>{
|
||||
for (final MapEntry<Object, String> entry in element.attributes.entries)
|
||||
if (entry.key is String) (entry.key as String).toLowerCase(): entry.value,
|
||||
};
|
||||
|
||||
// 주소 속성 — 맨 앞에
|
||||
if (tag == 'img') {
|
||||
final String? src = _imageSource(
|
||||
attributes['src'],
|
||||
imagesShown: imagesShown,
|
||||
onProxied: onProxied,
|
||||
);
|
||||
if (src != null) out.write(' src="${escapeHtmlAttribute(src)}"');
|
||||
}
|
||||
if (tag == 'a') {
|
||||
final String? href = _linkTarget(attributes['href']);
|
||||
if (href != null) out.write(' href="${escapeHtmlAttribute(href)}"');
|
||||
}
|
||||
|
||||
for (final MapEntry<String, String> entry in attributes.entries) {
|
||||
final String name = entry.key;
|
||||
if (name == 'src' || name == 'href') continue;
|
||||
if (!_globalAttributes.contains(name) && !tagSpecific.contains(name)) {
|
||||
continue;
|
||||
}
|
||||
final String value = entry.value;
|
||||
final int limit = name == 'style' ? _maxStyleLength : _maxAttributeLength;
|
||||
if (value.length > limit) continue;
|
||||
out.write(' $name="${escapeHtmlAttribute(value)}"');
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/// 그림 주소 — 늘 `data:` 그림, 외부 이미지를 보일 때만 서버의 중계 주소도. 그 밖은 null(주소를 지운다 — 그림은 숨는다)
|
||||
String? _imageSource(
|
||||
String? raw, {
|
||||
required bool imagesShown,
|
||||
required bool Function(String token) onProxied,
|
||||
}) {
|
||||
final String? src = raw?.trim();
|
||||
if (src == null || src.isEmpty) return null;
|
||||
if (_dataImage.hasMatch(src)) return src;
|
||||
if (!imagesShown) return null;
|
||||
final String? token = proxiedImageToken(src);
|
||||
if (token == null || !onProxied(token)) return null;
|
||||
return src;
|
||||
}
|
||||
|
||||
/// 링크 주소 — http · https · mailto 만, 공백 · 제어 문자가 섞인 주소는 지운다
|
||||
String? _linkTarget(String? raw) {
|
||||
final String? href = raw?.trim();
|
||||
if (href == null || href.isEmpty || href.length > _maxHrefLength) {
|
||||
return null;
|
||||
}
|
||||
if (!_safeLink.hasMatch(href)) return null;
|
||||
if (href.runes.any(_isControlOrSpace)) return null;
|
||||
return href;
|
||||
}
|
||||
|
||||
bool _isControlOrSpace(int rune) =>
|
||||
rune <= 0x20 || (rune >= 0x7F && rune <= 0x9F);
|
||||
|
||||
/// 중계 주소의 표 — 그 꼴이 아니면 null
|
||||
String? proxiedImageToken(String src) =>
|
||||
kProxiedImagePattern.firstMatch(src.trim())?.group(1);
|
||||
|
||||
/// 글 이스케이프 — 본문의 글 · 텍스트 본문
|
||||
String escapeHtmlText(String text) => text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
|
||||
/// 속성 값 이스케이프 — 따옴표 · 꺾쇠까지(값 안에 `>` 가 남지 않아 [resolveProxiedImages] 가 태그 경계를 믿을 수 있다)
|
||||
String escapeHtmlAttribute(String value) => value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
|
||||
/// 정화한 조각의 중계 그림 주소를 받은 그림(`data:`)으로 바꾼다 — 받지 못한(실패 · 한도 밖 · 아직 받는 중) 그림은 주소를 지운다(그림이 숨는다).
|
||||
///
|
||||
/// 같은 그림이 여러 번 나오면 나온 만큼 문서에 실린다 — 그래서 실은 그림 글(`data:` 주소의 글자 수)을 **나온 차례대로** 더해
|
||||
/// [maxTotalLength] 를 넘는 자리부터는 모두 주소를 지운다(뒤의 작은 그림도). 문서를 만드는 도중에 센다 — 같은 4.9MB 그림을
|
||||
/// `<img>` 1,000번으로 넣은 본문이 6.5GB 문서가 되기 전에 멈춘다.
|
||||
/// [sanitizeMailHtml] 이 쓴 `<img src="/api/mail-images/<표>"` 꼴만 본다 — 글(이스케이프된 `<img`)과 다른 속성 값에는 닿지 않는다
|
||||
String resolveProxiedImages(
|
||||
String fragment,
|
||||
Map<String, String> dataUris, {
|
||||
int maxTotalLength = kMailImagesDocumentMaxLength,
|
||||
}) {
|
||||
int used = 0;
|
||||
bool full = false;
|
||||
return fragment.replaceAllMapped(_proxiedImageTag, (Match match) {
|
||||
final String? data = dataUris[match.group(1)!];
|
||||
if (data == null || full) return '<img';
|
||||
if (used + data.length > maxTotalLength) {
|
||||
full = true;
|
||||
return '<img';
|
||||
}
|
||||
used += data.length;
|
||||
return '<img src="${escapeHtmlAttribute(data)}"';
|
||||
});
|
||||
}
|
||||
|
||||
final RegExp _proxiedImageTag = RegExp(
|
||||
r'<img src="/api/mail-images/([A-Za-z0-9_-]+)"',
|
||||
);
|
||||
|
||||
/// 본문 문서의 기본 모양 — 앱의 메일 본문 15 · 줄 간격 1.75(앱 디자인 README "크기 · 글꼴"), 글자색 · 강조색 · 인용선은 웹 `MAIL_FRAME_STYLE` 과 같다.
|
||||
/// 글꼴은 CSP 로 내려받을 수 없어 기기 글꼴을 쓴다. 여백은 보드 `App1Reader` 의 본문(위 16 · 좌우 20 · 아래 20 — 마지막 줄이 막대에 붙지 않게 24)
|
||||
const String _documentStyle =
|
||||
'html{-webkit-text-size-adjust:100%;overflow-x:auto}'
|
||||
"body{margin:0;padding:16px 20px 24px;font-family:Pretendard,'Noto Sans KR','Apple SD Gothic Neo',system-ui,sans-serif;"
|
||||
'font-size:15px;line-height:1.75;color:#1F2733;background:#FFFFFF;overflow-wrap:anywhere}'
|
||||
'img{max-width:100%;height:auto}'
|
||||
// 주소를 지운 그림(외부 이미지 차단 · 받지 못함)은 깨진 그림 대신 숨긴다 — 차단 사실은 본문 위 안내 줄이 알린다
|
||||
'img:not([src]){display:none}'
|
||||
'a{color:#0E7C86}'
|
||||
'pre{white-space:pre-wrap}'
|
||||
'blockquote{margin:0 0 0 4px;padding-left:12px;border-left:2px solid #98A2B3;color:#4A5363}'
|
||||
'.mail-text{white-space:pre-wrap}';
|
||||
|
||||
/// WebView 에 넣을 문서 — CSP 를 맨 앞에(어떤 내용보다 먼저 걸리게) 둔다
|
||||
String buildMailDocument(String bodyFragment) => <String>[
|
||||
'<!doctype html>',
|
||||
'<html lang="ko"><head>',
|
||||
'<meta charset="utf-8">',
|
||||
'<meta http-equiv="Content-Security-Policy" content="$kMailDocumentCsp">',
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||
'<meta name="referrer" content="no-referrer">',
|
||||
'<style>$_documentStyle</style>',
|
||||
'</head>',
|
||||
'<body>$bodyFragment</body>',
|
||||
'</html>',
|
||||
].join();
|
||||
@@ -0,0 +1,71 @@
|
||||
/// 외부 이미지(서버의 중계 그림)를 받아 본문에 넣을 때의 한도와 판정 — 순수 함수 (계약 2절 "외부 이미지 보기").
|
||||
///
|
||||
/// WebView 는 바깥에 나가지 않으므로 앱이 Dio(Bearer)로 `GET /api/mail-images/{표}` 를 받아 `data:` 로 바꿔 넣는다.
|
||||
/// 서버가 이미 그림인지 보고(앞 바이트 — PNG · JPEG · GIF · WebP) 5MB 에서 끊지만, 앱도 받은 바이트를 다시 본다.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// 그림 하나의 최대 크기 — 서버가 바깥에서 받는 한도와 같다(5MB)
|
||||
const int kMailImageMaxBytes = 5 * 1024 * 1024;
|
||||
|
||||
/// 한 통에서 받아 두는 그림 합계의 최대 크기(서로 다른 그림마다 한 번) — 앱에서 정함. 그림은 문서 글(base64 — 4/3 배)에 실려 WebView 로 넘어가므로
|
||||
/// 메모리를 지키려고 둔다(웹은 브라우저가 그림마다 따로 받아 한도가 없다). 넘는 그림은 자리를 비운다
|
||||
const int kMailImagesTotalMaxBytes = 10 * 1024 * 1024;
|
||||
|
||||
/// 본문 문서에 싣는 그림 글(`data:` 주소의 글자 수)의 합계 한도 — 같은 그림이 여러 번 나오면 **나온 만큼** 센다.
|
||||
/// 문서 차례대로 싣다가 넘는 자리부터는 주소를 지운다(`resolveProxiedImages`). 같은 그림 하나를 `<img>` 1,000번(HTML 40KB)으로 넣어도
|
||||
/// 문서가 이 한도 안에 든다 — 받는 양의 한도([kMailImagesTotalMaxBytes])와 같은 수다
|
||||
const int kMailImagesDocumentMaxLength = 10 * 1024 * 1024;
|
||||
|
||||
/// 함께 받는 그림 수 — 서버의 한 직원 한도(6)보다 적게 둔다
|
||||
const int kMailImageConcurrency = 4;
|
||||
|
||||
/// 받은 바이트의 앞부분으로 그림 형식을 정한다 — PNG · JPEG · GIF · WebP 만(서버와 같다). 아니면 null
|
||||
String? sniffImageType(Uint8List bytes) {
|
||||
if (_startsWith(bytes, _png)) return 'image/png';
|
||||
if (_startsWith(bytes, _jpeg)) return 'image/jpeg';
|
||||
if (_startsWith(bytes, _gif87) || _startsWith(bytes, _gif89)) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (bytes.length >= _webpHeaderLength &&
|
||||
_startsWith(bytes, _riff) &&
|
||||
_matchesAt(bytes, _webp, _webpOffset)) {
|
||||
return 'image/webp';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// `data:` 그림 주소
|
||||
String imageDataUri(String mimeType, Uint8List bytes) =>
|
||||
'data:$mimeType;base64,${base64Encode(bytes)}';
|
||||
|
||||
/// 받은 그림을 넣을 수 있는가 — 형식이 그림이고 한도 안이면 그 `data:` 주소, 아니면 null.
|
||||
/// [usedBytes] 는 이 통에 이미 넣은 그림의 바이트 합
|
||||
String? acceptMailImage(Uint8List bytes, {required int usedBytes}) {
|
||||
if (bytes.isEmpty || bytes.length > kMailImageMaxBytes) return null;
|
||||
if (usedBytes + bytes.length > kMailImagesTotalMaxBytes) return null;
|
||||
final String? type = sniffImageType(bytes);
|
||||
return type == null ? null : imageDataUri(type, bytes);
|
||||
}
|
||||
|
||||
const List<int> _png = <int>[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
const List<int> _jpeg = <int>[0xFF, 0xD8, 0xFF];
|
||||
const List<int> _gif87 = <int>[0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
|
||||
const List<int> _gif89 = <int>[0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
|
||||
const List<int> _riff = <int>[0x52, 0x49, 0x46, 0x46];
|
||||
const List<int> _webp = <int>[0x57, 0x45, 0x42, 0x50];
|
||||
const int _webpOffset = 8;
|
||||
const int _webpHeaderLength = 12;
|
||||
|
||||
bool _startsWith(Uint8List bytes, List<int> magic) =>
|
||||
_matchesAt(bytes, magic, 0);
|
||||
|
||||
bool _matchesAt(Uint8List bytes, List<int> magic, int offset) {
|
||||
if (bytes.length < offset + magic.length) return false;
|
||||
for (int index = 0; index < magic.length; index++) {
|
||||
if (bytes[offset + index] != magic[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/// 본문 WebView 의 탐색 판정 — 순수 함수 (앱 디자인 README 「읽기」: 링크는 바깥 브라우저 · mailto 는 앱의 쓰기 화면).
|
||||
///
|
||||
/// WebView 는 처음 넣은 문서(`about:blank` — `loadHtmlString` 의 기준 주소 없음) 밖으로 가지 않는다. 주 틀에서 누른 링크는 모두 막고
|
||||
/// 앱이 대신 연다 — http · https 는 바깥 앱(브라우저), mailto 는 앱의 쓰기 화면. 그 밖(javascript: · file: · content: · intent: ·
|
||||
/// 상대 주소)은 아무것도 하지 않는다. 정화가 http · https · mailto 밖의 링크를 이미 지우지만 여기서 한 번 더 본다.
|
||||
///
|
||||
/// **아래 틀(iframe)의 탐색은 이 판정을 거치지 않는다**(안드로이드 — webview_flutter_android 는 주 틀만 넘기고 아래 틀은 WebView 가 그대로 연다).
|
||||
/// 아래 틀을 막는 것은 정화(iframe · frame · object · embed 를 내용째 버림)와 문서의 CSP(`default-src 'none'` — frame-src 가 이것을 따른다)다.
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
|
||||
|
||||
/// WebView 가 처음 연 문서의 주소 — 기준 주소 없이 넣은 문서(Android `loadDataWithBaseURL(null, …)` · iOS `loadHTMLString`)
|
||||
const String kInitialDocumentUrl = 'about:blank';
|
||||
|
||||
/// 바깥으로 보낼 링크 주소의 최대 길이 — 정화의 링크 한도와 같다
|
||||
const int kMaxExternalLinkLength = 2048;
|
||||
|
||||
/// 탐색 하나의 판정
|
||||
sealed class MailNavigation {
|
||||
const MailNavigation();
|
||||
}
|
||||
|
||||
/// 처음 넣은 문서 — 그대로 연다
|
||||
final class OpenInitialDocument extends MailNavigation {
|
||||
const OpenInitialDocument();
|
||||
}
|
||||
|
||||
/// 막고 바깥 앱(브라우저)으로 — http · https
|
||||
final class OpenExternally extends MailNavigation {
|
||||
const OpenExternally(this.uri);
|
||||
|
||||
final Uri uri;
|
||||
}
|
||||
|
||||
/// 막고 앱의 쓰기 화면으로 — mailto
|
||||
final class OpenCompose extends MailNavigation {
|
||||
const OpenCompose(this.prefill);
|
||||
|
||||
final ComposePrefill prefill;
|
||||
}
|
||||
|
||||
/// 막고 아무것도 하지 않는다
|
||||
final class IgnoreNavigation extends MailNavigation {
|
||||
const IgnoreNavigation();
|
||||
}
|
||||
|
||||
/// WebView 의 탐색 요청([url] · [isMainFrame]) → 판정
|
||||
MailNavigation mailNavigationOf(String url, {required bool isMainFrame}) {
|
||||
final String trimmed = url.trim();
|
||||
if (trimmed == kInitialDocumentUrl) {
|
||||
return isMainFrame ? const OpenInitialDocument() : const IgnoreNavigation();
|
||||
}
|
||||
// 아래 틀 — 안드로이드는 여기까지 오지 않는다(정화와 CSP 가 막는다). 다른 플랫폼이 넘기면 열지 않는다
|
||||
if (!isMainFrame) return const IgnoreNavigation();
|
||||
return mailLinkOf(trimmed);
|
||||
}
|
||||
|
||||
/// 본문의 링크 주소 → 판정 (누른 링크)
|
||||
MailNavigation mailLinkOf(String url) {
|
||||
if (url.isEmpty || url.length > kMaxExternalLinkLength) {
|
||||
return const IgnoreNavigation();
|
||||
}
|
||||
if (url.runes.any((int rune) => rune < 0x20 || rune == 0x7F)) {
|
||||
return const IgnoreNavigation();
|
||||
}
|
||||
final Uri? uri = Uri.tryParse(url);
|
||||
if (uri == null) return const IgnoreNavigation();
|
||||
switch (uri.scheme.toLowerCase()) {
|
||||
case 'http':
|
||||
case 'https':
|
||||
return uri.host.isEmpty ? const IgnoreNavigation() : OpenExternally(uri);
|
||||
case 'mailto':
|
||||
final ComposePrefill? prefill = composePrefillOfMailto(uri);
|
||||
return prefill == null ? const IgnoreNavigation() : OpenCompose(prefill);
|
||||
default:
|
||||
return const IgnoreNavigation();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:arasystem_app/core/modules/module_routes.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
|
||||
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -19,23 +21,36 @@ abstract final class MailRoutes {
|
||||
static String message(String messageId) =>
|
||||
'$home/messages/${Uri.encodeComponent(messageId)}';
|
||||
|
||||
/// 메일 쓰기 (전체 화면 — 탭 막대 없이)
|
||||
/// 메일 쓰기 (전체 화면 — 탭 막대 없이). 본문의 mailto 링크는 채울 값을 라우트의 `extra`([ComposePrefill])로 넘긴다
|
||||
static String get compose => '$home/compose';
|
||||
|
||||
/// 임시 저장본 이어 쓰기 — 임시보관함의 줄을 누르면(웹과 같다 — 주소 `?draft=<id>`)
|
||||
static String composeDraft(String draftId) => Uri(
|
||||
path: compose,
|
||||
queryParameters: <String, String>{draftQuery: draftId},
|
||||
).toString();
|
||||
static String composeDraft(String draftId) =>
|
||||
_compose(<String, String>{ComposeQuery.draft: draftId});
|
||||
|
||||
/// 답장 · 전체 답장 — 읽기 화면의 아래 막대(웹과 같다 — `?replyTo=<id>` · `&all=1`)
|
||||
static String composeReply(String messageId, {bool all = false}) =>
|
||||
_compose(<String, String>{
|
||||
ComposeQuery.replyTo: messageId,
|
||||
if (all) ComposeQuery.replyAll: ComposeQuery.replyAllValue,
|
||||
});
|
||||
|
||||
/// 전달 — 읽기 화면의 아래 막대(웹과 같다 — `?forward=<id>`)
|
||||
static String composeForward(String messageId) =>
|
||||
_compose(<String, String>{ComposeQuery.forward: messageId});
|
||||
|
||||
static String _compose(Map<String, String> query) =>
|
||||
Uri(path: compose, queryParameters: query).toString();
|
||||
|
||||
/// 이어 쓸 임시 저장본 id 의 쿼리 이름 (웹 `/mail/compose?draft=` 와 같다)
|
||||
static const String draftQuery = 'draft';
|
||||
static const String draftQuery = ComposeQuery.draft;
|
||||
}
|
||||
|
||||
/// 메일 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
|
||||
///
|
||||
/// 목록은 앱 보드 `App1MailList` 화면이다. 읽기 · 쓰기는 아직 자리 화면이다 — 보드(`App1Reader` · `App1Compose`)대로 만들면
|
||||
/// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 둔다).
|
||||
/// 목록은 앱 보드 `App1MailList`, 읽기는 `App1Reader` 화면이다. 쓰기는 아직 자리 화면이다 — 보드(`App1Compose`)대로 만들면
|
||||
/// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 — 여는 까닭은 `composeIntentOf(state.uri.queryParameters)`,
|
||||
/// mailto 로 채울 값은 `state.extra` 의 [ComposePrefill]).
|
||||
final ModuleRoutes mailModuleRoutes = ModuleRoutes(
|
||||
id: AppModuleId.mail,
|
||||
// 알림 · 깊은 링크의 id 는 메일 id 다 — 새 메일 알림("새 메일 N통")처럼 id 가 없으면 첫 화면
|
||||
@@ -53,8 +68,11 @@ final ModuleRoutes mailModuleRoutes = ModuleRoutes(
|
||||
GoRoute(
|
||||
path: 'messages/:messageId',
|
||||
parentNavigatorKey: context.rootNavigatorKey,
|
||||
builder: (BuildContext _, GoRouterState _) =>
|
||||
const PlaceholderPage(title: MailTexts.message),
|
||||
builder: (BuildContext _, GoRouterState state) => MessageReaderPage(
|
||||
// 메일마다 새 화면 — 다른 메일로 바꿔 끼우면 앞 메일의 상태(펼침 · 받기)를 이어 쓰지 않는다
|
||||
key: ValueKey<String>(state.pathParameters['messageId']!),
|
||||
messageId: state.pathParameters['messageId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'compose',
|
||||
|
||||
@@ -9,16 +9,143 @@ library;
|
||||
import 'package:arasystem_app/core/utils/korean_format.dart';
|
||||
import 'package:arasystem_app/core/utils/korean_text.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart';
|
||||
|
||||
/// 메일 모듈의 화면 이름 — 아직 자리 화면인 읽기 · 쓰기가 쓴다
|
||||
/// 메일 모듈의 화면 이름 — 아직 자리 화면인 쓰기가 쓴다
|
||||
abstract final class MailTexts {
|
||||
/// 메일 읽기 (전체 화면)
|
||||
static const String message = '메일 읽기';
|
||||
|
||||
/// 메일 쓰기 (전체 화면)
|
||||
static const String compose = '메일 쓰기';
|
||||
}
|
||||
|
||||
/// 읽기 화면 (보드 `App1Reader`). 보드에 없는 상태(붙잡힌 메일 · 본문 안내 · 받지 못함 · 첨부 열기 · 삭제 확인)의 글은
|
||||
/// 웹 상수(`frontend/src/shared/constants/mail.ts` · `mail-send-later.ts`)를 그대로 옮겼다 — 앱에서 새로 쓴 글은 그 줄에 적었다
|
||||
abstract final class MailReaderTexts {
|
||||
// ── 위 막대 ──
|
||||
static const String back = '목록으로';
|
||||
static const String delete = '삭제';
|
||||
|
||||
/// 휴지통에만 든 메일의 [delete] 대신 — 확인 뒤 영구 삭제
|
||||
static const String purge = '영구 삭제';
|
||||
static const String more = '더 보기';
|
||||
|
||||
// ── 더 보기 ──
|
||||
static const String markUnread = '안 읽음 표시';
|
||||
|
||||
/// 읽음 처리를 하지 못한 메일 — [markUnread] 대신 (앱에서 새로 씀)
|
||||
static const String markRead = '읽음 표시';
|
||||
static const String flag = '중요 표시';
|
||||
static const String unflag = '중요 해제';
|
||||
static const String move = '이동';
|
||||
static const String reportSpam = '스팸 신고';
|
||||
|
||||
/// 스팸메일함의 메일 — [reportSpam] 대신
|
||||
static const String notSpam = '스팸 아님';
|
||||
|
||||
/// 중요 표시를 바꾼 뒤 (앱에서 새로 씀 — 읽기 화면에는 별이 없어 알림으로 알린다)
|
||||
static const String flagged = '중요 표시했습니다.';
|
||||
static const String unflagged = '중요 표시를 해제했습니다.';
|
||||
|
||||
// ── 머리 ──
|
||||
static const String noSubject = '(제목 없음)';
|
||||
static const String noSender = '(보낸 사람 없음)';
|
||||
|
||||
/// 받는 사람 가운데 지금 직원
|
||||
static const String me = '나';
|
||||
|
||||
/// 받는 사람 줄 — "받는 사람 나 외 1명"
|
||||
static String recipients(String first, int others) =>
|
||||
others > 0 ? '받는 사람 $first 외 ${formatCount(others)}명' : '받는 사람 $first';
|
||||
static const String noRecipients = '받는 사람 없음';
|
||||
|
||||
// 머리 펼침의 칸 이름
|
||||
static const String fromLabel = '보낸 사람';
|
||||
static const String toLabel = '받는 사람';
|
||||
static const String ccLabel = '참조';
|
||||
static const String bccLabel = '숨은참조';
|
||||
static const String dateLabel = '날짜';
|
||||
|
||||
// ── 안내 줄 ──
|
||||
static const String imagesBlocked =
|
||||
'외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.';
|
||||
static const String showImages = '이미지 보기';
|
||||
|
||||
/// 그림을 받아 넣는 동안 — 화면 낭독기 (앱에서 새로 씀)
|
||||
static const String imagesLoading = '이미지를 불러오는 중';
|
||||
|
||||
/// 본문 안내 (웹 `MAIL_BODY_NOTICES`)
|
||||
static String bodyNotice(MailBodyNotice notice) => switch (notice) {
|
||||
MailBodyNotice.htmlOmitted => '본문이 복잡해 서식 없이 글자만 표시합니다.',
|
||||
MailBodyNotice.unavailable => '본문이 너무 크거나 복잡해 표시할 수 없습니다.',
|
||||
MailBodyNotice.truncated => '본문이 길어 앞부분만 표시합니다.',
|
||||
};
|
||||
|
||||
/// 보내기 취소를 기다리는 메일 (앱에서 새로 씀 — 웹 읽기 화면에는 이 줄이 없다)
|
||||
static const String sendingNotice = '곧 보낼 메일입니다.';
|
||||
|
||||
/// 예약한 메일 — "11월 7일 (토) 오전 9:00 에 보낼 메일입니다." (보드 그대로 띄어 쓴다)
|
||||
static String scheduledNotice(String time) => '$time 에 보낼 메일입니다.';
|
||||
static const String undoSend = '보내기 취소';
|
||||
static const String cancelSchedule = '예약 취소';
|
||||
static const String undoSendDone = '보내기를 취소하고 임시보관함으로 옮겼습니다.';
|
||||
static const String scheduleCanceled = '예약을 취소하고 임시보관함으로 옮겼습니다.';
|
||||
|
||||
// ── 보내지 않은 메일의 [삭제] 확인 (처음 초점은 [취소]) ──
|
||||
static const String heldDeleteTitle = '보내지 않은 메일을 삭제할까요?';
|
||||
static const String heldDeleteMessage = '보내지 않고 휴지통으로 옮깁니다.';
|
||||
static const String heldDeleteConfirm = '삭제';
|
||||
static const String heldDeleteCancel = '취소';
|
||||
static const String heldDeleted = '보내지 않고 휴지통으로 옮겼습니다.';
|
||||
|
||||
// ── 본문 ──
|
||||
/// 본문 자리 — 화면 낭독기
|
||||
static const String body = '메일 본문';
|
||||
|
||||
/// 처음 불러오는 동안 — 화면 낭독기
|
||||
static const String loading = '메일 불러오는 중';
|
||||
|
||||
/// 본문을 정화해 문서로 만드는 동안 본문 자리의 진행 표시 — 화면 낭독기 (앱에서 새로 씀)
|
||||
static const String bodyLoading = '본문을 불러오고 있습니다.';
|
||||
|
||||
/// 본문의 링크를 열 앱이 없을 때 (앱에서 새로 씀)
|
||||
static const String linkFailed = '이 링크를 열 수 있는 앱이 없습니다.';
|
||||
|
||||
// ── 첨부 ──
|
||||
static String attachmentsTitle(int count) => '첨부 파일 ${formatCount(count)}개';
|
||||
|
||||
/// 받기 단추 — 화면 낭독기 "11월_정기점검_안내.pdf 받기"
|
||||
static String download(String name) => '$name 받기';
|
||||
static String cancelDownload(String name) => '$name 받기 취소';
|
||||
|
||||
/// 진행 막대 — 화면 낭독기
|
||||
static String downloading(String name) => '$name 받는 중';
|
||||
|
||||
/// 진행 글 — "받는 중 64%" (전체 크기를 모르면 "받는 중")
|
||||
static String progress(int? percent) =>
|
||||
percent == null ? '받는 중' : '받는 중 $percent%';
|
||||
|
||||
/// 대용량 첨부 줄의 크기 옆 (앱에서 새로 씀 — 첫 판은 받지 않는다)
|
||||
static const String largeFile = '대용량 첨부';
|
||||
static const String largeFilesNote =
|
||||
'대용량 첨부는 앱에서 아직 받을 수 없습니다. 본문의 링크를 누르거나 웹메일에서 받아 주세요.';
|
||||
|
||||
// 받은 파일을 열지 못했을 때 (앱에서 새로 씀)
|
||||
static const String noAppToOpen =
|
||||
'이 파일을 열 수 있는 앱이 없습니다. 파일을 여는 앱을 설치하거나 웹메일에서 받아 주세요.';
|
||||
static const String openFailed = '파일을 열지 못했습니다. 다시 받아 주세요.';
|
||||
static const String saveFailed = '파일을 저장하지 못했습니다. 휴대폰의 저장 공간을 확인해 주세요.';
|
||||
|
||||
/// 앱에서 열지 않는 형식(웹 문서 · SVG · 설치 파일 · 인증서 — `isBlockedFileOpen`)의 [받기] — 받기 전에 알린다
|
||||
static const String blockedFileType =
|
||||
'이 형식의 파일은 앱에서 열 수 없습니다. PC 웹메일에서 받아 주세요.';
|
||||
|
||||
// ── 아래 막대 ──
|
||||
/// 아래 막대 — 화면 낭독기
|
||||
static const String actions = '답장과 전달';
|
||||
static const String reply = '답장';
|
||||
static const String replyAll = '전체 답장';
|
||||
static const String forward = '전달';
|
||||
}
|
||||
|
||||
/// 목록 화면 (보드 `App1MailList` 「받은편지함」 · 「당겨서 새로고침」 · 「빈 메일함 (휴지통)」)
|
||||
abstract final class MailListTexts {
|
||||
// ── 위 막대 ──
|
||||
@@ -163,6 +290,17 @@ abstract final class MailBulkTexts {
|
||||
skipped,
|
||||
);
|
||||
|
||||
/// 스팸 아님 — "메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다." (웹 `notSpamMovedText`)
|
||||
static String notSpamMoved(
|
||||
int done,
|
||||
String mailboxName, {
|
||||
int skipped = 0,
|
||||
}) => _withSkipped(
|
||||
'메일 ${formatCount(done)}통을 스팸이 아닌 메일로 표시해 $mailboxName${directionParticle(mailboxName)} 옮겼습니다.',
|
||||
done,
|
||||
skipped,
|
||||
);
|
||||
|
||||
static String purged(int done, {int skipped = 0}) =>
|
||||
_withSkipped('메일 ${formatCount(done)}통을 영구 삭제했습니다.', done, skipped);
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/// 읽기 화면 머리의 표시 — 순수 함수 (보드 `App1Reader` 의 머리: 보낸 사람 · 시각 · "받는 사람 나 외 1명" · 펼친 칸).
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/utils/korean_format.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
|
||||
/// 머리의 시각 — 오늘 "오전 10:42" · 어제 "어제 오후 4:20" · 올해 "11월 3일 오후 4:20" · 그 전 "2025. 10. 24. 오후 4:20".
|
||||
/// 기기 시간대로 본다(목록의 `formatMailListTime` 에 시각을 붙인 꼴)
|
||||
String formatReaderTime(DateTime at, DateTime now) {
|
||||
final DateTime local = at.toLocal();
|
||||
final DateTime today = now.toLocal();
|
||||
final String time = koreanTimeOfDay(local);
|
||||
if (isSameCalendarDay(local, today)) return time;
|
||||
if (isSameCalendarDay(local, previousCalendarDay(today))) {
|
||||
return '어제 $time';
|
||||
}
|
||||
if (local.year == today.year) return '${koreanMonthDay(local)} $time';
|
||||
return '${koreanNumericDate(local)} $time';
|
||||
}
|
||||
|
||||
/// 머리 펼침의 날짜 — "2026년 11월 4일 (수) 오후 4:20"
|
||||
String formatReaderFullDate(DateTime at) => koreanFullDateTime(at.toLocal());
|
||||
|
||||
/// 받는 사람 줄 — 받는 사람 · 참조 · 숨은참조를 합쳐(같은 주소는 한 번) 지금 직원이 있으면 "나"를 앞에.
|
||||
/// "받는 사람 나" · "받는 사람 나 외 1명" · "받는 사람 박준호 외 2명" · 아무도 없으면 "받는 사람 없음"
|
||||
String recipientSummaryOf(MessageDetail detail, {required String? myEmail}) {
|
||||
final List<MailAddress> everyone = <MailAddress>[
|
||||
...detail.summary.to,
|
||||
...detail.cc,
|
||||
...detail.bcc,
|
||||
];
|
||||
final Set<String> seen = <String>{};
|
||||
final List<MailAddress> unique = <MailAddress>[
|
||||
for (final MailAddress address in everyone)
|
||||
if (seen.add(address.email.toLowerCase())) address,
|
||||
];
|
||||
if (unique.isEmpty) return MailReaderTexts.noRecipients;
|
||||
final String? me = myEmail?.toLowerCase();
|
||||
final bool includesMe = me != null && seen.contains(me);
|
||||
final String first = includesMe
|
||||
? MailReaderTexts.me
|
||||
: unique.first.displayName;
|
||||
return MailReaderTexts.recipients(first, unique.length - 1);
|
||||
}
|
||||
|
||||
/// 머리 펼침의 한 칸 — 이름 · 주소 (이름이 없으면 주소만)
|
||||
typedef ReaderAddressLine = ({String name, String? email});
|
||||
|
||||
/// 주소 하나 → 펼친 칸의 줄. 이름이 없거나 주소와 같으면 주소만 한 줄
|
||||
ReaderAddressLine readerAddressLine(MailAddress address) {
|
||||
final String? name = address.name?.trim();
|
||||
if (name == null || name.isEmpty || name == address.email) {
|
||||
return (name: address.email, email: null);
|
||||
}
|
||||
return (name: name, email: address.email);
|
||||
}
|
||||
|
||||
/// 붙잡힌 메일의 안내 줄 글 — 예약 "11월 7일 (토) 오전 9:00 에 보낼 메일입니다." · 보내기 취소를 기다리는 동안 "곧 보낼 메일입니다."
|
||||
String pendingNoticeOf(PendingSend pending) =>
|
||||
pending.kind == PendingSendKind.scheduled
|
||||
? MailReaderTexts.scheduledNotice(
|
||||
koreanScheduleTime(pending.sendAt.toLocal()),
|
||||
)
|
||||
: MailReaderTexts.sendingNotice;
|
||||
@@ -0,0 +1,506 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/api/api_exception.dart';
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/features/mail/data/mail_link_launcher.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
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/domain/compose_request.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_links.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart';
|
||||
import 'package:arasystem_app/features/mail/mail_routes.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/attachment_downloads.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_controller.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_state.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_body_view.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_move_sheet.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/reader_attachments.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/reader_header.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/reader_notices.dart';
|
||||
import 'package:arasystem_app/shared/widgets/confirm_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// 머리(제목 · 보낸 사람 · 안내 줄 · 첨부)가 차지할 수 있는 최대 높이 — 남은 자리의 이만큼. 넘으면 머리 안에서 스크롤한다
|
||||
const double kReaderHeadMaxFraction = 0.5;
|
||||
|
||||
/// 메일 읽기 — 앱 보드 `App1Reader` (앱 디자인 README 「읽기」). 탭 막대 없는 전체 화면이다.
|
||||
///
|
||||
/// - 위 막대: 뒤로 · 삭제(휴지통에만 든 메일은 빨간 영구 삭제) · 더 보기(안 읽음 표시 · 중요 표시 · 이동 · 스팸 신고 — 스팸메일함에서는 스팸 아님).
|
||||
/// 붙잡힌 메일(보내기 취소를 기다림 · 예약)에는 이동 · 스팸이 없고 [삭제]는 확인 뒤 발송을 취소하고 휴지통으로.
|
||||
/// - 머리는 앱 화면, 본문은 WebView(`MailBodyView`) — 머리 아래 남은 자리를 다 쓰고 본문은 그 안에서 스크롤한다.
|
||||
/// 머리가 길면(펼친 받는 사람 · 첨부 여럿) 남은 자리의 절반까지만 쓰고 머리 안에서 스크롤한다.
|
||||
/// - **첨부 목록은 머리 안(안내 줄 아래 · 본문 위)이다** — 보드는 본문 아래지만, 본문이 WebView 안에서 스크롤하므로 아래에 두면
|
||||
/// 늘 화면 아래 자리를 차지한다(쓰기 화면처럼 위에 둔다 — 앱에서 정함).
|
||||
/// - 아래 행동 막대: 답장 · 전체 답장 · 전달 — 쓰기 화면(다음 차례)으로 그 메일 id 를 넘긴다(`MailRoutes.composeReply` 등). 예약한 메일에는 없다(웹과 같다).
|
||||
/// - 옮기기 · 스팸 · 휴지통 · 영구 삭제 · 발송 취소가 끝나면 목록으로 돌아간다(목록 · 메일함 개수는 컨트롤러가 맞춘다).
|
||||
/// 그 일을 하는 동안은 아래 막대와 본문 링크(mailto 포함)를 막고, 돌아가기는 읽기 화면이 맨 위일 때만 한다(위에 연 화면을 닫지 않게).
|
||||
class MessageReaderPage extends ConsumerStatefulWidget {
|
||||
const MessageReaderPage({required this.messageId, super.key});
|
||||
|
||||
/// 주소의 메일 id
|
||||
final String messageId;
|
||||
|
||||
@override
|
||||
ConsumerState<MessageReaderPage> createState() => _MessageReaderPageState();
|
||||
}
|
||||
|
||||
class _MessageReaderPageState extends ConsumerState<MessageReaderPage> {
|
||||
/// 보내지 않은 메일의 삭제 확인 창이 열려 있다 — 그사이 발송이 풀리면 닫는다(보낸 메일의 삭제 확인으로 바뀌지 않게 — 웹과 같다)
|
||||
bool _heldDialogOpen = false;
|
||||
|
||||
MessageReaderController get _controller =>
|
||||
ref.read(messageReaderProvider(widget.messageId).notifier);
|
||||
|
||||
/// 목록으로 — 쌓아 연 화면이면 뒤로, 주소로 바로 열었으면 받은편지함으로.
|
||||
/// 읽기 화면이 맨 위일 때만 닫는다 — 일이 끝나기를 기다리는 사이 위에 다른 화면이 열렸으면 그 화면을 닫지 않는다
|
||||
void _leave() {
|
||||
if (!mounted) return;
|
||||
if (!(ModalRoute.of(context)?.isCurrent ?? false)) return;
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go(MailRoutes.home);
|
||||
}
|
||||
}
|
||||
|
||||
void _notice(String text) => ref.read(mailNoticeSinkProvider)(text);
|
||||
|
||||
// ── 위 막대 ──
|
||||
|
||||
Future<void> _delete(MessageDetail detail, MailboxesState mailboxes) async {
|
||||
if (detail.summary.isHeld) {
|
||||
_heldDialogOpen = true;
|
||||
final bool confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: MailReaderTexts.heldDeleteTitle,
|
||||
message: MailReaderTexts.heldDeleteMessage,
|
||||
confirmLabel: MailReaderTexts.heldDeleteConfirm,
|
||||
cancelLabel: MailReaderTexts.heldDeleteCancel,
|
||||
danger: true,
|
||||
);
|
||||
_heldDialogOpen = false;
|
||||
if (!confirmed || !mounted) return;
|
||||
if (await _controller.deleteHeld()) _leave();
|
||||
return;
|
||||
}
|
||||
if (deletesPermanently(
|
||||
detail.summary,
|
||||
mailboxes.byRole(MailboxRole.trash),
|
||||
)) {
|
||||
final bool confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: MailBulkTexts.purgeTitle(1),
|
||||
message: MailBulkTexts.purgeMessage,
|
||||
confirmLabel: MailBulkTexts.purgeConfirm,
|
||||
cancelLabel: MailBulkTexts.purgeCancel,
|
||||
danger: true,
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
if (await _controller.purge()) _leave();
|
||||
return;
|
||||
}
|
||||
if (await _controller.moveToTrash()) _leave();
|
||||
}
|
||||
|
||||
Future<void> _onMenu(
|
||||
ReaderMenuAction action,
|
||||
MessageDetail detail,
|
||||
MailboxesState mailboxes,
|
||||
) async {
|
||||
switch (action) {
|
||||
case ReaderMenuAction.toggleRead:
|
||||
final bool markUnread = detail.summary.isRead;
|
||||
// [안 읽음 표시]는 나중에 읽을 메일이라 목록으로 돌아간다(흔한 메일 앱과 같다 — 앱에서 정함)
|
||||
if (await _controller.setRead(!markUnread) && markUnread) _leave();
|
||||
case ReaderMenuAction.toggleFlag:
|
||||
await _controller.toggleFlag();
|
||||
case ReaderMenuAction.move:
|
||||
final List<MailboxNode> targets = <MailboxNode>[
|
||||
for (final MailboxNode node in moveTargetsOf(mailboxes.items, null))
|
||||
if (!detail.summary.mailboxIds.contains(node.mailbox.id)) node,
|
||||
];
|
||||
if (targets.isEmpty) return;
|
||||
final Mailbox? chosen = await showMailboxPickerSheet(
|
||||
context,
|
||||
targets: targets,
|
||||
);
|
||||
if (chosen == null || !mounted) return;
|
||||
if (await _controller.moveToMailbox(chosen)) _leave();
|
||||
case ReaderMenuAction.toggleSpam:
|
||||
if (await _controller.toggleSpam()) _leave();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 안내 줄 ──
|
||||
|
||||
Future<void> _cancelHeld() async {
|
||||
if (await _controller.cancelHeld()) _leave();
|
||||
}
|
||||
|
||||
// ── 본문 링크 ──
|
||||
|
||||
void _onNavigation(MailNavigation navigation) {
|
||||
// 삭제 · 이동 · 스팸 등을 하는 동안은 열지 않는다 — 끝나면 목록으로 돌아간다
|
||||
if (ref.read(messageReaderProvider(widget.messageId)).busy != null) return;
|
||||
switch (navigation) {
|
||||
case OpenExternally(:final Uri uri):
|
||||
unawaited(_openExternally(uri));
|
||||
case OpenCompose(:final ComposePrefill prefill):
|
||||
unawaited(context.push<void>(MailRoutes.compose, extra: prefill));
|
||||
case OpenInitialDocument():
|
||||
case IgnoreNavigation():
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openExternally(Uri uri) async {
|
||||
final bool opened = await ref.read(mailLinkLauncherProvider)(uri);
|
||||
if (!opened && mounted) _notice(MailReaderTexts.linkFailed);
|
||||
}
|
||||
|
||||
// ── 아래 막대 ──
|
||||
|
||||
void _compose(String location) => unawaited(context.push<void>(location));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final MessageReaderState reader = ref.watch(
|
||||
messageReaderProvider(widget.messageId),
|
||||
);
|
||||
final MailboxesState mailboxes = ref.watch(mailboxesProvider);
|
||||
// 받은편지함 위에서 연 화면이 사라질 때까지 [되돌리기] 알림을 쥔다(컨트롤러도 쥔다)
|
||||
ref.listen<MailToast?>(mailToastProvider, (MailToast? _, MailToast? _) {});
|
||||
ref.listen<MessageReaderState>(messageReaderProvider(widget.messageId), (
|
||||
MessageReaderState? previous,
|
||||
MessageReaderState next,
|
||||
) {
|
||||
// 확인 창을 연 채 발송이 풀렸다 — 창을 닫는다
|
||||
if (_heldDialogOpen && (previous?.isHeld ?? false) && !next.isHeld) {
|
||||
_heldDialogOpen = false;
|
||||
Navigator.of(context, rootNavigator: true).pop(false);
|
||||
}
|
||||
});
|
||||
|
||||
final MessageDetail? detail = reader.status == MessageReaderStatus.ready
|
||||
? reader.detail
|
||||
: null;
|
||||
final bool busy = reader.busy != null;
|
||||
final bool held = detail?.summary.isHeld ?? false;
|
||||
final bool scheduled =
|
||||
detail?.summary.pendingSend?.kind == PendingSendKind.scheduled;
|
||||
|
||||
return Scaffold(
|
||||
appBar: ReaderAppBar(
|
||||
onBack: _leave,
|
||||
onDelete: detail == null || busy
|
||||
? null
|
||||
: () => unawaited(_delete(detail, mailboxes)),
|
||||
permanentDelete:
|
||||
detail != null &&
|
||||
!held &&
|
||||
deletesPermanently(
|
||||
detail.summary,
|
||||
mailboxes.byRole(MailboxRole.trash),
|
||||
),
|
||||
menu: detail == null
|
||||
? const <ReaderMenuItem>[]
|
||||
: readerMenuOf(detail, mailboxes),
|
||||
onMenu: detail == null || busy
|
||||
? null
|
||||
: (ReaderMenuAction action) =>
|
||||
unawaited(_onMenu(action, detail, mailboxes)),
|
||||
),
|
||||
body: switch (reader.status) {
|
||||
MessageReaderStatus.loading => const _ReaderSkeleton(),
|
||||
MessageReaderStatus.failed => _ReaderError(
|
||||
error: reader.error ?? ApiException.fromCode('SYS_001'),
|
||||
onRetry: () => unawaited(_controller.retry()),
|
||||
onLeave: _leave,
|
||||
),
|
||||
MessageReaderStatus.ready => _ReaderContent(
|
||||
messageId: widget.messageId,
|
||||
reader: reader,
|
||||
onShowImages: () => unawaited(_controller.showImages()),
|
||||
onCancelHeld: () => unawaited(_cancelHeld()),
|
||||
onNavigation: _onNavigation,
|
||||
),
|
||||
},
|
||||
// 삭제 · 이동 · 스팸 등을 하는 동안은 쓰기 화면을 열지 않는다 — 끝나면 목록으로 돌아간다
|
||||
bottomNavigationBar: detail == null || scheduled
|
||||
? null
|
||||
: ReaderActionBar(
|
||||
onReply: busy
|
||||
? null
|
||||
: () => _compose(MailRoutes.composeReply(widget.messageId)),
|
||||
onReplyAll: busy
|
||||
? null
|
||||
: () => _compose(
|
||||
MailRoutes.composeReply(widget.messageId, all: true),
|
||||
),
|
||||
onForward: busy
|
||||
? null
|
||||
: () => _compose(MailRoutes.composeForward(widget.messageId)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 휴지통에만 든 메일인가 — 그러면 [삭제]가 확인 뒤 영구 삭제다(휴지통과 다른 메일함에 함께 든 메일은 휴지통으로 옮기기 — 웹과 같다).
|
||||
/// 휴지통을 아직 모르면 아니라고 본다(옮기기 쪽이 해롭지 않다)
|
||||
bool deletesPermanently(MessageSummary summary, Mailbox? trash) {
|
||||
if (trash == null || summary.mailboxIds.isEmpty) return false;
|
||||
return summary.mailboxIds.every((String id) => id == trash.id);
|
||||
}
|
||||
|
||||
/// 더 보기 메뉴 — 읽음 · 중요는 늘, 이동 · 스팸은 붙잡히지 않은 메일이고 옮길 곳을 알 때만
|
||||
List<ReaderMenuItem> readerMenuOf(
|
||||
MessageDetail detail,
|
||||
MailboxesState mailboxes,
|
||||
) {
|
||||
final MessageSummary summary = detail.summary;
|
||||
final Mailbox? junk = mailboxes.byRole(MailboxRole.junk);
|
||||
final bool inJunk = junk != null && summary.mailboxIds.contains(junk.id);
|
||||
final bool spamTarget = inJunk
|
||||
? mailboxes.byRole(MailboxRole.inbox) != null
|
||||
: junk != null;
|
||||
final bool canMove = moveTargetsOf(
|
||||
mailboxes.items,
|
||||
null,
|
||||
).any((MailboxNode node) => !summary.mailboxIds.contains(node.mailbox.id));
|
||||
return <ReaderMenuItem>[
|
||||
summary.isRead
|
||||
? const ReaderMenuItem(
|
||||
ReaderMenuAction.toggleRead,
|
||||
MailReaderTexts.markUnread,
|
||||
Icons.mark_email_unread_outlined,
|
||||
)
|
||||
: const ReaderMenuItem(
|
||||
ReaderMenuAction.toggleRead,
|
||||
MailReaderTexts.markRead,
|
||||
Icons.drafts_outlined,
|
||||
),
|
||||
summary.isFlagged
|
||||
? const ReaderMenuItem(
|
||||
ReaderMenuAction.toggleFlag,
|
||||
MailReaderTexts.unflag,
|
||||
Icons.star,
|
||||
)
|
||||
: const ReaderMenuItem(
|
||||
ReaderMenuAction.toggleFlag,
|
||||
MailReaderTexts.flag,
|
||||
Icons.star_border,
|
||||
),
|
||||
if (!summary.isHeld && canMove)
|
||||
const ReaderMenuItem(
|
||||
ReaderMenuAction.move,
|
||||
MailReaderTexts.move,
|
||||
Icons.drive_file_move_outlined,
|
||||
),
|
||||
if (!summary.isHeld && spamTarget)
|
||||
inJunk
|
||||
? const ReaderMenuItem(
|
||||
ReaderMenuAction.toggleSpam,
|
||||
MailReaderTexts.notSpam,
|
||||
Icons.inbox_outlined,
|
||||
)
|
||||
: const ReaderMenuItem(
|
||||
ReaderMenuAction.toggleSpam,
|
||||
MailReaderTexts.reportSpam,
|
||||
Icons.report_outlined,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 받은 메일 — 머리(남은 자리의 절반까지 · 넘으면 그 안에서 스크롤) + 본문 WebView(남은 자리 전부)
|
||||
class _ReaderContent extends ConsumerWidget {
|
||||
const _ReaderContent({
|
||||
required this.messageId,
|
||||
required this.reader,
|
||||
required this.onShowImages,
|
||||
required this.onCancelHeld,
|
||||
required this.onNavigation,
|
||||
});
|
||||
|
||||
final String messageId;
|
||||
final MessageReaderState reader;
|
||||
final VoidCallback onShowImages;
|
||||
final VoidCallback onCancelHeld;
|
||||
final void Function(MailNavigation navigation) onNavigation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final MessageDetail detail = reader.detail!;
|
||||
final Map<String, AttachmentDownload> downloads = ref.watch(
|
||||
attachmentDownloadsProvider(messageId),
|
||||
);
|
||||
final AttachmentDownloadsController downloader = ref.read(
|
||||
attachmentDownloadsProvider(messageId).notifier,
|
||||
);
|
||||
// 시각 표기의 "오늘 · 어제"를 가르는 지금 — 시험은 손 시계로 덮어쓴다
|
||||
final DateTime now = ref.read(clockProvider)();
|
||||
final String? myEmail = ref.watch(mailAccountProvider);
|
||||
final List<MessageAttachment> attachments = detail.listedAttachments;
|
||||
final String? document = reader.document;
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: constraints.maxHeight * kReaderHeadMaxFraction,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
ReaderHeader(detail: detail, myEmail: myEmail, now: now),
|
||||
ReaderNotices(
|
||||
detail: detail,
|
||||
htmlOmitted: reader.htmlOmitted,
|
||||
busy: reader.busy != null,
|
||||
imagesLoading: reader.imagesLoading,
|
||||
onShowImages: onShowImages,
|
||||
onCancelHeld: onCancelHeld,
|
||||
),
|
||||
if (attachments.isNotEmpty || detail.largeFiles.isNotEmpty)
|
||||
ReaderAttachments(
|
||||
attachments: attachments,
|
||||
largeFiles: detail.largeFiles,
|
||||
downloads: downloads,
|
||||
onDownload: (MessageAttachment attachment) =>
|
||||
unawaited(downloader.download(attachment)),
|
||||
onCancel: (MessageAttachment attachment) =>
|
||||
downloader.cancel(attachment.id),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (reader.imagesLoading)
|
||||
Semantics(
|
||||
label: MailReaderTexts.imagesLoading,
|
||||
liveRegion: true,
|
||||
child: const LinearProgressIndicator(
|
||||
minHeight: AppSize.progressBarHeight,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: document == null
|
||||
? const _BodyLoading()
|
||||
: MailBodyView(
|
||||
document: document,
|
||||
onNavigation: onNavigation,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 본문을 정화해 문서로 만드는 동안(큰 본문은 다른 isolate 에서) — 본문 자리 맨 위의 가는 진행 줄
|
||||
class _BodyLoading extends StatelessWidget {
|
||||
const _BodyLoading();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
label: MailReaderTexts.bodyLoading,
|
||||
liveRegion: true,
|
||||
child: const Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: LinearProgressIndicator(minHeight: AppSize.progressBarHeight),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 처음 받는 동안 — 제목 · 보낸 사람 · 본문 줄 모양의 회색 막대
|
||||
class _ReaderSkeleton extends StatelessWidget {
|
||||
const _ReaderSkeleton();
|
||||
|
||||
static const List<double> _widths = <double>[0.7, 0.45, 1, 1, 0.6];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color bar = Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
return Semantics(
|
||||
label: MailReaderTexts.loading,
|
||||
liveRegion: true,
|
||||
child: ExcludeSemantics(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s20,
|
||||
AppSpacing.s12,
|
||||
AppSpacing.s20,
|
||||
0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
for (final double width in _widths) ...<Widget>[
|
||||
FractionallySizedBox(
|
||||
widthFactor: width,
|
||||
child: Container(
|
||||
height: AppSpacing.s12,
|
||||
decoration: BoxDecoration(
|
||||
color: bar,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xs),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일은 [목록으로] — 다시 해도 같다)
|
||||
class _ReaderError extends StatelessWidget {
|
||||
const _ReaderError({
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
required this.onLeave,
|
||||
});
|
||||
|
||||
final ApiException error;
|
||||
final VoidCallback onRetry;
|
||||
final VoidCallback onLeave;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: MailListError(
|
||||
error: error,
|
||||
onRetry: onRetry,
|
||||
onLeave: onLeave,
|
||||
leaveLabel: MailReaderTexts.back,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
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/downloaded_files.dart';
|
||||
import 'package:arasystem_app/core/files/file_types.dart';
|
||||
import 'package:arasystem_app/core/storage/token_store.dart';
|
||||
import 'package:arasystem_app/core/storage/token_store_provider.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:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
|
||||
/// 받고 있는 첨부 하나
|
||||
@immutable
|
||||
class AttachmentDownload {
|
||||
const AttachmentDownload({required this.received, this.total});
|
||||
|
||||
/// 받은 바이트
|
||||
final int received;
|
||||
|
||||
/// 전체 바이트 — 모르면 null
|
||||
final int? total;
|
||||
|
||||
/// 받은 비율(0 ~ 100) — 전체를 모르면 null
|
||||
int? get percent {
|
||||
final int? all = total;
|
||||
if (all == null || all <= 0) return null;
|
||||
return (received * 100 ~/ all).clamp(0, 100);
|
||||
}
|
||||
|
||||
/// 진행 막대의 값(0 ~ 1) — 전체를 모르면 null(돌기만 한다)
|
||||
double? get fraction {
|
||||
final int? value = percent;
|
||||
return value == null ? null : value / 100;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is AttachmentDownload &&
|
||||
other.received == received &&
|
||||
other.total == total;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(received, total);
|
||||
}
|
||||
|
||||
/// 이 세션에 받아 둔 첨부 — 같은 첨부를 다시 누르면 받지 않고 바로 연다. 메일을 보는 직원이 바뀌면 받은 파일을 모두 지운다.
|
||||
///
|
||||
/// 앱이 살아 있는 동안 둔다(받은 파일 기록). 지난 실행이 남긴 파일은 main 이 앱을 띄울 때 지운다(`DownloadedFileStore.purge`)
|
||||
final Provider<MailDownloadedFiles> mailDownloadedFilesProvider =
|
||||
Provider<MailDownloadedFiles>((Ref ref) {
|
||||
final MailDownloadedFiles files = MailDownloadedFiles(
|
||||
ref.watch(downloadedFileStoreProvider),
|
||||
);
|
||||
ref.listen<String?>(mailAccountProvider, (
|
||||
String? previous,
|
||||
String? next,
|
||||
) {
|
||||
if (previous != next) unawaited(files.forgetAll());
|
||||
});
|
||||
return files;
|
||||
});
|
||||
|
||||
/// 받아 둔 첨부의 기록 — (메일 id, 첨부 id) → 기기 안 경로
|
||||
class MailDownloadedFiles {
|
||||
MailDownloadedFiles(this.store);
|
||||
|
||||
final DownloadedFileStore store;
|
||||
final Map<String, String> _paths = <String, String>{};
|
||||
|
||||
static String _key(String messageId, String attachmentId) =>
|
||||
'$messageId\n$attachmentId';
|
||||
|
||||
String? pathOf(String messageId, String attachmentId) =>
|
||||
_paths[_key(messageId, attachmentId)];
|
||||
|
||||
void remember(String messageId, String attachmentId, String path) =>
|
||||
_paths[_key(messageId, attachmentId)] = path;
|
||||
|
||||
void forget(String messageId, String attachmentId) =>
|
||||
_paths.remove(_key(messageId, attachmentId));
|
||||
|
||||
/// 기록을 비우고 받은 파일을 모두 지운다 — 로그아웃 · 세션 끝 · 다른 직원
|
||||
Future<void> forgetAll() {
|
||||
_paths.clear();
|
||||
return store.purge();
|
||||
}
|
||||
}
|
||||
|
||||
/// 한 메일의 첨부 받기 — 메일 id 마다 하나, 읽기 화면이 있는 동안만(autoDispose — 떠나면 받던 것을 그만둔다)
|
||||
final NotifierProviderFamily<
|
||||
AttachmentDownloadsController,
|
||||
Map<String, AttachmentDownload>,
|
||||
String
|
||||
>
|
||||
attachmentDownloadsProvider = NotifierProvider.autoDispose
|
||||
.family<
|
||||
AttachmentDownloadsController,
|
||||
Map<String, AttachmentDownload>,
|
||||
String
|
||||
>(AttachmentDownloadsController.new);
|
||||
|
||||
/// 첨부 받기 — 계약 2절 `GET /api/messages/{id}/attachments/{attachmentId}` (보드 `App1Reader` 「첨부 받는 중」).
|
||||
///
|
||||
/// - [받기]를 누르면 앱 임시 폴더에 받고(진행 %) 다 받으면 다른 앱으로 연다(`FileOpener` — 안드로이드 `ACTION_VIEW`).
|
||||
/// 이 세션에 받아 둔 파일이 있으면 다시 받지 않고 연다. 열 형식은 확장자에서 먼저 정한다(`fileOpenTypeOf` — 보낸 쪽 형식은 확장자를 모를 때만).
|
||||
/// - 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 받지 않고 알린다(`isBlockedFileOpen`) — 다른 앱(브라우저)이 스크립트를 돌리지 않게.
|
||||
/// - [받기 취소]는 받던 것을 그만두고 쓰다 만 파일을 지운다. 화면을 떠나도 그만둔다.
|
||||
/// - 서버의 거절(없음 등)은 인터셉터가 알렸다. 받는 도중 끊기면 여기서 `SYS_001` 로 알린다(응답이 이미 인터셉터를 지났다).
|
||||
/// - 401 은 인증 인터셉터가 갱신한 뒤 다시 보낸다. 세션이 바뀌었으면 받은 파일을 지우고 열지 않는다.
|
||||
class AttachmentDownloadsController
|
||||
extends Notifier<Map<String, AttachmentDownload>> {
|
||||
AttachmentDownloadsController(this.messageId);
|
||||
|
||||
final String messageId;
|
||||
final Map<String, CancelToken> _cancels = <String, CancelToken>{};
|
||||
|
||||
@override
|
||||
Map<String, AttachmentDownload> build() {
|
||||
ref.watch(mailAccountProvider);
|
||||
_cancelAll();
|
||||
ref.onDispose(_cancelAll);
|
||||
return const <String, AttachmentDownload>{};
|
||||
}
|
||||
|
||||
void _cancelAll() {
|
||||
for (final CancelToken token in _cancels.values) {
|
||||
token.cancel();
|
||||
}
|
||||
_cancels.clear();
|
||||
}
|
||||
|
||||
/// [받기] — 받는 중이면 아무것도 하지 않는다. 앱에서 열지 않는 형식(`isBlockedFileOpen` — 웹 문서 · SVG · 설치 파일 · 인증서)은
|
||||
/// 받기 전에 알리고 받지 않는다
|
||||
Future<void> download(MessageAttachment attachment) async {
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted || state.containsKey(attachment.id)) return;
|
||||
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
|
||||
if (isBlockedFileOpen(attachment.name, attachment.type)) {
|
||||
notice(MailReaderTexts.blockedFileType);
|
||||
return;
|
||||
}
|
||||
final MailDownloadedFiles files = ref.read(mailDownloadedFilesProvider);
|
||||
final MailRepository repository = ref.read(mailRepositoryProvider);
|
||||
final FileOpener opener = ref.read(fileOpenerProvider);
|
||||
final ErrorNotifier errors = ref.read(errorNotifierProvider);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
|
||||
final String? saved = files.pathOf(messageId, attachment.id);
|
||||
if (saved != null) {
|
||||
if (await files.store.exists(saved)) {
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
await _open(
|
||||
saved,
|
||||
attachment,
|
||||
opener: opener,
|
||||
files: files,
|
||||
notice: notice,
|
||||
);
|
||||
return;
|
||||
}
|
||||
files.forget(messageId, attachment.id);
|
||||
}
|
||||
|
||||
final CancelToken cancel = CancelToken();
|
||||
_cancels[attachment.id] = cancel;
|
||||
_setProgress(attachment.id, 0, _sizeOf(attachment));
|
||||
DownloadTarget? target;
|
||||
try {
|
||||
target = await files.store.create(attachment.name);
|
||||
await repository.downloadAttachment(
|
||||
messageId: messageId,
|
||||
attachmentId: attachment.id,
|
||||
target: target,
|
||||
cancelToken: cancel,
|
||||
onProgress: (int received, int? total) {
|
||||
if (ref.mounted && !cancel.isCancelled) {
|
||||
_setProgress(attachment.id, received, total ?? _sizeOf(attachment));
|
||||
}
|
||||
},
|
||||
);
|
||||
await target.close();
|
||||
} on DioException catch (error) {
|
||||
await target?.discard();
|
||||
_finish(attachment.id, cancel);
|
||||
if (error.type == DioExceptionType.cancel) return;
|
||||
// 받는 도중 끊겼다 — 응답은 인터셉터를 지나 아무도 알리지 않았다
|
||||
if (error is DownloadInterrupted && tokens.sessionGeneration == session) {
|
||||
errors.show(ApiException.fromCode('SYS_001'));
|
||||
}
|
||||
return;
|
||||
} on FileSystemException {
|
||||
await target?.discard();
|
||||
_finish(attachment.id, cancel);
|
||||
if (tokens.sessionGeneration == session) {
|
||||
notice(MailReaderTexts.saveFailed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_finish(attachment.id, cancel);
|
||||
// 그사이 세션이 끝났거나 다른 직원이 들어왔다 — 받은 파일을 남기지 않는다
|
||||
if (tokens.sessionGeneration != session) {
|
||||
await target.discard();
|
||||
return;
|
||||
}
|
||||
files.remember(messageId, attachment.id, target.path);
|
||||
// 화면을 떠났으면 열지 않는다 — 받은 파일은 다음에 누르면 바로 연다
|
||||
if (!ref.mounted || cancel.isCancelled) return;
|
||||
await _open(
|
||||
target.path,
|
||||
attachment,
|
||||
opener: opener,
|
||||
files: files,
|
||||
notice: notice,
|
||||
);
|
||||
}
|
||||
|
||||
/// [받기 취소]
|
||||
void cancel(String attachmentId) {
|
||||
if (!ref.mounted) return;
|
||||
_cancels.remove(attachmentId)?.cancel();
|
||||
if (!state.containsKey(attachmentId)) return;
|
||||
state = <String, AttachmentDownload>{...state}..remove(attachmentId);
|
||||
}
|
||||
|
||||
Future<void> _open(
|
||||
String path,
|
||||
MessageAttachment attachment, {
|
||||
required FileOpener opener,
|
||||
required MailDownloadedFiles files,
|
||||
required void Function(String text) notice,
|
||||
}) async {
|
||||
// 열 형식은 확장자에서 먼저 — 보낸 쪽이 정한 형식은 확장자를 모를 때만(`fileOpenTypeOf`)
|
||||
final FileOpenResult result = await opener.open(
|
||||
path: path,
|
||||
mimeType: fileOpenTypeOf(attachment.name, attachment.type),
|
||||
);
|
||||
switch (result) {
|
||||
case FileOpenResult.opened:
|
||||
return;
|
||||
case FileOpenResult.noApp:
|
||||
notice(MailReaderTexts.noAppToOpen);
|
||||
case FileOpenResult.notFound:
|
||||
case FileOpenResult.failed:
|
||||
files.forget(messageId, attachment.id);
|
||||
notice(MailReaderTexts.openFailed);
|
||||
}
|
||||
}
|
||||
|
||||
int? _sizeOf(MessageAttachment attachment) =>
|
||||
attachment.size > 0 ? attachment.size : null;
|
||||
|
||||
/// 진행을 적는다 — 보이는 비율이 바뀔 때만(조각마다 화면을 다시 그리지 않게)
|
||||
void _setProgress(String attachmentId, int received, int? total) {
|
||||
final AttachmentDownload next = AttachmentDownload(
|
||||
received: received,
|
||||
total: total,
|
||||
);
|
||||
final AttachmentDownload? current = state[attachmentId];
|
||||
if (current != null &&
|
||||
current.total == next.total &&
|
||||
current.percent == next.percent &&
|
||||
next.percent != null) {
|
||||
return;
|
||||
}
|
||||
state = <String, AttachmentDownload>{...state, attachmentId: next};
|
||||
}
|
||||
|
||||
/// 받기 하나가 끝났다 — 그 받기([cancel])가 아직 이 첨부의 받기일 때만 진행을 지운다.
|
||||
/// [받기 취소] 바로 뒤에 다시 [받기]를 눌렀으면 앞 받기의 끝(취소)이 새 받기의 진행을 지우지 않는다
|
||||
void _finish(String attachmentId, CancelToken cancel) {
|
||||
if (!identical(_cancels[attachmentId], cancel)) return;
|
||||
_cancels.remove(attachmentId);
|
||||
if (!ref.mounted || !state.containsKey(attachmentId)) return;
|
||||
state = <String, AttachmentDownload>{...state}..remove(attachmentId);
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,7 @@ class MailListController extends Notifier<MailListState> {
|
||||
|
||||
void _onMailChange(MailChange change) {
|
||||
if (identical(change.source, this)) return;
|
||||
_applyLocalChanges(change);
|
||||
if (_visible) {
|
||||
unawaited(_sync());
|
||||
} else {
|
||||
@@ -324,6 +325,44 @@ class MailListController extends Notifier<MailListState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 읽기 화면이 바꾼 메일을 서버에 다시 묻기 전에 바로 바꾼다 — 읽음 · 중요는 그 줄을 바꾸고,
|
||||
/// 이 메일함을 떠난 메일(옮기기 · 스팸 · 휴지통)과 뺀 메일(영구 삭제 · 발송 취소)은 뺀다. 그 전에 떠난 맞추기 응답은 버린다
|
||||
void _applyLocalChanges(MailChange change) {
|
||||
if (!change.hasLocalChanges || state.status != MailListStatus.ready) return;
|
||||
final String? mailboxId = state.mailboxId;
|
||||
final Map<String, MessageSummary> updated = <String, MessageSummary>{
|
||||
for (final MessageSummary message in change.updated) message.id: message,
|
||||
};
|
||||
bool changed = false;
|
||||
final List<MessageSummary> kept = <MessageSummary>[];
|
||||
for (final MessageSummary message in state.items) {
|
||||
if (change.removedIds.contains(message.id)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
final MessageSummary? next = updated[message.id];
|
||||
if (next == null) {
|
||||
kept.add(message);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
if (mailboxId != null && !next.mailboxIds.contains(mailboxId)) continue;
|
||||
kept.add(next);
|
||||
}
|
||||
if (!changed) return;
|
||||
final int removed = state.items.length - kept.length;
|
||||
final MailPageInfo? info = state.pageInfo;
|
||||
final int? total = info?.total;
|
||||
_localGeneration++;
|
||||
state = state.withItems(
|
||||
_withPendingFlags(kept),
|
||||
pageInfo: info != null && total != null && removed > 0
|
||||
? info.copyWith(total: math.max(0, total - removed))
|
||||
: info,
|
||||
);
|
||||
_afterItemsChanged();
|
||||
}
|
||||
|
||||
/// 보이는 목록을 지금까지 불러온 만큼(최대 100통) 다시 받아 맞춘다 — 고른 메일 · 스크롤 위치는 그대로(계약 2절 "새 메일 확인").
|
||||
/// 한 번에 하나 — 도는 동안 또 부르면 끝난 뒤 한 번 더 맞추고, 돌려주는 Future 는 그것까지 끝나야 끝난다.
|
||||
/// 실패는 알리지 않는다(다음 확인에서 다시 맞춘다)
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/core/utils/app_foreground.dart';
|
||||
import 'package:arasystem_app/core/utils/clock.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -17,7 +18,12 @@ const Duration kMailStateRecheckGap = Duration(seconds: 10);
|
||||
/// 메일이 바뀌었다는 소식 — 목록 · 메일함 개수가 듣는다
|
||||
@immutable
|
||||
class MailChange {
|
||||
const MailChange(this.seq, {this.source});
|
||||
const MailChange(
|
||||
this.seq, {
|
||||
this.source,
|
||||
this.updated = const <MessageSummary>[],
|
||||
this.removedIds = const <String>{},
|
||||
});
|
||||
|
||||
/// 소식마다 하나씩 오른다 — 처음부터 다시 만들면 0
|
||||
final int seq;
|
||||
@@ -25,14 +31,31 @@ class MailChange {
|
||||
/// 이 앱의 목록이 스스로 바꾼 것이면 그 목록(그 목록은 이미 맞췄다) — 서버에서 알아챈 바뀜 · 되돌리기는 null
|
||||
final Object? source;
|
||||
|
||||
/// 읽기 화면이 바꾼 메일의 요약(`PATCH /api/messages/{id}` 의 응답에서 그 요청이 바꾼 칸만 읽기 화면의 요약에 덮은 것) — 목록은 서버에 다시 묻기 전에 그 줄을 바로 바꾼다
|
||||
/// (그 메일함을 떠났으면 뺀다). 맞추기는 그대로 한다
|
||||
final List<MessageSummary> updated;
|
||||
|
||||
/// 읽기 화면이 영구 삭제 · 발송 취소로 뺀 메일 — 목록은 바로 뺀다
|
||||
final Set<String> removedIds;
|
||||
|
||||
/// 목록이 바로 바꿀 것이 있다
|
||||
bool get hasLocalChanges => updated.isNotEmpty || removedIds.isNotEmpty;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MailChange &&
|
||||
other.seq == seq &&
|
||||
identical(other.source, source);
|
||||
identical(other.source, source) &&
|
||||
listEquals(other.updated, updated) &&
|
||||
setEquals(other.removedIds, removedIds);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(seq, identityHashCode(source));
|
||||
int get hashCode => Object.hash(
|
||||
seq,
|
||||
identityHashCode(source),
|
||||
Object.hashAll(updated),
|
||||
Object.hashAllUnordered(removedIds),
|
||||
);
|
||||
}
|
||||
|
||||
/// 새 메일 확인 — 메일 목록이 보이는 동안만 돈다. 세션이 바뀌면 기준을 버린다.
|
||||
@@ -104,10 +127,20 @@ class MailStateWatcher extends Notifier<MailChange> {
|
||||
if (before != _active) _onActiveChanged();
|
||||
}
|
||||
|
||||
/// 이 앱이 메일을 바꿨다 — 다른 목록 · 메일함 개수가 맞추게 알린다. [source] 는 이미 맞춘 목록(없으면 모든 목록)
|
||||
void markChanged({Object? source}) {
|
||||
/// 이 앱이 메일을 바꿨다 — 다른 목록 · 메일함 개수가 맞추게 알린다. [source] 는 이미 맞춘 목록(없으면 모든 목록).
|
||||
/// 읽기 화면은 바꾼 메일의 요약([updated]) · 뺀 메일([removedIds])을 실어 목록이 바로 바꾸게 한다
|
||||
void markChanged({
|
||||
Object? source,
|
||||
List<MessageSummary> updated = const <MessageSummary>[],
|
||||
Set<String> removedIds = const <String>{},
|
||||
}) {
|
||||
if (!ref.mounted) return;
|
||||
state = MailChange(state.seq + 1, source: source);
|
||||
state = MailChange(
|
||||
state.seq + 1,
|
||||
source: source,
|
||||
updated: List<MessageSummary>.unmodifiable(updated),
|
||||
removedIds: Set<String>.unmodifiable(removedIds),
|
||||
);
|
||||
}
|
||||
|
||||
void _onActiveChanged() {
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:arasystem_app/core/api/api_error_mapper.dart';
|
||||
import 'package:arasystem_app/core/api/api_exception.dart';
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/core/storage/token_store.dart';
|
||||
import 'package:arasystem_app/core/storage/token_store_provider.dart';
|
||||
import 'package:arasystem_app/core/utils/clock.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
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/mail_repository.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_html.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_state.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
|
||||
/// 본문 조각을 만드는 일 — 정화([mailBodyFragment]). 시험은 덮어쓸 수 있다
|
||||
typedef MailBodyBuilder =
|
||||
Future<SanitizedMailBody> Function({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
required bool imagesShown,
|
||||
});
|
||||
|
||||
/// 본문 조각 만들기 — 짧고 얕은 본문은 그 자리에서, 길거나 깊은 본문은 [Isolate.run] 으로([mailBodyBuildPlaceOf])
|
||||
final Provider<MailBodyBuilder> mailBodyBuilderProvider =
|
||||
Provider<MailBodyBuilder>((Ref ref) => buildMailBody);
|
||||
|
||||
/// [mailBodyBuilderProvider] 의 기본 — 2MB 까지 오는 본문 · 짧아도 깊은 본문을 정화하는 동안 화면이 멈추지 않게 곳을 고른다.
|
||||
/// 처음 받은 본문과 [이미지 보기]로 다시 받은 본문이 같은 길을 간다
|
||||
Future<SanitizedMailBody> buildMailBody({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
required bool imagesShown,
|
||||
}) async {
|
||||
SanitizedMailBody build() => mailBodyFragment(
|
||||
htmlBody: htmlBody,
|
||||
textBody: textBody,
|
||||
imagesShown: imagesShown,
|
||||
);
|
||||
final MailBodyBuildPlace place = mailBodyBuildPlaceOf(
|
||||
htmlBody: htmlBody,
|
||||
textBody: textBody,
|
||||
);
|
||||
if (place == MailBodyBuildPlace.inline) return build();
|
||||
return Isolate.run(build);
|
||||
}
|
||||
|
||||
/// 메일 한 통의 읽기 화면 — 주소의 메일 id 마다 하나. 화면이 있는 동안만 둔다(autoDispose) — 다른 메일을 열면 앞 응답은 버린다.
|
||||
final NotifierProviderFamily<
|
||||
MessageReaderController,
|
||||
MessageReaderState,
|
||||
String
|
||||
>
|
||||
messageReaderProvider = NotifierProvider.autoDispose
|
||||
.family<MessageReaderController, MessageReaderState, String>(
|
||||
MessageReaderController.new,
|
||||
);
|
||||
|
||||
/// 읽기 화면 — 계약 2절 `GET /api/messages/{id}` · 읽음 · 중요 · 옮기기 · 스팸 · 삭제 · 9단계 "보내기 취소 · 예약 발송" · "외부 이미지 보기"
|
||||
/// (웹 `stores/mail/message.ts` · `useMessageRemoval` · `useScheduledMessageActions` 와 같은 규칙)
|
||||
///
|
||||
/// - 열면 안 읽은 메일을 읽음으로 바꾼다(화면 · 메일함 개수를 먼저 — 실패하면 알리지 않고 되돌린다).
|
||||
/// - 본문은 두 번째 정화 뒤 CSP 를 건 문서로 만든다(`mail_html.dart` — 짧고 얕은 본문만 그 자리에서, 길거나 깊은 본문은 다른 isolate 에서).
|
||||
/// 외부 이미지를 보이는 본문이면(설정 · [이미지 보기]) 중계 그림을 Dio 로 받아(한 번에 [kMailImageConcurrency] 개 · 한도 `mail_images.dart`)
|
||||
/// `data:` 로 넣고 문서를 한 번 더 만든다. 그림 받기는 화면이 사라지거나 본문이 바뀌면 끊는다(끊긴 그림은 숨기고 알리지 않는다).
|
||||
/// - 옮기기 · 스팸 · 휴지통은 응답을 받은 뒤 목록으로 돌아가고(부르는 쪽이), 알림에 [되돌리기](원래 메일함 — 스팸은 표시도 반대로).
|
||||
/// 휴지통에만 든 메일은 확인 뒤 영구 삭제(`POST /api/message-deletions`) — `DELETE` 는 쓰지 않는다(웹과 같다).
|
||||
/// - 붙잡힌 메일(보내기 취소를 기다림 · 예약)은 옮기거나 스팸으로 보내지 않는다 — [보내기 취소] · [예약 취소]는 임시보관함으로,
|
||||
/// [삭제]는 확인 뒤 취소하고 휴지통으로. 보낼 시각 + 2초에 다시 보고(서버 시계) 풀렸으면 안내 줄을 지운다.
|
||||
/// - 늦은 응답: 받기 세대([_seq]) · 본문 세대([_bodySeq])가 바뀌었거나 화면이 사라졌으면 버린다.
|
||||
/// 세션이 바뀌었으면(토큰 보관의 세션 세대) 알림 · [되돌리기] · 목록 알리기를 하지 않는다.
|
||||
/// - 응답은 그 요청이 바꾼 칸(읽음 · 중요 · 메일함)만 지금 요약에 덮는다 — 그사이 다른 요청이 바꾼 칸 · 다시 받은 본문을 되돌리지 않게.
|
||||
/// - 목록 · 메일함 개수는 새 메일 확인의 소식([MailStateWatcher.markChanged] — 그렇게 맞춘 요약을 싣는다)으로 맞춘다.
|
||||
class MessageReaderController extends Notifier<MessageReaderState> {
|
||||
MessageReaderController(this.messageId);
|
||||
|
||||
/// 주소의 메일 id
|
||||
final String messageId;
|
||||
|
||||
int _seq = 0;
|
||||
int _bodySeq = 0;
|
||||
SanitizedMailBody? _body;
|
||||
Map<String, String> _images = const <String, String>{};
|
||||
|
||||
/// 지금 본문의 중계 그림 받기 — 화면이 사라지거나 본문이 바뀌면(다시 받기 · [이미지 보기]) 끊는다
|
||||
CancelToken? _imagesCancel;
|
||||
|
||||
/// 서버 시계 − 기기 시계 — 받은 응답의 `Date` 로 잰다(붙잡아 둔 발송이 풀릴 때를 서버 시계로 잰다)
|
||||
Duration _serverOffset = Duration.zero;
|
||||
Timer? _heldTimer;
|
||||
int _heldRechecks = 0;
|
||||
|
||||
/// 연 메일의 읽음 처리 — [setRead] 가 먼저 끝나기를 기다린다(뒤에 보낸 것이 이기게)
|
||||
Future<void>? _reading;
|
||||
bool _flagging = false;
|
||||
|
||||
MailRepository get _repository => ref.read(mailRepositoryProvider);
|
||||
|
||||
Clock get _clock => ref.read(clockProvider);
|
||||
|
||||
@override
|
||||
MessageReaderState build() {
|
||||
final String? account = ref.watch(mailAccountProvider);
|
||||
_seq++;
|
||||
_bodySeq++;
|
||||
_body = null;
|
||||
_images = const <String, String>{};
|
||||
_serverOffset = Duration.zero;
|
||||
_heldRechecks = 0;
|
||||
_reading = null;
|
||||
_flagging = false;
|
||||
_cancelHeldTimer();
|
||||
_cancelImages();
|
||||
ref
|
||||
..onDispose(_cancelHeldTimer)
|
||||
..onDispose(_cancelImages);
|
||||
// [되돌리기] 알림이 이 화면에서 목록으로 돌아가는 동안 사라지지 않게 쥔다(목록 화면이 이어 쥔다)
|
||||
ref.listen<MailToast?>(mailToastProvider, (MailToast? _, MailToast? _) {});
|
||||
// 세션이 없으면(로그아웃하며 화면이 사라지는 중) 묻지 않는다
|
||||
if (account == null) return const MessageReaderState();
|
||||
unawaited(_load(_seq));
|
||||
return const MessageReaderState();
|
||||
}
|
||||
|
||||
// ── 받기 ─────────────────────────────────────────
|
||||
|
||||
Future<void> _load(int seq) async {
|
||||
final Ref ref = this.ref;
|
||||
try {
|
||||
final ({MessageDetail detail, DateTime? serverTime}) result =
|
||||
await _repository.getMessage(messageId);
|
||||
if (!ref.mounted || seq != _seq) return;
|
||||
_noteServerTime(result.serverTime);
|
||||
state = MessageReaderState(
|
||||
status: MessageReaderStatus.ready,
|
||||
detail: result.detail,
|
||||
);
|
||||
_scheduleHeldCheck();
|
||||
unawaited(_buildBody(result.detail));
|
||||
if (!result.detail.summary.isRead) _reading = _markReadOnOpen();
|
||||
} on DioException catch (error) {
|
||||
if (!ref.mounted || seq != _seq) return;
|
||||
state = MessageReaderState(
|
||||
status: MessageReaderStatus.failed,
|
||||
error: toApiException(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [다시 시도]
|
||||
Future<void> retry() async {
|
||||
if (!ref.mounted) return;
|
||||
_seq++;
|
||||
_bodySeq++;
|
||||
_cancelImages();
|
||||
state = const MessageReaderState();
|
||||
await _load(_seq);
|
||||
}
|
||||
|
||||
void _noteServerTime(DateTime? serverTime) {
|
||||
if (serverTime != null) _serverOffset = serverTime.difference(_clock());
|
||||
}
|
||||
|
||||
DateTime _serverNow() => _clock().add(_serverOffset);
|
||||
|
||||
void _setSummary(MessageSummary Function(MessageSummary current) change) {
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (detail == null) return;
|
||||
state = state.copyWith(detail: detail.withSummary(change(detail.summary)));
|
||||
}
|
||||
|
||||
/// 응답([response])에서 그 요청이 바꾼 칸([changes])만 **지금** 요약에 덮는다 — 그사이 다른 요청이 바꾼 칸(중요 · 읽음)과
|
||||
/// 다시 받은 본문([이미지 보기])을 응답의 옛 값 · 요청 전에 쥔 값으로 되돌리지 않게. 목록에 알릴 요약을 돌려준다(화면이 없으면 응답 그대로)
|
||||
MessageSummary _applyResponse(
|
||||
MessageChanges changes,
|
||||
MessageSummary response,
|
||||
) {
|
||||
final MessageDetail? detail = ref.mounted ? state.detail : null;
|
||||
if (detail == null) return response;
|
||||
final bool moved = changes.mailboxId != null || changes.isJunk != null;
|
||||
final MessageSummary merged = detail.summary.copyWith(
|
||||
isRead: changes.isRead == null ? null : response.isRead,
|
||||
isFlagged: changes.isFlagged == null ? null : response.isFlagged,
|
||||
mailboxIds: moved ? response.mailboxIds : null,
|
||||
);
|
||||
state = state.copyWith(detail: detail.withSummary(merged));
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── 본문 ─────────────────────────────────────────
|
||||
|
||||
/// 본문 문서를 만든다 — 정화한 문서를 먼저 보이고, 중계 그림이 있으면 받아 넣은 문서로 한 번 더 바꾼다
|
||||
Future<void> _buildBody(MessageDetail detail) async {
|
||||
final Ref ref = this.ref;
|
||||
final int bodySeq = ++_bodySeq;
|
||||
// 앞 본문의 그림 받기를 끊는다 — 그 문서는 이제 쓰지 않는다
|
||||
_cancelImages();
|
||||
bool alive() => ref.mounted && bodySeq == _bodySeq;
|
||||
final MailBodyBuilder buildBody = ref.read(mailBodyBuilderProvider);
|
||||
SanitizedMailBody body;
|
||||
try {
|
||||
body = await buildBody(
|
||||
htmlBody: detail.htmlBody,
|
||||
textBody: detail.textBody,
|
||||
imagesShown: detail.imagesShown,
|
||||
);
|
||||
} on Object {
|
||||
// 정화가 뜻밖에 실패하면 서식 없이 글자 본문만 보인다
|
||||
body = mailBodyFragment(
|
||||
htmlBody: null,
|
||||
textBody: detail.textBody,
|
||||
imagesShown: false,
|
||||
);
|
||||
}
|
||||
if (!alive()) return;
|
||||
_body = body;
|
||||
_images = const <String, String>{};
|
||||
final bool hasImages = body.proxiedTokens.isNotEmpty;
|
||||
state = state.copyWith(
|
||||
document: _document(),
|
||||
bodyOmitted: body.htmlOmitted,
|
||||
imagesLoading: hasImages,
|
||||
);
|
||||
if (!hasImages) return;
|
||||
final CancelToken cancel = _imagesCancel = CancelToken();
|
||||
final Map<String, String> loaded = await _fetchImages(
|
||||
body.proxiedTokens,
|
||||
alive,
|
||||
cancel,
|
||||
);
|
||||
if (!alive()) return;
|
||||
_images = loaded;
|
||||
state = state.copyWith(document: _document(), imagesLoading: false);
|
||||
}
|
||||
|
||||
/// WebView 에 넣을 문서 — 중계 그림은 받은 것만, 문서에 싣는 그림 글은 나온 횟수까지 세어 한도 안에서([resolveProxiedImages])
|
||||
String? _document() {
|
||||
final SanitizedMailBody? body = _body;
|
||||
if (body == null) return null;
|
||||
return buildMailDocument(
|
||||
body.proxiedTokens.isEmpty
|
||||
? body.fragment
|
||||
: resolveProxiedImages(body.fragment, _images),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancelImages() {
|
||||
_imagesCancel?.cancel();
|
||||
_imagesCancel = null;
|
||||
}
|
||||
|
||||
/// 중계 그림을 받는다 — 한 번에 [kMailImageConcurrency] 개, 그림 하나 · 합계 한도를 넘거나 그림이 아니면 그 자리를 비운다.
|
||||
/// 실패 · 끊김([cancel] — 화면을 떠남 · 본문이 바뀜)은 알리지 않는다(웹도 받지 못한 그림 자리는 비어 있다)
|
||||
Future<Map<String, String>> _fetchImages(
|
||||
List<String> tokens,
|
||||
bool Function() alive,
|
||||
CancelToken cancel,
|
||||
) async {
|
||||
final MailRepository repository = _repository;
|
||||
final Map<String, String> loaded = <String, String>{};
|
||||
int used = 0;
|
||||
int next = 0;
|
||||
Future<void> worker() async {
|
||||
while (next < tokens.length &&
|
||||
alive() &&
|
||||
!cancel.isCancelled &&
|
||||
used < kMailImagesTotalMaxBytes) {
|
||||
final String token = tokens[next++];
|
||||
try {
|
||||
final Uint8List bytes = await repository.fetchMailImage(
|
||||
token,
|
||||
maxBytes: kMailImageMaxBytes,
|
||||
cancelToken: cancel,
|
||||
);
|
||||
if (!alive()) return;
|
||||
final String? data = acceptMailImage(bytes, usedBytes: used);
|
||||
if (data == null) continue;
|
||||
loaded[token] = data;
|
||||
used += bytes.length;
|
||||
} on DioException {
|
||||
// 그 자리를 비운다
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Future.wait(<Future<void>>[
|
||||
for (
|
||||
int index = 0;
|
||||
index < math.min(kMailImageConcurrency, tokens.length);
|
||||
index++
|
||||
)
|
||||
worker(),
|
||||
]);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/// [이미지 보기] — 이 메일을 `images=show` 로 다시 받아 본문만 바꾼다(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹과 같다).
|
||||
/// 다시 열면 설정대로다. 실패는 인터셉터가 알리고 안내 줄은 그대로 남는다
|
||||
Future<void> showImages() async {
|
||||
final Ref ref = this.ref;
|
||||
final MessageDetail? current = state.detail;
|
||||
if (!ref.mounted ||
|
||||
current == null ||
|
||||
current.imagesShown ||
|
||||
state.imagesLoading) {
|
||||
return;
|
||||
}
|
||||
final int seq = _seq;
|
||||
state = state.copyWith(imagesLoading: true);
|
||||
try {
|
||||
final ({MessageDetail detail, DateTime? serverTime}) result =
|
||||
await _repository.getMessage(
|
||||
messageId,
|
||||
showImages: true,
|
||||
silent: false,
|
||||
);
|
||||
if (!ref.mounted || seq != _seq) return;
|
||||
final MessageDetail next = (state.detail ?? current).withBodyOf(
|
||||
result.detail,
|
||||
);
|
||||
state = state.copyWith(detail: next);
|
||||
await _buildBody(next);
|
||||
} on DioException {
|
||||
if (!ref.mounted || seq != _seq) return;
|
||||
state = state.copyWith(imagesLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 읽음 · 중요 ─────────────────────────────────────
|
||||
|
||||
/// 연 메일의 읽음 처리 — 화면 · 메일함 개수를 먼저 바꾸고, 실패하면 알리지 않고 되돌린다
|
||||
Future<void> _markReadOnOpen() async {
|
||||
final Ref ref = this.ref;
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (detail == null || detail.summary.isRead) return;
|
||||
final MailRepository repository = _repository;
|
||||
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
final List<String> boxes = detail.summary.mailboxIds;
|
||||
_setSummary((MessageSummary s) => s.copyWith(isRead: true));
|
||||
mailboxes.adjustUnread(boxes, -1);
|
||||
const MessageChanges changes = MessageChanges(isRead: true);
|
||||
try {
|
||||
final MessageSummary summary = await repository.updateMessage(
|
||||
messageId,
|
||||
changes,
|
||||
silent: true,
|
||||
);
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
watcher.markChanged(
|
||||
updated: <MessageSummary>[_applyResponse(changes, summary)],
|
||||
);
|
||||
} on DioException {
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
mailboxes.adjustUnread(boxes, 1);
|
||||
if (ref.mounted) {
|
||||
_setSummary((MessageSummary s) => s.copyWith(isRead: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [안 읽음 표시] · [읽음 표시] — 응답을 받은 뒤 화면을 바꾼다. 성공하면 참([안 읽음 표시]면 부르는 쪽이 목록으로 돌아간다)
|
||||
Future<bool> setRead(bool read) async {
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted || state.busy != null || state.detail == null) {
|
||||
return false;
|
||||
}
|
||||
final Future<void>? reading = _reading;
|
||||
if (reading != null) await reading;
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (!ref.mounted || detail == null || state.busy != null) return false;
|
||||
if (detail.summary.isRead == read) return true;
|
||||
final MailRepository repository = _repository;
|
||||
final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier);
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
final MessageChanges changes = MessageChanges(isRead: read);
|
||||
state = state.copyWith(busy: MessageReaderBusy.marking);
|
||||
try {
|
||||
final MessageSummary summary = await repository.updateMessage(
|
||||
messageId,
|
||||
changes,
|
||||
);
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
mailboxes.adjustUnread(detail.summary.mailboxIds, read ? -1 : 1);
|
||||
// 요청 전에 쥔 메일 정보(detail)로 되쓰지 않는다 — 그사이 [이미지 보기]가 본문을 바꿨을 수 있다
|
||||
watcher.markChanged(
|
||||
updated: <MessageSummary>[_applyResponse(changes, summary)],
|
||||
);
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return true;
|
||||
} on DioException {
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// [중요 표시] · [중요 해제] — 화면을 먼저 바꾸고 실패하면 되돌린다. 바꾸면 알림(읽기 화면에는 별이 없다)
|
||||
Future<void> toggleFlag() async {
|
||||
final Ref ref = this.ref;
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (!ref.mounted || detail == null || _flagging) return;
|
||||
final bool next = !detail.summary.isFlagged;
|
||||
final MailRepository repository = _repository;
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
_flagging = true;
|
||||
_setSummary((MessageSummary s) => s.copyWith(isFlagged: next));
|
||||
final MessageChanges changes = MessageChanges(isFlagged: next);
|
||||
try {
|
||||
final MessageSummary summary = await repository.updateMessage(
|
||||
messageId,
|
||||
changes,
|
||||
);
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
watcher.markChanged(
|
||||
updated: <MessageSummary>[_applyResponse(changes, summary)],
|
||||
);
|
||||
notice(next ? MailReaderTexts.flagged : MailReaderTexts.unflagged);
|
||||
} on DioException {
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
if (ref.mounted) {
|
||||
_setSummary((MessageSummary s) => s.copyWith(isFlagged: !next));
|
||||
}
|
||||
} finally {
|
||||
_flagging = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 옮기기 · 스팸 · 삭제 ─────────────────────────────────
|
||||
|
||||
/// 휴지통으로 — 성공하면 참(목록으로 돌아간다). 휴지통을 모르면 메일함 목록을 받아 보고, 그래도 모르면 알린다
|
||||
Future<bool> moveToTrash() async {
|
||||
final Mailbox? trash = await _mailboxOfRole(MailboxRole.trash);
|
||||
if (trash == null) return false;
|
||||
return _move(trash, text: MailBulkTexts.moved(1, trash.name));
|
||||
}
|
||||
|
||||
/// [이동] — 고른 메일함으로
|
||||
Future<bool> moveToMailbox(Mailbox target) =>
|
||||
_move(target, text: MailBulkTexts.moved(1, target.name));
|
||||
|
||||
/// [스팸 신고](스팸메일함으로 + 스팸 표시) · [스팸 아님](받은편지함으로 + 스팸 아님 표시) — 그 메일의 메일함으로 정한다
|
||||
Future<bool> toggleSpam() async {
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (detail == null) return false;
|
||||
final Mailbox? junk = await _mailboxOfRole(MailboxRole.junk);
|
||||
if (junk == null) return false;
|
||||
final bool inJunk = detail.summary.mailboxIds.contains(junk.id);
|
||||
if (!inJunk) {
|
||||
return _move(junk, isJunk: true, text: MailBulkTexts.moved(1, junk.name));
|
||||
}
|
||||
final Mailbox? inbox = await _mailboxOfRole(MailboxRole.inbox);
|
||||
if (inbox == null) return false;
|
||||
return _move(
|
||||
inbox,
|
||||
isJunk: false,
|
||||
text: MailBulkTexts.notSpamMoved(1, inbox.name),
|
||||
);
|
||||
}
|
||||
|
||||
/// 역할의 메일함 — 모르면 메일함 목록을 다시 받아 본다. 그래도 모르면 알리고 null (옮기지 않는다)
|
||||
Future<Mailbox?> _mailboxOfRole(MailboxRole role) async {
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted) return null;
|
||||
Mailbox? mailbox = ref.read(mailboxesProvider).byRole(role);
|
||||
if (mailbox != null) return mailbox;
|
||||
await ref.read(mailboxesProvider.notifier).reload();
|
||||
if (!ref.mounted) return null;
|
||||
final MailboxesState mailboxes = ref.read(mailboxesProvider);
|
||||
mailbox = mailboxes.byRole(role);
|
||||
if (mailbox == null) {
|
||||
ref
|
||||
.read(errorNotifierProvider)
|
||||
.show(mailboxes.failure ?? ApiException.fromCode('BIZ_001'));
|
||||
}
|
||||
return mailbox;
|
||||
}
|
||||
|
||||
/// [target] 으로 옮긴다 — 응답을 받으면 목록에 알리고 알림에 [되돌리기](원래 메일함 · 스팸 표시는 반대로)
|
||||
Future<bool> _move(
|
||||
Mailbox target, {
|
||||
required String text,
|
||||
bool? isJunk,
|
||||
}) async {
|
||||
final Ref ref = this.ref;
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (!ref.mounted ||
|
||||
detail == null ||
|
||||
state.busy != null ||
|
||||
detail.summary.isHeld) {
|
||||
return false;
|
||||
}
|
||||
final MailRepository repository = _repository;
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final MailToastNotifier toast = ref.read(mailToastProvider.notifier);
|
||||
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
final String? origin = _originOf(detail.summary, target.id);
|
||||
final MessageChanges changes = MessageChanges(
|
||||
mailboxId: target.id,
|
||||
isJunk: isJunk,
|
||||
);
|
||||
state = state.copyWith(busy: MessageReaderBusy.removing);
|
||||
try {
|
||||
final MessageSummary summary = await repository.updateMessage(
|
||||
messageId,
|
||||
changes,
|
||||
);
|
||||
// 끝난 세션의 결과는 새 세션에 알리지 않는다 — [되돌리기]도 없다
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
watcher.markChanged(
|
||||
updated: <MessageSummary>[_applyResponse(changes, summary)],
|
||||
);
|
||||
if (origin == null) {
|
||||
notice(text);
|
||||
} else {
|
||||
toast.offer(
|
||||
text,
|
||||
actionLabel: MailBulkTexts.undo,
|
||||
onAction: () => _restoreMessage(
|
||||
messageId,
|
||||
mailboxId: origin,
|
||||
isJunk: isJunk == null ? null : !isJunk,
|
||||
repository: repository,
|
||||
watcher: watcher,
|
||||
notice: notice,
|
||||
tokens: tokens,
|
||||
session: session,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return true;
|
||||
} on DioException {
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 되돌릴 메일함 — 옮길 곳이 아닌 첫 메일함
|
||||
String? _originOf(MessageSummary summary, String targetId) {
|
||||
for (final String id in summary.mailboxIds) {
|
||||
if (id != targetId) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 영구 삭제 — 확인 창에서 [영구 삭제]를 누른 뒤에만 부른다. 지웠으면 참(목록으로 돌아간다).
|
||||
/// 서버가 그대로 두었으면(그사이 휴지통을 떠남) 알리고 메일 정보를 다시 받는다
|
||||
Future<bool> purge() async {
|
||||
final Ref ref = this.ref;
|
||||
final MessageDetail? detail = state.detail;
|
||||
if (!ref.mounted || detail == null || state.busy != null) return false;
|
||||
final MailRepository repository = _repository;
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
state = state.copyWith(busy: MessageReaderBusy.removing);
|
||||
try {
|
||||
final MessageDeletions result = await repository.deleteMessages(<String>[
|
||||
messageId,
|
||||
]);
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
final bool deleted = result.deletedIds.contains(messageId);
|
||||
if (deleted) {
|
||||
watcher.markChanged(removedIds: <String>{messageId});
|
||||
notice(MailBulkTexts.purged(1));
|
||||
} else {
|
||||
watcher.markChanged();
|
||||
notice(MailBulkTexts.purged(0, skipped: 1));
|
||||
}
|
||||
if (ref.mounted) {
|
||||
state = state.copyWith(clearBusy: true);
|
||||
if (!deleted) unawaited(_refreshSummary());
|
||||
}
|
||||
return deleted;
|
||||
} on DioException {
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 붙잡힌 메일 ─────────────────────────────────────
|
||||
|
||||
/// [보내기 취소] · [예약 취소] — 보내지 않고 임시보관함으로. 취소했으면 참(목록으로 돌아간다).
|
||||
/// 이미 보냈으면(`MAIL_014` — 인터셉터가 알렸다) 메일 정보를 다시 받아 안내 줄을 맞춘다
|
||||
Future<bool> cancelHeld() async {
|
||||
final Ref ref = this.ref;
|
||||
final PendingSend? pending = state.detail?.summary.pendingSend;
|
||||
if (!ref.mounted || pending == null || state.busy != null) return false;
|
||||
final MailRepository repository = _repository;
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
state = state.copyWith(busy: MessageReaderBusy.cancelling);
|
||||
try {
|
||||
await repository.cancelPendingSend(messageId);
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
watcher.markChanged(removedIds: <String>{messageId});
|
||||
notice(
|
||||
pending.kind == PendingSendKind.scheduled
|
||||
? MailReaderTexts.scheduleCanceled
|
||||
: MailReaderTexts.undoSendDone,
|
||||
);
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return true;
|
||||
} on DioException {
|
||||
if (!ref.mounted) return false;
|
||||
state = state.copyWith(clearBusy: true);
|
||||
unawaited(_refreshSummary());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 보내지 않은 메일의 [삭제] — 확인 창 뒤에만 부른다. 발송을 취소한 뒤 휴지통으로 옮긴다
|
||||
/// (취소하지 않고 옮기면 엔진은 그 시각에 그대로 보낸다). 옮겼으면 참(목록으로 돌아간다)
|
||||
Future<bool> deleteHeld() async {
|
||||
final Ref ref = this.ref;
|
||||
if (!ref.mounted || !state.isHeld || state.busy != null) return false;
|
||||
final MailRepository repository = _repository;
|
||||
final MailStateWatcher watcher = ref.read(
|
||||
mailStateWatcherProvider.notifier,
|
||||
);
|
||||
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
|
||||
final TokenStore tokens = ref.read(tokenStoreProvider);
|
||||
final int session = tokens.sessionGeneration;
|
||||
state = state.copyWith(busy: MessageReaderBusy.cancelling);
|
||||
final String draftId;
|
||||
try {
|
||||
draftId = await repository.cancelPendingSend(messageId);
|
||||
} on DioException {
|
||||
if (!ref.mounted) return false;
|
||||
state = state.copyWith(clearBusy: true);
|
||||
unawaited(_refreshSummary());
|
||||
return false;
|
||||
}
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
// 발송은 취소했다 — 메일은 임시보관함에 있다(보낸편지함 목록에서 뺀다)
|
||||
watcher.markChanged(removedIds: <String>{messageId});
|
||||
final Mailbox? trash = await _mailboxOfRole(MailboxRole.trash);
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
if (trash != null) {
|
||||
try {
|
||||
final MessageSummary summary = await repository.updateMessage(
|
||||
draftId,
|
||||
MessageChanges(mailboxId: trash.id),
|
||||
);
|
||||
if (tokens.sessionGeneration != session) return false;
|
||||
watcher.markChanged(updated: <MessageSummary>[summary]);
|
||||
notice(MailReaderTexts.heldDeleted);
|
||||
if (ref.mounted) state = state.copyWith(clearBusy: true);
|
||||
return true;
|
||||
} on DioException {
|
||||
// 옮기지 못했다 — 알림은 인터셉터가 띄웠다. 메일은 임시보관함에 남는다
|
||||
}
|
||||
}
|
||||
if (ref.mounted) {
|
||||
state = state.copyWith(clearBusy: true);
|
||||
unawaited(_refreshSummary());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 메일 정보만 다시 받는다(본문은 그대로) — 붙잡힌 발송이 풀렸는지 · 그사이 바뀐 메일함. 실패는 알리지 않는다
|
||||
Future<void> _refreshSummary() async {
|
||||
final Ref ref = this.ref;
|
||||
final int seq = _seq;
|
||||
try {
|
||||
final ({MessageDetail detail, DateTime? serverTime}) result =
|
||||
await _repository.getMessage(messageId);
|
||||
if (!ref.mounted || seq != _seq) return;
|
||||
_noteServerTime(result.serverTime);
|
||||
_setSummary((MessageSummary _) => result.detail.summary);
|
||||
} on DioException {
|
||||
// 다음 차례에 다시 본다
|
||||
}
|
||||
if (ref.mounted && seq == _seq) _scheduleHeldCheck();
|
||||
}
|
||||
|
||||
/// 붙잡힌 발송이 풀리는 때(보낼 시각 + 2초, 서버 시계)에 메일 정보를 다시 본다 — 그 뒤에도 붙잡힌 것으로 보이면 5초마다 3번 더
|
||||
/// (엔진이 풀어도 새 메일 확인의 상태는 바뀌지 않는다 — 목록 컨트롤러와 같은 규칙)
|
||||
void _scheduleHeldCheck() {
|
||||
_cancelHeldTimer();
|
||||
final PendingSend? pending = state.detail?.summary.pendingSend;
|
||||
if (!ref.mounted || pending == null) return;
|
||||
final DateTime now = _serverNow();
|
||||
final DateTime due = pending.sendAt.add(kHeldReleaseGrace);
|
||||
final Duration delay;
|
||||
if (due.isAfter(now)) {
|
||||
delay = due.difference(now);
|
||||
} else {
|
||||
if (_heldRechecks >= kHeldReleaseMaxRechecks) return;
|
||||
delay = kHeldReleaseRecheck;
|
||||
}
|
||||
_heldTimer = Timer(delay, () {
|
||||
_heldTimer = null;
|
||||
if (!ref.mounted) return;
|
||||
if (!due.isAfter(_serverNow())) _heldRechecks++;
|
||||
unawaited(_refreshSummary());
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelHeldTimer() {
|
||||
_heldTimer?.cancel();
|
||||
_heldTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// [되돌리기] — 옮긴 메일을 원래 메일함으로(스팸 표시는 반대로) 다시 옮긴 뒤 목록 · 메일함 개수를 맞춘다.
|
||||
///
|
||||
/// 알림이 읽기 화면보다 오래 남으므로(목록에서 누른다) 화면 없이 돈다. 만든 세션에서만 움직인다(메일 id 는 계정마다 겹칠 수 있다)
|
||||
Future<void> _restoreMessage(
|
||||
String messageId, {
|
||||
required String mailboxId,
|
||||
required bool? isJunk,
|
||||
required MailRepository repository,
|
||||
required MailStateWatcher watcher,
|
||||
required void Function(String text) notice,
|
||||
required TokenStore tokens,
|
||||
required int session,
|
||||
}) async {
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
try {
|
||||
final MessageSummary summary = await repository.updateMessage(
|
||||
messageId,
|
||||
MessageChanges(mailboxId: mailboxId, isJunk: isJunk),
|
||||
);
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
watcher.markChanged(updated: <MessageSummary>[summary]);
|
||||
notice(MailBulkTexts.restored(1));
|
||||
} on DioException catch (error) {
|
||||
// 알림은 인터셉터가 띄웠다 — 그사이 지워졌으면(없음) 목록만 맞춘다
|
||||
if (tokens.sessionGeneration != session) return;
|
||||
if (apiErrorCodeOf(error) == 'RES_001') watcher.markChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:arasystem_app/core/api/api_exception.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 읽기 화면의 상태
|
||||
enum MessageReaderStatus {
|
||||
/// 메일을 받는 중
|
||||
loading,
|
||||
|
||||
/// 받았다
|
||||
ready,
|
||||
|
||||
/// 받지 못했다 — 받지 못함 + [다시 시도] (없는 메일은 [목록으로])
|
||||
failed,
|
||||
}
|
||||
|
||||
/// 하는 중인 일 — 끝날 때까지 위 막대 · 안내 줄의 단추를 막는다
|
||||
enum MessageReaderBusy {
|
||||
/// 옮기기 · 스팸 · 휴지통 · 영구 삭제
|
||||
removing,
|
||||
|
||||
/// 붙잡아 둔 발송 취소(보내기 취소 · 예약 취소 · 보내지 않은 메일의 삭제)
|
||||
cancelling,
|
||||
|
||||
/// 읽음 표시 바꾸기([안 읽음 표시]는 끝나면 목록으로)
|
||||
marking,
|
||||
}
|
||||
|
||||
/// 메일 한 통의 읽기 화면
|
||||
@immutable
|
||||
class MessageReaderState {
|
||||
const MessageReaderState({
|
||||
this.status = MessageReaderStatus.loading,
|
||||
this.detail,
|
||||
this.error,
|
||||
this.document,
|
||||
this.bodyOmitted = false,
|
||||
this.imagesLoading = false,
|
||||
this.busy,
|
||||
});
|
||||
|
||||
final MessageReaderStatus status;
|
||||
final MessageDetail? detail;
|
||||
|
||||
/// 받지 못한 까닭 (코드 · 추적 ID)
|
||||
final ApiException? error;
|
||||
|
||||
/// WebView 에 넣을 문서(정화 · CSP 를 마쳤다) — 만들기 전이면 null
|
||||
final String? document;
|
||||
|
||||
/// 서식 본문이 앱의 정화 한도를 넘어 글자 본문을 보인다 — 서버의 생략(`isHtmlBodyOmitted`)과 같은 안내를 보인다
|
||||
final bool bodyOmitted;
|
||||
|
||||
/// 본문 안내에 쓸 생략 표시 — 서버가 비웠거나 앱이 정화하지 않았다
|
||||
bool get htmlOmitted => (detail?.isHtmlBodyOmitted ?? false) || bodyOmitted;
|
||||
|
||||
/// [이미지 보기]로 다시 받는 중 · 중계 그림을 받아 넣는 중
|
||||
final bool imagesLoading;
|
||||
|
||||
/// 하는 중인 일 — 없으면 null
|
||||
final MessageReaderBusy? busy;
|
||||
|
||||
/// 아직 보내지 않은 내 메일(보내기 취소를 기다림 · 예약) — 취소로만 보낸편지함을 떠난다
|
||||
bool get isHeld => detail?.summary.isHeld ?? false;
|
||||
|
||||
MessageReaderState copyWith({
|
||||
MessageDetail? detail,
|
||||
String? document,
|
||||
bool? bodyOmitted,
|
||||
bool? imagesLoading,
|
||||
MessageReaderBusy? busy,
|
||||
bool clearBusy = false,
|
||||
}) => MessageReaderState(
|
||||
status: status,
|
||||
detail: detail ?? this.detail,
|
||||
error: error,
|
||||
document: document ?? this.document,
|
||||
bodyOmitted: bodyOmitted ?? this.bodyOmitted,
|
||||
imagesLoading: imagesLoading ?? this.imagesLoading,
|
||||
busy: clearBusy ? null : (busy ?? this.busy),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/features/mail/domain/mail_links.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
|
||||
/// 메일 본문 — 앱이 만든 문서([document] — 두 번째 정화 · CSP 를 마쳤다 `mail_html.dart`)만 넣는 WebView 한 겹.
|
||||
///
|
||||
/// - 스크립트를 끈다. 문서 안의 CSP 가 모든 바깥 요청을 막는다(그림은 `data:` 만 — 외부 이미지는 앱이 받아 넣었다).
|
||||
/// - 주 틀의 탐색은 모두 막는다 — 처음 넣은 문서(`about:blank`)만 열고, 누른 링크는 [onNavigation] 으로 앱에 넘긴다(`mail_links.dart`).
|
||||
/// 아래 틀(iframe)의 탐색은 안드로이드가 이 판정에 넘기지 않는다(WebView 가 그대로 연다) — 아래 틀은 정화(iframe 을 내용째 버림)와
|
||||
/// 문서의 CSP(`default-src 'none'` — frame-src)가 막는다.
|
||||
/// - 안드로이드: 파일 · content 주소 접근 · 위치 · 권한 요청 · 파일 고르기를 끄고, 섞인 내용(http)을 막는다.
|
||||
/// 글자 크기는 기기의 글자 크기 설정을 따른다(`setTextZoom` — WebView 는 저절로 따르지 않는다).
|
||||
/// - 쿠키 · 캐시: 문서가 바깥에 나가지 않아 쿠키를 받거나 보낼 일이 없고 내려받아 둘 것도 없다 — 따로 지우지 않는다.
|
||||
/// - 토큰은 WebView 에 가지 않는다(Bearer 는 Dio 만 — 그림 · 첨부는 앱이 받는다).
|
||||
///
|
||||
/// 높이: 머리(앱 화면) 아래 남은 자리를 다 쓰고 본문은 이 안에서 스크롤한다(앱 디자인 결정 — 스크립트가 꺼져 내용 높이를 잴 수 없다).
|
||||
class MailBodyView extends StatefulWidget {
|
||||
const MailBodyView({
|
||||
required this.document,
|
||||
required this.onNavigation,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// WebView 에 넣을 문서 — 바뀌면 다시 넣는다
|
||||
final String document;
|
||||
|
||||
/// 누른 링크의 판정 — 바깥 브라우저 · 쓰기 화면 · 무시(앱이 연다)
|
||||
final void Function(MailNavigation navigation) onNavigation;
|
||||
|
||||
@override
|
||||
State<MailBodyView> createState() => _MailBodyViewState();
|
||||
}
|
||||
|
||||
class _MailBodyViewState extends State<MailBodyView> {
|
||||
late final WebViewController _controller = WebViewController();
|
||||
late final Future<void> _configured = _configure();
|
||||
|
||||
/// 마지막으로 넣은 문서 — 같은 문서는 다시 넣지 않는다
|
||||
String? _loaded;
|
||||
int? _textZoom;
|
||||
Color? _background;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load(widget.document));
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final Color background = Theme.of(context).colorScheme.surface;
|
||||
if (background != _background) {
|
||||
_background = background;
|
||||
unawaited(
|
||||
_configured.then((_) => _controller.setBackgroundColor(background)),
|
||||
);
|
||||
}
|
||||
final int zoom = MediaQuery.textScalerOf(context).scale(_percent).round();
|
||||
if (zoom != _textZoom) {
|
||||
_textZoom = zoom;
|
||||
final AndroidWebViewController? android = _androidOf(_controller);
|
||||
if (android != null) {
|
||||
unawaited(_configured.then((_) => android.setTextZoom(zoom)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 안드로이드 WebView 면 그 컨트롤러 — 시험의 가짜 WebView · iOS 는 null
|
||||
static AndroidWebViewController? _androidOf(WebViewController controller) {
|
||||
final Object platform = controller.platform;
|
||||
return platform is AndroidWebViewController ? platform : null;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MailBodyView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.document != _loaded) unawaited(_load(widget.document));
|
||||
}
|
||||
|
||||
static const double _percent = 100;
|
||||
|
||||
Future<void> _configure() async {
|
||||
final WebViewController controller = _controller;
|
||||
await controller.setJavaScriptMode(JavaScriptMode.disabled);
|
||||
await controller.setNavigationDelegate(
|
||||
NavigationDelegate(onNavigationRequest: _onNavigationRequest),
|
||||
);
|
||||
final AndroidWebViewController? android = _androidOf(controller);
|
||||
if (android != null) {
|
||||
await android.setAllowFileAccess(false);
|
||||
await android.setAllowContentAccess(false);
|
||||
await android.setGeolocationEnabled(false);
|
||||
await android.setMediaPlaybackRequiresUserGesture(true);
|
||||
await android.setMixedContentMode(MixedContentMode.neverAllow);
|
||||
await android.setOnShowFileSelector(
|
||||
(FileSelectorParams _) async => const <String>[],
|
||||
);
|
||||
await android.setOnPlatformPermissionRequest(
|
||||
(PlatformWebViewPermissionRequest request) => unawaited(request.deny()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
NavigationDecision _onNavigationRequest(NavigationRequest request) {
|
||||
final MailNavigation navigation = mailNavigationOf(
|
||||
request.url,
|
||||
isMainFrame: request.isMainFrame,
|
||||
);
|
||||
if (navigation is OpenInitialDocument) return NavigationDecision.navigate;
|
||||
if (mounted) widget.onNavigation(navigation);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
Future<void> _load(String document) async {
|
||||
_loaded = document;
|
||||
await _configured;
|
||||
// 기다리는 사이 더 새 문서가 왔으면 그것만 넣는다
|
||||
if (!mounted || _loaded != document) return;
|
||||
await _controller.loadHtmlString(document);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
container: true,
|
||||
label: MailReaderTexts.body,
|
||||
child: WebViewWidget(controller: _controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,12 +59,13 @@ class MailListEmpty extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일함은 [받은편지함으로] — 다시 해도 같다)
|
||||
/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다)
|
||||
/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다). 읽기 화면도 쓴다(없는 메일은 [목록으로])
|
||||
class MailListError extends StatelessWidget {
|
||||
const MailListError({
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
this.onLeave,
|
||||
this.leaveLabel = MailListTexts.toInbox,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -74,6 +75,9 @@ class MailListError extends StatelessWidget {
|
||||
/// 없는 메일함(`RES_001`)일 때 갈 곳 — 없으면 늘 [다시 시도]
|
||||
final VoidCallback? onLeave;
|
||||
|
||||
/// [onLeave] 단추의 이름
|
||||
final String leaveLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
@@ -94,7 +98,7 @@ class MailListError extends StatelessWidget {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: notFound ? leave : onRetry,
|
||||
child: Text(notFound ? MailListTexts.toInbox : MailListTexts.retry),
|
||||
child: Text(notFound ? leaveLabel : MailListTexts.retry),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/theme/app_theme.dart';
|
||||
import 'package:arasystem_app/core/theme/app_typography.dart';
|
||||
import 'package:arasystem_app/core/utils/file_size.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/attachment_downloads.dart';
|
||||
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 첨부 목록 — "첨부 파일 N개" · 줄마다 파일 아이콘 · 이름 · 크기 · [받기] (보드 `App1Reader`).
|
||||
///
|
||||
/// 받는 동안은 크기 자리에 진행 막대 · "받는 중 64%" 이고 단추가 [받기 취소](✕)다.
|
||||
/// 대용량 첨부(11단계)는 첫 판에서 받지 않는다 — 이름 · 크기와 "대용량 첨부"만 보이고, 목록 아래에 받는 길을 알린다.
|
||||
class ReaderAttachments extends StatelessWidget {
|
||||
const ReaderAttachments({
|
||||
required this.attachments,
|
||||
required this.largeFiles,
|
||||
required this.downloads,
|
||||
required this.onDownload,
|
||||
required this.onCancel,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// 본문 안 그림을 뺀 첨부
|
||||
final List<MessageAttachment> attachments;
|
||||
final List<MessageLargeFile> largeFiles;
|
||||
|
||||
/// 받고 있는 첨부 — 첨부 id 마다
|
||||
final Map<String, AttachmentDownload> downloads;
|
||||
final ValueChanged<MessageAttachment> onDownload;
|
||||
final ValueChanged<MessageAttachment> onCancel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final int count = attachments.length + largeFiles.length;
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s12,
|
||||
),
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: extra.borderSubtle,
|
||||
width: AppSize.borderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s4),
|
||||
child: Semantics(
|
||||
header: true,
|
||||
child: Text(
|
||||
MailReaderTexts.attachmentsTitle(count),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final MessageAttachment attachment in attachments) ...<Widget>[
|
||||
const SizedBox(height: AppSpacing.s8),
|
||||
_AttachmentRow(
|
||||
key: ValueKey<String>('attachment-${attachment.id}'),
|
||||
attachment: attachment,
|
||||
download: downloads[attachment.id],
|
||||
onDownload: () => onDownload(attachment),
|
||||
onCancel: () => onCancel(attachment),
|
||||
),
|
||||
],
|
||||
for (final MessageLargeFile file in largeFiles) ...<Widget>[
|
||||
const SizedBox(height: AppSpacing.s8),
|
||||
_FileRow(
|
||||
icon: Icons.cloud_outlined,
|
||||
name: file.name,
|
||||
below: Text(
|
||||
'${formatFileSize(file.size)} · ${MailReaderTexts.largeFile}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: AppTypography.regular,
|
||||
color: extra.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (largeFiles.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s4,
|
||||
AppSpacing.s10,
|
||||
AppSpacing.s4,
|
||||
0,
|
||||
),
|
||||
child: KeepAllText(
|
||||
MailReaderTexts.largeFilesNote,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: extra.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AttachmentRow extends StatelessWidget {
|
||||
const _AttachmentRow({
|
||||
required this.attachment,
|
||||
required this.download,
|
||||
required this.onDownload,
|
||||
required this.onCancel,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final MessageAttachment attachment;
|
||||
|
||||
/// 받는 중이면 그 진행 — 아니면 null
|
||||
final AttachmentDownload? download;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
static const List<FontFeature> _tabular = <FontFeature>[
|
||||
FontFeature.tabularFigures(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final AttachmentDownload? progress = download;
|
||||
final Widget below = progress == null
|
||||
? Text(
|
||||
formatFileSize(attachment.size),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: AppTypography.regular,
|
||||
color: extra.textMuted,
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.xs),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress.fraction,
|
||||
minHeight: AppSize.progressBarHeight,
|
||||
backgroundColor: scheme.surfaceContainerHighest,
|
||||
color: scheme.primary,
|
||||
// 값은 진행 막대가 0 ~ 100 의 수로 읽힌다(Flutter 가 비율로 만든다)
|
||||
semanticsLabel: MailReaderTexts.downloading(
|
||||
attachment.name,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s8),
|
||||
ExcludeSemantics(
|
||||
child: Text(
|
||||
MailReaderTexts.progress(progress.percent),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: AppTypography.regular,
|
||||
color: scheme.onPrimaryContainer,
|
||||
fontFeatures: _tabular,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return _FileRow(
|
||||
icon: Icons.insert_drive_file_outlined,
|
||||
name: attachment.name,
|
||||
below: below,
|
||||
trailing: progress == null
|
||||
? IconButton(
|
||||
icon: const Icon(
|
||||
Icons.download_outlined,
|
||||
size: AppSize.iconMedium,
|
||||
),
|
||||
tooltip: MailReaderTexts.download(attachment.name),
|
||||
color: extra.textStrong,
|
||||
onPressed: onDownload,
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.close, size: AppSize.iconMedium),
|
||||
tooltip: MailReaderTexts.cancelDownload(attachment.name),
|
||||
color: extra.textStrong,
|
||||
onPressed: onCancel,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 파일 줄의 모양 — 테두리 상자 · 아이콘 칸(40) · 이름 · 아래 줄 · 오른쪽 단추
|
||||
class _FileRow extends StatelessWidget {
|
||||
const _FileRow({
|
||||
required this.icon,
|
||||
required this.name,
|
||||
required this.below,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String name;
|
||||
final Widget below;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final Widget? action = trailing;
|
||||
return Container(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: AppSize.attachmentRowMinHeight,
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.s12,
|
||||
AppSpacing.s8,
|
||||
action == null ? AppSpacing.s12 : AppSpacing.s4,
|
||||
AppSpacing.s8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: scheme.outlineVariant,
|
||||
width: AppSize.borderWidth,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
ExcludeSemantics(
|
||||
child: Container(
|
||||
width: AppSize.fileIconBox,
|
||||
height: AppSize.fileIconBox,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: AppSize.iconMedium,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: AppTypography.medium,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
below,
|
||||
],
|
||||
),
|
||||
),
|
||||
?action,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/theme/app_theme.dart';
|
||||
import 'package:arasystem_app/core/theme/app_typography.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 더 보기 메뉴의 항목
|
||||
enum ReaderMenuAction {
|
||||
/// [안 읽음 표시] · [읽음 표시]
|
||||
toggleRead,
|
||||
|
||||
/// [중요 표시] · [중요 해제]
|
||||
toggleFlag,
|
||||
|
||||
/// [이동]
|
||||
move,
|
||||
|
||||
/// [스팸 신고] · [스팸 아님]
|
||||
toggleSpam,
|
||||
}
|
||||
|
||||
/// 읽기 화면의 위 막대 — 뒤로 · (빈자리) · 삭제 · 더 보기 (보드 `App1Reader`, 높이 56)
|
||||
class ReaderAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const ReaderAppBar({
|
||||
required this.onBack,
|
||||
this.onDelete,
|
||||
this.permanentDelete = false,
|
||||
this.menu = const <ReaderMenuItem>[],
|
||||
this.onMenu,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final VoidCallback onBack;
|
||||
|
||||
/// [삭제] — null 이면 꺼진 단추(받는 중 · 하는 중)
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
/// 휴지통에만 든 메일 — [삭제]가 빨간 [영구 삭제]
|
||||
final bool permanentDelete;
|
||||
|
||||
/// 더 보기 메뉴 — 비면 단추를 그리지 않는다
|
||||
final List<ReaderMenuItem> menu;
|
||||
|
||||
/// 더 보기 메뉴에서 고른 것 — null 이면 꺼진 단추
|
||||
final ValueChanged<ReaderMenuAction>? onMenu;
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(AppSize.readerBarHeight);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final ValueChanged<ReaderMenuAction>? select = onMenu;
|
||||
return AppBar(
|
||||
toolbarHeight: AppSize.readerBarHeight,
|
||||
automaticallyImplyLeading: false,
|
||||
leadingWidth: AppSize.touchMin + AppSpacing.s4,
|
||||
titleSpacing: 0,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(left: AppSpacing.s4),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, size: AppSize.iconLarge),
|
||||
tooltip: MailReaderTexts.back,
|
||||
color: extra.textStrong,
|
||||
onPressed: onBack,
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.only(right: AppSpacing.s4),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
permanentDelete
|
||||
? Icons.delete_forever_outlined
|
||||
: Icons.delete_outline,
|
||||
size: AppSize.iconToolbar,
|
||||
),
|
||||
tooltip: permanentDelete
|
||||
? MailReaderTexts.purge
|
||||
: MailReaderTexts.delete,
|
||||
color: permanentDelete ? scheme.error : extra.textStrong,
|
||||
onPressed: onDelete,
|
||||
),
|
||||
if (menu.isNotEmpty)
|
||||
PopupMenuButton<ReaderMenuAction>(
|
||||
icon: const Icon(Icons.more_vert, size: AppSize.iconToolbar),
|
||||
iconColor: extra.textStrong,
|
||||
tooltip: MailReaderTexts.more,
|
||||
enabled: select != null,
|
||||
position: PopupMenuPosition.under,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: AppSize.readerMenuWidth,
|
||||
),
|
||||
color: scheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
onSelected: select,
|
||||
itemBuilder: (BuildContext context) =>
|
||||
<PopupMenuEntry<ReaderMenuAction>>[
|
||||
for (final ReaderMenuItem item in menu)
|
||||
PopupMenuItem<ReaderMenuAction>(
|
||||
value: item.action,
|
||||
height: AppSize.touchMin,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
item.icon,
|
||||
size: AppSize.iconMedium,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.label,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 더 보기 메뉴의 한 줄
|
||||
@immutable
|
||||
class ReaderMenuItem {
|
||||
const ReaderMenuItem(this.action, this.label, this.icon);
|
||||
|
||||
final ReaderMenuAction action;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
}
|
||||
|
||||
/// 아래 행동 막대 — 답장 · 전체 답장 · 전달 (보드 `App1Reader`, 높이 64 · 위에 선, 화면 낭독기 이름 "답장과 전달").
|
||||
/// 단추마다 null 이면 꺼진다(삭제 · 이동 등을 하는 동안 — 끝나면 목록으로 돌아가므로 그 위에 쓰기 화면을 열지 않게)
|
||||
class ReaderActionBar extends StatelessWidget {
|
||||
const ReaderActionBar({
|
||||
required this.onReply,
|
||||
required this.onReplyAll,
|
||||
required this.onForward,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final VoidCallback? onReply;
|
||||
final VoidCallback? onReplyAll;
|
||||
final VoidCallback? onForward;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
return Semantics(
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
label: MailReaderTexts.actions,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
width: AppSize.borderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
height: AppSize.readerActionBarHeight,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
_ActionButton(
|
||||
icon: Icons.reply,
|
||||
label: MailReaderTexts.reply,
|
||||
color: extra.textStrong,
|
||||
onPressed: onReply,
|
||||
),
|
||||
_ActionButton(
|
||||
icon: Icons.reply_all,
|
||||
label: MailReaderTexts.replyAll,
|
||||
color: extra.textStrong,
|
||||
onPressed: onReplyAll,
|
||||
),
|
||||
_ActionButton(
|
||||
icon: Icons.forward,
|
||||
label: MailReaderTexts.forward,
|
||||
color: extra.textStrong,
|
||||
onPressed: onForward,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
const _ActionButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
/// null 이면 꺼진다
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final VoidCallback? press = onPressed;
|
||||
final Color shown = press == null ? theme.disabledColor : color;
|
||||
return Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
enabled: press != null,
|
||||
label: label,
|
||||
excludeSemantics: true,
|
||||
onTap: press,
|
||||
child: InkWell(
|
||||
onTap: press,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(icon, size: AppSize.iconToolbar, color: shown),
|
||||
const SizedBox(height: AppSpacing.s2),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
color: shown,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/theme/app_theme.dart';
|
||||
import 'package:arasystem_app/core/theme/app_typography.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/message_reader_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 읽기 화면의 머리 — 제목 · 보낸 사람(동그란 사진 40) · 시각 · "받는 사람 나 외 1명"(누르면 펼침) (보드 `App1Reader`).
|
||||
///
|
||||
/// 펼치면 회색 상자에 보낸 사람 · 받는 사람 · 참조 · 숨은참조(내가 보낸 메일) · 날짜를 칸마다 이름 · 주소로 보인다.
|
||||
class ReaderHeader extends StatefulWidget {
|
||||
const ReaderHeader({
|
||||
required this.detail,
|
||||
required this.myEmail,
|
||||
required this.now,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final MessageDetail detail;
|
||||
|
||||
/// 지금 직원의 주소 — 받는 사람 가운데 있으면 "나"
|
||||
final String? myEmail;
|
||||
|
||||
/// 시각 표기의 "오늘 · 어제"를 가르는 지금
|
||||
final DateTime now;
|
||||
|
||||
@override
|
||||
State<ReaderHeader> createState() => _ReaderHeaderState();
|
||||
}
|
||||
|
||||
class _ReaderHeaderState extends State<ReaderHeader> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final MessageDetail detail = widget.detail;
|
||||
final String subject = detail.summary.subject.isEmpty
|
||||
? MailReaderTexts.noSubject
|
||||
: detail.summary.subject;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s20,
|
||||
AppSpacing.s6,
|
||||
AppSpacing.s20,
|
||||
0,
|
||||
),
|
||||
child: Semantics(
|
||||
header: true,
|
||||
child: Text(
|
||||
subject,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s20,
|
||||
AppSpacing.s14,
|
||||
AppSpacing.s20,
|
||||
0,
|
||||
),
|
||||
child: _SenderRow(
|
||||
detail: detail,
|
||||
myEmail: widget.myEmail,
|
||||
now: widget.now,
|
||||
expanded: _expanded,
|
||||
onToggle: () => setState(() => _expanded = !_expanded),
|
||||
),
|
||||
),
|
||||
if (_expanded) _ReaderDetails(detail: detail),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SenderRow extends StatelessWidget {
|
||||
const _SenderRow({
|
||||
required this.detail,
|
||||
required this.myEmail,
|
||||
required this.now,
|
||||
required this.expanded,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
final MessageDetail detail;
|
||||
final String? myEmail;
|
||||
final DateTime now;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
static const List<FontFeature> _tabular = <FontFeature>[
|
||||
FontFeature.tabularFigures(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final MailAddress? from = detail.summary.from;
|
||||
final String sender = from?.displayName ?? MailReaderTexts.noSender;
|
||||
final String? initial = from == null
|
||||
? null
|
||||
: initialOf(from.name, fallback: from.email);
|
||||
final String recipients = recipientSummaryOf(detail, myEmail: myEmail);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
ExcludeSemantics(child: _Avatar(initial: initial)),
|
||||
const SizedBox(width: AppSpacing.s12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
sender,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s8),
|
||||
Text(
|
||||
formatReaderTime(detail.summary.receivedAt, now),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: AppTypography.regular,
|
||||
color: extra.textMuted,
|
||||
fontFeatures: _tabular,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
expanded: expanded,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
onTap: onToggle,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: AppSize.touchMin,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Text(
|
||||
recipients,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s4),
|
||||
Icon(
|
||||
expanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: AppSize.iconTiny,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
const _Avatar({required this.initial});
|
||||
|
||||
final String? initial;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final String? letter = initial;
|
||||
return Container(
|
||||
width: AppSize.avatar,
|
||||
height: AppSize.avatar,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.primaryContainer,
|
||||
),
|
||||
child: letter == null
|
||||
? Icon(
|
||||
Icons.person_outline,
|
||||
size: AppSize.iconMedium,
|
||||
color: scheme.onPrimaryContainer,
|
||||
)
|
||||
: Text(
|
||||
letter,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
color: scheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 머리 펼침 — 칸 이름(64) · 이름과 주소 (보드 `App1Reader` 「머리 펼침」)
|
||||
class _ReaderDetails extends StatelessWidget {
|
||||
const _ReaderDetails({required this.detail});
|
||||
|
||||
final MessageDetail detail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final MailAddress? from = detail.summary.from;
|
||||
final List<Widget> rows = <Widget>[
|
||||
_DetailsRow(
|
||||
label: MailReaderTexts.fromLabel,
|
||||
lines: from == null
|
||||
? const <ReaderAddressLine>[
|
||||
(name: MailReaderTexts.noSender, email: null),
|
||||
]
|
||||
: <ReaderAddressLine>[readerAddressLine(from)],
|
||||
),
|
||||
if (detail.summary.to.isNotEmpty)
|
||||
_DetailsRow(
|
||||
label: MailReaderTexts.toLabel,
|
||||
lines: detail.summary.to.map(readerAddressLine).toList(),
|
||||
),
|
||||
if (detail.cc.isNotEmpty)
|
||||
_DetailsRow(
|
||||
label: MailReaderTexts.ccLabel,
|
||||
lines: detail.cc.map(readerAddressLine).toList(),
|
||||
),
|
||||
if (detail.bcc.isNotEmpty)
|
||||
_DetailsRow(
|
||||
label: MailReaderTexts.bccLabel,
|
||||
lines: detail.bcc.map(readerAddressLine).toList(),
|
||||
),
|
||||
_DetailsRow(
|
||||
label: MailReaderTexts.dateLabel,
|
||||
lines: <ReaderAddressLine>[
|
||||
(name: formatReaderFullDate(detail.summary.receivedAt), email: null),
|
||||
],
|
||||
),
|
||||
];
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s10,
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s4,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.s14,
|
||||
vertical: AppSpacing.s12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
for (int index = 0; index < rows.length; index++) ...<Widget>[
|
||||
if (index > 0) const SizedBox(height: AppSpacing.s8),
|
||||
rows[index],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailsRow extends StatelessWidget {
|
||||
const _DetailsRow({required this.label, required this.lines});
|
||||
|
||||
final String label;
|
||||
final List<ReaderAddressLine> lines;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final TextStyle? labelStyle = theme.textTheme.bodySmall?.copyWith(
|
||||
color: extra.textMuted,
|
||||
);
|
||||
final TextStyle? nameStyle = theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
);
|
||||
final TextStyle? emailStyle = theme.textTheme.bodySmall?.copyWith(
|
||||
color: extra.textMuted,
|
||||
);
|
||||
return MergeSemantics(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: AppSize.detailsLabelWidth,
|
||||
child: Text(label, style: labelStyle),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
for (final ReaderAddressLine line in lines) ...<Widget>[
|
||||
Text(line.name, style: nameStyle),
|
||||
if (line.email case final String email)
|
||||
Text(email, style: emailStyle),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/theme/app_theme.dart';
|
||||
import 'package:arasystem_app/core/theme/app_typography.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/message_reader_view.dart';
|
||||
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 머리 아래의 안내 줄 — 차례: 붙잡힌 메일(청록 — [보내기 취소] · [예약 취소]) → 본문 안내(생략 → 잘림) → 외부 이미지 차단(노랑 — [이미지 보기])
|
||||
/// (보드 `App1Reader` · 웹 `MessageReaderNotices` 와 같은 차례)
|
||||
class ReaderNotices extends StatelessWidget {
|
||||
const ReaderNotices({
|
||||
required this.detail,
|
||||
required this.htmlOmitted,
|
||||
required this.busy,
|
||||
required this.imagesLoading,
|
||||
required this.onShowImages,
|
||||
required this.onCancelHeld,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final MessageDetail detail;
|
||||
|
||||
/// 서식 본문을 보이지 않는다 — 서버가 비웠거나(`isHtmlBodyOmitted`) 앱의 정화 한도를 넘었다
|
||||
final bool htmlOmitted;
|
||||
|
||||
/// 하는 일이 있다 — 안내 줄의 단추를 막는다
|
||||
final bool busy;
|
||||
|
||||
/// [이미지 보기]로 다시 받는 중
|
||||
final bool imagesLoading;
|
||||
final VoidCallback onShowImages;
|
||||
final VoidCallback onCancelHeld;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PendingSend? pending = detail.summary.pendingSend;
|
||||
final List<MailBodyNotice> bodyNotices = mailBodyNoticesOf(
|
||||
textBody: detail.textBody,
|
||||
isHtmlBodyOmitted: htmlOmitted,
|
||||
isBodyTruncated: detail.isBodyTruncated,
|
||||
);
|
||||
// 서식 본문을 보이지 않으면 외부 이미지도 없다 — [이미지 보기]를 두지 않는다
|
||||
final bool imagesBlocked =
|
||||
detail.hasBlockedImages && !detail.imagesShown && !htmlOmitted;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
if (pending != null)
|
||||
ReaderNotice(
|
||||
key: const ValueKey<String>('notice-held'),
|
||||
tone: ReaderNoticeTone.brand,
|
||||
icon: Icons.schedule,
|
||||
text: pendingNoticeOf(pending),
|
||||
actionLabel: pending.kind == PendingSendKind.scheduled
|
||||
? MailReaderTexts.cancelSchedule
|
||||
: MailReaderTexts.undoSend,
|
||||
onAction: busy ? null : onCancelHeld,
|
||||
),
|
||||
for (final MailBodyNotice notice in bodyNotices)
|
||||
ReaderNotice(
|
||||
key: ValueKey<String>('notice-${notice.name}'),
|
||||
tone: ReaderNoticeTone.warning,
|
||||
icon: Icons.error_outline,
|
||||
text: MailReaderTexts.bodyNotice(notice),
|
||||
),
|
||||
if (imagesBlocked)
|
||||
ReaderNotice(
|
||||
key: const ValueKey<String>('notice-images'),
|
||||
tone: ReaderNoticeTone.warning,
|
||||
icon: Icons.hide_image_outlined,
|
||||
text: MailReaderTexts.imagesBlocked,
|
||||
actionLabel: MailReaderTexts.showImages,
|
||||
onAction: imagesLoading ? null : onShowImages,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 안내 줄의 색
|
||||
enum ReaderNoticeTone {
|
||||
/// 노랑 — 본문 안내 · 외부 이미지 차단
|
||||
warning,
|
||||
|
||||
/// 청록 — 붙잡힌 메일
|
||||
brand,
|
||||
}
|
||||
|
||||
/// 안내 줄 하나 — 아이콘 · 글, 단추는 오른쪽 아래(누르는 곳 48 — 보드 `App1Reader` 의 [이미지 보기])
|
||||
class ReaderNotice extends StatelessWidget {
|
||||
const ReaderNotice({
|
||||
required this.tone,
|
||||
required this.icon,
|
||||
required this.text,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ReaderNoticeTone tone;
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
/// 단추 이름 — 없으면 단추 없는 줄
|
||||
final String? actionLabel;
|
||||
|
||||
/// 단추 — null 이면 꺼진 단추(하는 중)
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final (Color background, Color border, Color foreground) = switch (tone) {
|
||||
ReaderNoticeTone.warning => (
|
||||
extra.warningSurface,
|
||||
extra.warningBorder,
|
||||
extra.warningText,
|
||||
),
|
||||
ReaderNoticeTone.brand => (
|
||||
scheme.primaryContainer,
|
||||
extra.brandTintBorder,
|
||||
scheme.onPrimaryContainer,
|
||||
),
|
||||
};
|
||||
final String? label = actionLabel;
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s12,
|
||||
AppSpacing.s16,
|
||||
0,
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.s14,
|
||||
AppSpacing.s12,
|
||||
label == null ? AppSpacing.s14 : AppSpacing.s6,
|
||||
label == null ? AppSpacing.s12 : AppSpacing.s2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
border: Border.all(color: border, width: AppSize.borderWidth),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s2),
|
||||
child: Icon(icon, size: AppSize.iconSmall, color: foreground),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s10),
|
||||
Expanded(
|
||||
child: KeepAllText(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: foreground,
|
||||
height: AppTypography.lineHeightRelaxed,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (label != null)
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: foreground,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.s12,
|
||||
),
|
||||
textStyle: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: AppTypography.bold,
|
||||
),
|
||||
),
|
||||
onPressed: onAction,
|
||||
child: Text(label),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user