generated from ttipo/project-base
fix(flutter): 첨부만 있는 메일의 읽기 화면과 쓰기 · 검색 입력칸을 고친다 (앱 1.0.1) #27
@@ -0,0 +1,56 @@
|
||||
/// 글에 보이는 글자가 있는가 — 순수 함수 (모듈 공통 — 메일 본문이 비었는지 등).
|
||||
///
|
||||
/// 아래 글자만 있는 글은 보이는 것이 없다(빈 글이다):
|
||||
/// - 공백: Dart `trim()` 이 지우는 공백 전부 — 빈칸 · 탭 · 줄바꿈 · nbsp · 전각 빈칸 · 줄 · 문단 구분 · BOM 등
|
||||
/// - 제어 문자: C0 · DEL · C1
|
||||
/// - 보이지 않는 글자: 유니코드의 Default_Ignorable_Code_Point — 제로폭 빈칸 · 이음(ZWNJ · ZWJ · WORD JOINER) · 방향 표시(LRM · RLM · 끼워 넣기 · 격리) ·
|
||||
/// soft hyphen · 이체 선택자 · 한글 채움 문자(U+115F · U+1160 · U+3164 · U+FFA0) · 태그 글자 등
|
||||
/// - 점자 빈칸(U+2800) — 글자이지만 빈칸으로 그려진다
|
||||
library;
|
||||
|
||||
/// [text] 에 보이는 글자가 하나라도 있는가 — 앞에서부터 보고 처음 보이는 글자에서 멈춘다(길이에 비례)
|
||||
bool hasVisibleText(String text) {
|
||||
for (final int rune in text.runes) {
|
||||
if (!_isInvisible(rune)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 보이지 않는 글자인가 — 위 머리말의 목록
|
||||
bool _isInvisible(int rune) {
|
||||
// C0 제어 문자 · 빈칸
|
||||
if (rune <= 0x20) return true;
|
||||
// 그 밖의 ASCII 는 모두 보인다
|
||||
if (rune < 0x7F) return false;
|
||||
// DEL · C1 제어 문자(NEL 포함) · nbsp
|
||||
if (rune <= 0xA0) return true;
|
||||
for (final (int first, int last) in _invisibleRanges) {
|
||||
if (rune < first) return false;
|
||||
if (rune <= last) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 보이지 않는 글자의 범위 — (처음, 끝) 끝 포함 · 작은 차례대로(U+00A0 까지는 위에서 따로 본다)
|
||||
const List<(int, int)> _invisibleRanges = <(int, int)>[
|
||||
(0x00AD, 0x00AD), // soft hyphen
|
||||
(0x034F, 0x034F), // 글자 묶음 이음(CGJ)
|
||||
(0x061C, 0x061C), // 아랍 글자 방향 표시
|
||||
(0x115F, 0x1160), // 한글 초성 · 중성 채움 문자
|
||||
(0x1680, 0x1680), // 오검 빈칸
|
||||
(0x17B4, 0x17B5), // 크메르의 소리 없는 모음
|
||||
(0x180B, 0x180F), // 몽골 이체 선택자 · 모음 구분
|
||||
(0x2000, 0x200F), // 너비가 다른 빈칸 · 제로폭 빈칸 · 이음 · 방향 표시
|
||||
(0x2028, 0x202F), // 줄 · 문단 구분 · 방향 끼워 넣기 · 좁은 nbsp
|
||||
(0x205F, 0x206F), // 수학 빈칸 · WORD JOINER · 보이지 않는 연산자 · 방향 격리
|
||||
(0x2800, 0x2800), // 점자 빈칸
|
||||
(0x3000, 0x3000), // 전각 빈칸
|
||||
(0x3164, 0x3164), // 한글 채움 문자
|
||||
(0xFE00, 0xFE0F), // 이체 선택자
|
||||
(0xFEFF, 0xFEFF), // BOM(제로폭 nbsp)
|
||||
(0xFFA0, 0xFFA0), // 반각 한글 채움 문자
|
||||
(0xFFF0, 0xFFF8), // 보이지 않게 정해 둔 빈 자리
|
||||
(0x1BCA0, 0x1BCA3), // 속기 서식 제어
|
||||
(0x1D173, 0x1D17A), // 악보 서식 제어
|
||||
(0xE0000, 0xE0FFF), // 태그 글자 · 이체 선택자 보충
|
||||
];
|
||||
@@ -11,8 +11,11 @@
|
||||
///
|
||||
/// 외부 이미지를 보일 때(서버가 중계 주소 `/api/mail-images/<표>` 로 바꿔 넣은 본문 — imagesShown)도 WebView 는 바깥에 나가지 않는다 —
|
||||
/// 앱이 Dio(Bearer)로 그림을 받아 `data:` 로 바꿔 넣는다([resolveProxiedImages]). 토큰이 WebView 에 가지 않는다.
|
||||
///
|
||||
/// 정화하며 본문에 보이는 것이 있는지도 함께 본다([SanitizedMailBody.hasVisibleContent]) — 없으면 읽기 화면이 본문 칸을 그리지 않는다.
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/utils/visible_text.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_highlight.dart';
|
||||
import 'package:html/dom.dart' as dom;
|
||||
@@ -67,6 +70,11 @@ const Set<String> _droppedWithContent = <String>{
|
||||
/// 내용이 없는 태그 — 닫는 태그를 쓰지 않는다
|
||||
const Set<String> _voidTags = <String>{'br', 'col', 'hr', 'img', 'wbr'};
|
||||
|
||||
/// 남기는 태그 가운데 글 없이도 그려지는 것 — 가로줄 · 목록 표시(• · 1.) · 펼침 표시(▶ 와 기본 이름) · 따옴표.
|
||||
/// 그림(`img`)은 주소가 남았을 때만 보인다(주소를 지운 그림은 문서가 숨긴다 — [buildMailDocument]) — 따로 본다.
|
||||
/// 모양 속성(style · bgcolor · border)만으로 그린 상자 · 선 · 바탕과 빈 인용의 왼쪽 막대는 보이는 것으로 치지 않는다 — 글도 그림도 없는 꾸밈뿐이다
|
||||
const Set<String> _drawnWithoutText = <String>{'hr', 'li', 'details', 'q'};
|
||||
|
||||
/// 어느 태그에나 남기는 속성 — 모양(style) · 표 · 글꼴의 옛 속성. 이벤트 처리기 · data-* · id · target · 외부 자원 속성은 없다
|
||||
const Set<String> _globalAttributes = <String>{
|
||||
'style', 'class', 'title', 'dir', 'lang', 'align', 'valign', 'width', //
|
||||
@@ -109,6 +117,7 @@ class SanitizedMailBody {
|
||||
const SanitizedMailBody({
|
||||
required this.fragment,
|
||||
required this.proxiedTokens,
|
||||
required this.hasVisibleContent,
|
||||
this.htmlOmitted = false,
|
||||
});
|
||||
|
||||
@@ -118,6 +127,12 @@ class SanitizedMailBody {
|
||||
/// 받아 넣을 중계 그림의 표 — 문서 차례 · 겹치지 않게 · [kMaxProxiedImages] 까지(외부 이미지를 보일 때만)
|
||||
final List<String> proxiedTokens;
|
||||
|
||||
/// 본문에 보이는 것이 있다 — 공백 · 보이지 않는 글자가 아닌 글자([hasVisibleText]), 주소가 남은 그림, 글 없이도 그려지는 태그
|
||||
/// (가로줄 · 목록 표시 · 펼침 표시 · 따옴표). 거짓이면 읽기 화면이 본문 칸(WebView)을 그리지 않는다.
|
||||
///
|
||||
/// 중계 그림은 받기 전에도 보이는 것으로 친다 — 하나도 받지 못하면 빈 본문 칸이 남는다(본문을 다시 보지 않는다)
|
||||
final bool hasVisibleContent;
|
||||
|
||||
/// 서식 본문이 앱의 정화 한도(길이 · 중첩)를 넘어 글자 본문을 보인다 — 화면은 서버의 생략과 같은 안내를 보인다
|
||||
final bool htmlOmitted;
|
||||
}
|
||||
@@ -130,7 +145,10 @@ String? displayedHtmlBody(String? htmlBody) =>
|
||||
/// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다).
|
||||
/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다.
|
||||
/// [textHighlights] 는 글 본문의 검색에서 맞은 곳(검색 결과에서 연 메일 — 14단계) — 글 본문을 보일 때만 `<mark>` 로 넣는다
|
||||
/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `<mark>` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다)
|
||||
/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `<mark>` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다).
|
||||
///
|
||||
/// 고른 본문에 보이는 것이 있는지([SanitizedMailBody.hasVisibleContent])도 함께 낸다 — 고르는 규칙은 그대로다(서식 본문이 비어 보여도
|
||||
/// 글자 본문으로 바꾸지 않는다 — 웹 `displayedHtmlBody` 와 같은 본문을 보인다)
|
||||
SanitizedMailBody mailBodyFragment({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
@@ -162,6 +180,7 @@ SanitizedMailBody _textFragment(
|
||||
fragment:
|
||||
'<div class="mail-text">${highlightedTextHtml(textBody ?? '', highlights)}</div>',
|
||||
proxiedTokens: const <String>[],
|
||||
hasVisibleContent: hasVisibleText(textBody ?? ''),
|
||||
htmlOmitted: htmlOmitted,
|
||||
);
|
||||
|
||||
@@ -316,15 +335,22 @@ const Set<String> _htmlVoidTags = <String>{
|
||||
/// 허용 목록 밖의 태그는 태그만 지우고 글은 남긴다(`<o:p>` · `<form>` · `<button>` 등), [_droppedWithContent] 는 내용째 버린다.
|
||||
/// HTML 이 아닌 이름공간(SVG · MathML)은 내용째 버린다. 주석은 지운다. 깊은 중첩에도 스택이 넘치지 않게 되풀이로 돈다.
|
||||
/// 문서 전체로 읽어 `<body>` 아래만 쓴다 — 조각으로 읽으면(`parseFragment`) 맨 위 요소가 많을 때 파서가 옮기며 제곱으로 느려진다
|
||||
/// (2026-11-06 측정: 맨 위 span 4만 개 10초 → 문서로 0.3초). 머리에 들어가는 태그(meta · title · style 등)는 어차피 버린다
|
||||
/// (2026-11-06 측정: 맨 위 span 4만 개 10초 → 문서로 0.3초). 머리에 들어가는 태그(meta · title · style 등)는 어차피 버린다.
|
||||
/// 쓰는 동안 보이는 것이 있는지([SanitizedMailBody.hasVisibleContent])도 본다 — 남긴 글 · 주소가 남은 그림 · [_drawnWithoutText] 만
|
||||
/// (내용째 버린 것 · 주석은 보이지 않는다). 처음 찾으면 더 보지 않는다
|
||||
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>[]);
|
||||
return const SanitizedMailBody(
|
||||
fragment: '',
|
||||
proxiedTokens: <String>[],
|
||||
hasVisibleContent: false,
|
||||
);
|
||||
}
|
||||
bool visible = false;
|
||||
|
||||
// 되풀이 깊이 우선 — 닫는 태그는 글로 쌓는다
|
||||
final List<Object> stack = <Object>[..._reversed(body.nodes)];
|
||||
@@ -337,6 +363,7 @@ SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
|
||||
final dom.Node node = item as dom.Node;
|
||||
if (node is dom.Text) {
|
||||
out.write(escapeHtmlText(node.data));
|
||||
if (!visible) visible = hasVisibleText(node.data);
|
||||
continue;
|
||||
}
|
||||
if (node is! dom.Element) continue;
|
||||
@@ -348,24 +375,29 @@ SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
|
||||
stack.addAll(_reversed(node.nodes));
|
||||
continue;
|
||||
}
|
||||
final String attributes = _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;
|
||||
},
|
||||
);
|
||||
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(attributes)
|
||||
..write('>');
|
||||
// 그림은 주소가 남았을 때만 보인다 — 남긴 주소는 늘 맨 앞에 쓴다([_attributesOf])
|
||||
if (!visible &&
|
||||
(_drawnWithoutText.contains(name) ||
|
||||
(name == 'img' && attributes.startsWith(' src=')))) {
|
||||
visible = true;
|
||||
}
|
||||
if (_voidTags.contains(name)) continue;
|
||||
stack
|
||||
..add('</$name>')
|
||||
@@ -374,6 +406,7 @@ SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
|
||||
return SanitizedMailBody(
|
||||
fragment: out.toString(),
|
||||
proxiedTokens: List<String>.unmodifiable(tokens),
|
||||
hasVisibleContent: visible,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -381,7 +414,7 @@ const String _htmlNamespace = 'http://www.w3.org/1999/xhtml';
|
||||
|
||||
Iterable<dom.Node> _reversed(List<dom.Node> nodes) => nodes.reversed;
|
||||
|
||||
/// 남길 속성을 ` 이름="값"` 꼴로 — 그림은 `src` 를, 링크는 `href` 를 맨 앞에 쓴다.
|
||||
/// 남길 속성을 ` 이름="값"` 꼴로 — 그림은 `src` 를, 링크는 `href` 를 맨 앞에 쓴다(보이는 그림인지 가르기 · [resolveProxiedImages] 가 이 모양에 기댄다).
|
||||
/// [onProxied] 는 남길 중계 그림이면 참(한 통의 한도를 넘으면 거짓 — 그 그림은 주소를 지운다)
|
||||
String _attributesOf(
|
||||
dom.Element element,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:arasystem_app/core/api/api_exception.dart';
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
@@ -33,7 +34,8 @@ 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 「읽기」). 탭 막대 없는 전체 화면이다.
|
||||
@@ -42,6 +44,8 @@ const double kReaderHeadMaxFraction = 0.5;
|
||||
/// 붙잡힌 메일(보내기 취소를 기다림 · 예약)에는 이동 · 스팸이 없고 [삭제]는 확인 뒤 발송을 취소하고 휴지통으로.
|
||||
/// - 머리는 앱 화면, 본문은 WebView(`MailBodyView`) — 머리 아래 남은 자리를 다 쓰고 본문은 그 안에서 스크롤한다.
|
||||
/// 머리가 길면(펼친 받는 사람 · 첨부 여럿) 남은 자리의 절반까지만 쓰고 머리 안에서 스크롤한다.
|
||||
/// **본문에 보이는 것이 없으면**(첨부만 있는 메일 등 — 상태의 `bodyEmpty`) 본문 칸을 두지 않고 머리가 화면 전체를 한 스크롤로 쓴다
|
||||
/// (2026-11-12 — 아래 절반이 빈 칸으로 남고 첨부 목록이 위 절반 안에서 잘렸다).
|
||||
/// - **첨부 목록은 머리 안(안내 줄 아래 · 본문 위)이다** — 보드는 본문 아래지만, 본문이 WebView 안에서 스크롤하므로 아래에 두면
|
||||
/// 늘 화면 아래 자리를 차지한다(쓰기 화면처럼 위에 둔다 — 앱에서 정함).
|
||||
/// - 아래 행동 막대: 답장 · 전체 답장 · 전달 — 쓰기 화면으로 그 메일 id 를 넘긴다(`MailRoutes.composeReply` 등). 예약한 메일에는 없다(웹과 같다).
|
||||
@@ -349,7 +353,8 @@ List<ReaderMenuItem> readerMenuOf(
|
||||
];
|
||||
}
|
||||
|
||||
/// 받은 메일 — 머리(남은 자리의 절반까지 · 넘으면 그 안에서 스크롤) + 본문 WebView(남은 자리 전부)
|
||||
/// 받은 메일 — 머리(남은 자리의 절반까지 · 넘으면 그 안에서 스크롤) + 본문 WebView(남은 자리 전부).
|
||||
/// 본문에 보이는 것이 없으면 본문 칸 없이 머리만(화면 전체까지 · 넘으면 그 안에서 스크롤)
|
||||
class _ReaderContent extends ConsumerWidget {
|
||||
const _ReaderContent({
|
||||
required this.messageId,
|
||||
@@ -383,16 +388,31 @@ class _ReaderContent extends ConsumerWidget {
|
||||
final String? myEmail = ref.watch(mailAccountProvider);
|
||||
final List<MessageAttachment> attachments = detail.listedAttachments;
|
||||
final String? document = reader.document;
|
||||
// 본문 칸 — 보이는 것이 없는 본문이면 두지 않는다. 문서를 만드는 동안(document 가 null)은 아직 모르므로 지금처럼 진행 줄을 둔다
|
||||
final bool bodyShown = !reader.bodyEmpty;
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
// 본문 칸이 없으면 머리가 화면 전체를 쓴다 — 그림 받는 진행 막대가 머리 아래에 들 자리만 남긴다
|
||||
final double headMaxHeight = bodyShown
|
||||
? constraints.maxHeight * kReaderHeadMaxFraction
|
||||
: math.max(
|
||||
0,
|
||||
constraints.maxHeight -
|
||||
(reader.imagesLoading ? AppSize.progressBarHeight : 0),
|
||||
);
|
||||
// 머리가 화면 아래 끝까지 닿으면(본문 칸이 없다) 시스템 내비게이션 막대 몫을 머리 끝에 더한다 — 마지막 첨부가 그 밑에 남지 않게.
|
||||
// 아래 행동 막대가 있으면 Scaffold 가 이 값을 이미 0 으로 만든다(예약 메일처럼 막대가 없을 때만 남는다)
|
||||
final double headBottomInset = bodyShown
|
||||
? 0
|
||||
: MediaQuery.paddingOf(context).bottom;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
// 머리는 두 짜임에서 같은 자리의 같은 위젯이다 — 빈 본문에서 [이미지 보기]로 본문 칸이 생겨도 펼친 받는 사람이 그대로 남는다
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: constraints.maxHeight * kReaderHeadMaxFraction,
|
||||
),
|
||||
constraints: BoxConstraints(maxHeight: headMaxHeight),
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: headBottomInset),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
@@ -438,14 +458,15 @@ class _ReaderContent extends ConsumerWidget {
|
||||
minHeight: AppSize.progressBarHeight,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: document == null
|
||||
? const _BodyLoading()
|
||||
: MailBodyView(
|
||||
document: document,
|
||||
onNavigation: onNavigation,
|
||||
),
|
||||
),
|
||||
if (bodyShown)
|
||||
Expanded(
|
||||
child: document == null
|
||||
? const _BodyLoading()
|
||||
: MailBodyView(
|
||||
document: document,
|
||||
onNavigation: onNavigation,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -101,6 +101,7 @@ messageReaderProvider = NotifierProvider.autoDispose
|
||||
///
|
||||
/// - 열면 안 읽은 메일을 읽음으로 바꾼다(화면 · 메일함 개수를 먼저 — 실패하면 알리지 않고 되돌린다).
|
||||
/// - 본문은 두 번째 정화 뒤 CSP 를 건 문서로 만든다(`mail_html.dart` — 짧고 얕은 본문만 그 자리에서, 길거나 깊은 본문은 다른 isolate 에서).
|
||||
/// 정화가 함께 낸 "보이는 것이 없다"([MessageReaderState.bodyEmpty] — 첨부만 있는 메일 등)도 같은 차례에 싣는다 — 화면은 본문 칸을 두지 않는다.
|
||||
/// 외부 이미지를 보이는 본문이면(설정 · [이미지 보기]) 중계 그림을 Dio 로 받아(한 번에 [kMailImageConcurrency] 개 · 한도 `mail_images.dart`)
|
||||
/// `data:` 로 넣고 문서를 한 번 더 만든다. 그림 받기는 화면이 사라지거나 본문이 바뀌면 끊는다(끊긴 그림은 숨기고 알리지 않는다).
|
||||
/// - 옮기기 · 스팸 · 휴지통은 응답을 받은 뒤 목록으로 돌아가고(부르는 쪽이), 알림에 [되돌리기](원래 메일함 — 스팸은 표시도 반대로).
|
||||
@@ -279,6 +280,8 @@ class MessageReaderController extends Notifier<MessageReaderState> {
|
||||
state = state.copyWith(
|
||||
document: _document(),
|
||||
bodyOmitted: body.htmlOmitted,
|
||||
// 보이는 것이 없는 본문이면 화면이 본문 칸을 두지 않는다 — 판정은 정화가 함께 냈다
|
||||
bodyEmpty: !body.hasVisibleContent,
|
||||
imagesLoading: hasImages,
|
||||
);
|
||||
if (!hasImages) return;
|
||||
|
||||
@@ -38,6 +38,7 @@ class MessageReaderState {
|
||||
this.error,
|
||||
this.document,
|
||||
this.bodyOmitted = false,
|
||||
this.bodyEmpty = false,
|
||||
this.imagesLoading = false,
|
||||
this.busy,
|
||||
this.subjectHighlight,
|
||||
@@ -52,6 +53,10 @@ class MessageReaderState {
|
||||
/// WebView 에 넣을 문서(정화 · CSP 를 마쳤다) — 만들기 전이면 null
|
||||
final String? document;
|
||||
|
||||
/// 보여 줄 본문에 보이는 것이 없다(첨부만 있는 메일 등 — 정화가 함께 낸 판정 `SanitizedMailBody.hasVisibleContent`).
|
||||
/// 화면은 본문 칸(WebView)을 두지 않고 머리(제목 · 안내 줄 · 첨부)를 화면 전체에서 한 스크롤로 보인다. 문서를 만들기 전에는 거짓
|
||||
final bool bodyEmpty;
|
||||
|
||||
/// 서식 본문이 앱의 정화 한도를 넘어 글자 본문을 보인다 — 서버의 생략(`isHtmlBodyOmitted`)과 같은 안내를 보인다
|
||||
final bool bodyOmitted;
|
||||
|
||||
@@ -75,6 +80,7 @@ class MessageReaderState {
|
||||
MessageDetail? detail,
|
||||
String? document,
|
||||
bool? bodyOmitted,
|
||||
bool? bodyEmpty,
|
||||
bool? imagesLoading,
|
||||
MessageReaderBusy? busy,
|
||||
bool clearBusy = false,
|
||||
@@ -85,6 +91,7 @@ class MessageReaderState {
|
||||
error: error,
|
||||
document: document ?? this.document,
|
||||
bodyOmitted: bodyOmitted ?? this.bodyOmitted,
|
||||
bodyEmpty: bodyEmpty ?? this.bodyEmpty,
|
||||
imagesLoading: imagesLoading ?? this.imagesLoading,
|
||||
busy: clearBusy ? null : (busy ?? this.busy),
|
||||
subjectHighlight: identical(subjectHighlight, _unset)
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
/// - 토큰은 WebView 에 가지 않는다(Bearer 는 Dio 만 — 그림 · 첨부는 앱이 받는다).
|
||||
///
|
||||
/// 높이: 머리(앱 화면) 아래 남은 자리를 다 쓰고 본문은 이 안에서 스크롤한다(앱 디자인 결정 — 스크립트가 꺼져 내용 높이를 잴 수 없다).
|
||||
/// 그래서 보이는 것이 없는 본문(첨부만 있는 메일 등)이면 읽기 화면이 이 칸을 아예 두지 않는다(`SanitizedMailBody.hasVisibleContent`).
|
||||
class MailBodyView extends StatefulWidget {
|
||||
const MailBodyView({
|
||||
required this.document,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:arasystem_app/core/utils/visible_text.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 보이는 글자가 있는가 — 공백 · 제어 문자 · 보이지 않는 글자만 있으면 빈 글이다
|
||||
void main() {
|
||||
/// 글자 코드로 만든 글 — 소스에 보이지 않는 글자를 두지 않는다
|
||||
String chars(List<int> codes) => String.fromCharCodes(codes);
|
||||
|
||||
test('Dart trim() 이 지우는 공백은 모두 보이지 않는다', () {
|
||||
int blanks = 0;
|
||||
for (int code = 0; code <= 0xFFFF; code++) {
|
||||
// 홀로 선 서로게이트는 글자가 아니다
|
||||
if (code >= 0xD800 && code <= 0xDFFF) continue;
|
||||
final String char = String.fromCharCode(code);
|
||||
if (char.trim().isNotEmpty) continue;
|
||||
blanks++;
|
||||
expect(
|
||||
hasVisibleText(char),
|
||||
isFalse,
|
||||
reason: 'U+${code.toRadixString(16).toUpperCase()}',
|
||||
);
|
||||
}
|
||||
// 유니코드 White_Space 25자 + BOM — 빈칸 · 탭 · 줄바꿈 · nbsp · 너비가 다른 빈칸 11자 · 줄 · 문단 구분 · 전각 빈칸 등
|
||||
expect(blanks, 26);
|
||||
});
|
||||
|
||||
test('빈 글 · 공백(nbsp · 전각 빈칸 · 줄 · 문단 구분 · BOM 등)뿐인 글은 보이는 것이 없다', () {
|
||||
expect(hasVisibleText(''), isFalse);
|
||||
expect(hasVisibleText(' \t\r\n'), isFalse);
|
||||
for (final int code in <int>[
|
||||
0x00A0, // nbsp
|
||||
0x0085, // NEL
|
||||
0x1680,
|
||||
0x2002,
|
||||
0x200A,
|
||||
0x2028,
|
||||
0x2029,
|
||||
0x202F,
|
||||
0x205F,
|
||||
0x3000, // 전각 빈칸
|
||||
0xFEFF, // BOM
|
||||
]) {
|
||||
expect(hasVisibleText(chars(<int>[0x20, code, 0x0A])), isFalse);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'제어 문자 · 보이지 않는 글자(제로폭 · 이음 · 방향 표시 · 이체 선택자 · 한글 채움 · 태그 · 점자 빈칸)도 보이지 않는다',
|
||||
() {
|
||||
for (final int code in <int>[
|
||||
0x0000,
|
||||
0x001F,
|
||||
0x007F,
|
||||
0x009F,
|
||||
0x00AD, // soft hyphen
|
||||
0x034F, // CGJ
|
||||
0x061C,
|
||||
0x115F, // 한글 초성 채움
|
||||
0x1160, // 한글 중성 채움
|
||||
0x180E,
|
||||
0x200B, // 제로폭 빈칸
|
||||
0x200C, // ZWNJ
|
||||
0x200D, // ZWJ
|
||||
0x200E, // LRM
|
||||
0x200F, // RLM
|
||||
0x202A,
|
||||
0x202E,
|
||||
0x2060, // WORD JOINER
|
||||
0x2066,
|
||||
0x2069,
|
||||
0x2800, // 점자 빈칸
|
||||
0x3164, // 한글 채움 문자
|
||||
0xFE0F, // 이체 선택자
|
||||
0xFFA0, // 반각 한글 채움 문자
|
||||
0x1BCA0,
|
||||
0x1D173,
|
||||
0xE0001, // 태그 글자
|
||||
0xE0041,
|
||||
0xE01EF, // 이체 선택자 보충
|
||||
]) {
|
||||
expect(
|
||||
hasVisibleText(chars(<int>[code, code])),
|
||||
isFalse,
|
||||
reason: 'U+${code.toRadixString(16).toUpperCase()}',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('글자 · 기호 · 숫자 · 이모지가 하나라도 있으면 보인다', () {
|
||||
expect(hasVisibleText('가'), isTrue);
|
||||
expect(hasVisibleText('a'), isTrue);
|
||||
expect(hasVisibleText('.'), isTrue);
|
||||
expect(hasVisibleText('~'), isTrue);
|
||||
expect(hasVisibleText('0'), isTrue);
|
||||
expect(hasVisibleText(chars(<int>[0x1F600])), isTrue);
|
||||
// 이음(ZWJ)으로 묶은 이모지 — 이음 말고 그림 글자가 보인다
|
||||
expect(hasVisibleText(chars(<int>[0x1F468, 0x200D, 0x1F469])), isTrue);
|
||||
// 보이지 않는 글자들 뒤에 글자 하나
|
||||
expect(
|
||||
hasVisibleText(chars(<int>[0x00A0, 0x200B, 0xFEFF, 0x3164, 0x41])),
|
||||
isTrue,
|
||||
);
|
||||
// 보이지 않는 글자의 범위 바로 옆 글자는 보인다
|
||||
expect(hasVisibleText(chars(<int>[0x00A1])), isTrue); // ¡
|
||||
expect(hasVisibleText(chars(<int>[0x2010])), isTrue); // 붙임표
|
||||
expect(hasVisibleText(chars(<int>[0x2801])), isTrue); // 점자 점 하나
|
||||
expect(hasVisibleText(chars(<int>[0x3163])), isTrue); // ㅣ
|
||||
expect(hasVisibleText(chars(<int>[0x20000])), isTrue); // 한자 확장 B
|
||||
});
|
||||
}
|
||||
@@ -372,6 +372,141 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('본문에 보이는 것이 있는가 (없으면 읽기 화면에 본문 칸이 없다)', () {
|
||||
bool visibleHtml(String html, {String? text, bool imagesShown = false}) =>
|
||||
mailBodyFragment(
|
||||
htmlBody: html,
|
||||
textBody: text,
|
||||
imagesShown: imagesShown,
|
||||
).hasVisibleContent;
|
||||
|
||||
bool visibleText(String? text) => mailBodyFragment(
|
||||
htmlBody: null,
|
||||
textBody: text,
|
||||
imagesShown: false,
|
||||
).hasVisibleContent;
|
||||
|
||||
test('빈 줄 · nbsp · 제로폭 글자뿐인 서식 본문은 비었다', () {
|
||||
expect(visibleHtml('<div><br></div>'), isFalse);
|
||||
expect(visibleHtml('<p> </p>'), isFalse);
|
||||
// Gmail · Outlook 이 본문 없이 보낸 꼴 — 모르는 태그(o:p)는 지우고 안의 nbsp 만 남는다
|
||||
expect(visibleHtml('<div dir="ltr"><br></div><div><br></div>'), isFalse);
|
||||
expect(
|
||||
visibleHtml('<p class="MsoNormal"><o:p> </o:p></p>'),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<div style="font-family:Pretendard;color:#1F2733"><p></p></div>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
// 제로폭 빈칸 · BOM · WORD JOINER · 한글 채움 문자 · 점자 빈칸
|
||||
expect(
|
||||
visibleHtml('<p>​⁠ㅤ⠀</p>'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('내용째 버리는 것 · 주석 · 주소를 지운 그림은 보이지 않는다', () {
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<style>p{color:red}</style><!-- 주석 --><script>alert(1)</script>'
|
||||
'<title>제목</title>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<p><svg><text>그림 글</text></svg><template><b>숨은 글</b></template></p>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
// 막은 바깥 그림 · 외부 이미지를 보이지 않을 때의 중계 주소 — 주소를 지워 문서가 숨긴다(alt 도 보이지 않는다)
|
||||
expect(
|
||||
visibleHtml('<img src="https://t.example/p.png" width="600">'),
|
||||
isFalse,
|
||||
);
|
||||
expect(visibleHtml('<img src="/api/mail-images/t1" alt="배너">'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'꾸밈(style · bgcolor · border)만으로 그린 상자 · 선과 빈 인용의 막대는 보이는 것으로 치지 않는다',
|
||||
() {
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<table border="1" bgcolor="#EEEEEE"><tr>'
|
||||
'<td style="height:20px;background:#0E7C86;border:1px solid #000000"></td>'
|
||||
'</tr></table>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(visibleHtml('<blockquote><br></blockquote>'), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('글 · 그림 · 가로줄 · 목록 표시 · 펼침 · 따옴표가 하나라도 있으면 보인다', () {
|
||||
expect(visibleHtml('<p>안녕하세요</p>'), isTrue);
|
||||
expect(visibleHtml('<o:p>모르는 태그 안의 글</o:p>'), isTrue);
|
||||
expect(visibleHtml('<p> </p><p>.</p>'), isTrue);
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<p><br></p><img src="data:image/png;base64,iVBORw0KGgo=">',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
// 외부 이미지를 보일 때의 중계 그림 — 받기 전에도 보이는 것으로 친다
|
||||
expect(
|
||||
visibleHtml('<img src="/api/mail-images/t1">', imagesShown: true),
|
||||
isTrue,
|
||||
);
|
||||
expect(visibleHtml('<hr>'), isTrue);
|
||||
expect(visibleHtml('<ul><li></li></ul>'), isTrue);
|
||||
expect(visibleHtml('<details></details>'), isTrue);
|
||||
expect(visibleHtml('<q></q>'), isTrue);
|
||||
});
|
||||
|
||||
test('글자 본문 — 없거나 공백 · 보이지 않는 글자뿐이면 비었다', () {
|
||||
expect(visibleText(null), isFalse);
|
||||
expect(visibleText(''), isFalse);
|
||||
expect(visibleText(' \n\t\n'), isFalse);
|
||||
expect(
|
||||
visibleText(String.fromCharCodes(<int>[0x00A0, 0x200B, 0x0A, 0xFEFF])),
|
||||
isFalse,
|
||||
);
|
||||
expect(visibleText('본문'), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'고른 본문으로 본다 — 공백뿐인 서식 본문 · 한도를 넘은 서식 본문은 글자 본문, 비어 보이는 서식 본문은 그대로',
|
||||
() {
|
||||
expect(visibleHtml(' \n ', text: ''), isFalse);
|
||||
expect(visibleHtml(' \n ', text: '글자 본문'), isTrue);
|
||||
// 서식 본문이 비어 보여도 글자 본문으로 바꾸지 않는다(웹 displayedHtmlBody 와 같은 본문을 보인다)
|
||||
expect(visibleHtml('<div><br></div>', text: '글자 본문'), isFalse);
|
||||
|
||||
const int depth = 20000;
|
||||
final String deep = '${'<div>' * depth}${'</div>' * depth}';
|
||||
final SanitizedMailBody omitted = mailBodyFragment(
|
||||
htmlBody: deep,
|
||||
textBody: '',
|
||||
imagesShown: false,
|
||||
);
|
||||
expect(omitted.htmlOmitted, isTrue);
|
||||
expect(omitted.hasVisibleContent, isFalse);
|
||||
expect(visibleHtml(deep, text: '글자 본문'), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('깊거나 큰 본문도 되풀이 없이 정화하며 판정한다', () {
|
||||
const int depth = 1500;
|
||||
expect(visibleHtml('${'<div>' * depth}<br>${'</div>' * depth}'), isFalse);
|
||||
expect(visibleHtml('${'<div>' * depth}끝${'</div>' * depth}'), isTrue);
|
||||
// 맨 끝에만 글이 있는 본문
|
||||
expect(visibleHtml('${'<p> </p>' * 20000}<p>끝</p>'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('본문 조각 · 문서', () {
|
||||
test('HTML 이 없거나 공백뿐이면 글자 본문을 이스케이프해 보인다', () {
|
||||
final SanitizedMailBody text = mailBodyFragment(
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'package:arasystem_app/features/mail/presentation/providers/message_reade
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_body_view.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -246,6 +247,232 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('빈 본문 (첨부만 있는 메일)', () {
|
||||
const int fileCount = 12;
|
||||
|
||||
/// 첨부 [fileCount] 개 — 본문 칸은 [body] 로 덮는다(없으면 비었다)
|
||||
Map<String, Object?> manyFiles([
|
||||
Map<String, Object?> body = const <String, Object?>{},
|
||||
]) => <String, Object?>{
|
||||
'htmlBody': null,
|
||||
'textBody': '',
|
||||
...body,
|
||||
'attachments': <Object?>[
|
||||
for (int n = 1; n <= fileCount; n++)
|
||||
attachmentJson('a$n', name: 'file-$n.pdf', size: 2048),
|
||||
],
|
||||
};
|
||||
|
||||
/// 머리(제목 · 안내 줄 · 첨부)의 스크롤 칸
|
||||
Finder head() => find.descendant(
|
||||
of: find.byType(MessageReaderPage),
|
||||
matching: find.byType(SingleChildScrollView),
|
||||
);
|
||||
|
||||
Rect actionBar(WidgetTester tester) =>
|
||||
tester.getRect(find.byType(ReaderActionBar));
|
||||
|
||||
testWidgets('보이는 것이 없으면 본문 칸 없이 머리가 화면 전체를 한 스크롤로 — 마지막 첨부까지 닿는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Gmail 이 본문 없이 보낸 서식 본문 — 빈 줄뿐이다
|
||||
put(
|
||||
'm1',
|
||||
isRead: true,
|
||||
detail: manyFiles(<String, Object?>{
|
||||
'htmlBody': '<div dir="ltr"><br></div>',
|
||||
}),
|
||||
);
|
||||
await open(tester, 'm1');
|
||||
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
expect(h.webView.controllers, isEmpty);
|
||||
expect(
|
||||
find.text(MailReaderTexts.attachmentsTitle(fileCount)),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 머리가 아래 막대 위까지 다 쓴다(본문이 있으면 절반까지)
|
||||
final Rect scroll = tester.getRect(head());
|
||||
expect(scroll.bottom, moreOrLessEquals(actionBar(tester).top));
|
||||
|
||||
// 마지막 첨부는 처음에는 화면 밖 — 머리를 스크롤해 닿는다
|
||||
final Finder last = find.text('file-$fileCount.pdf');
|
||||
expect(last.hitTestable(), findsNothing);
|
||||
await tester.drag(head(), const Offset(0, -2000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final Rect row = tester.getRect(last);
|
||||
expect(row.top, greaterThanOrEqualTo(scroll.top));
|
||||
expect(row.bottom, lessThanOrEqualTo(scroll.bottom));
|
||||
// 본문 칸이 있었다면 머리가 닿지 못하는 아래 절반이다
|
||||
expect(
|
||||
row.center.dy,
|
||||
greaterThan(scroll.top + scroll.height * kReaderHeadMaxFraction),
|
||||
);
|
||||
// 닿은 첨부를 받아 연다
|
||||
h.mail.attachmentFiles['a$fileCount'] = () =>
|
||||
FakeResponse.bytes(<int>[1, 2], contentType: 'application/pdf');
|
||||
await tapTooltip(tester, MailReaderTexts.download('file-$fileCount.pdf'));
|
||||
expect(h.opener.opened.single.path, endsWith('/file-$fileCount.pdf'));
|
||||
});
|
||||
|
||||
testWidgets('본문이 있으면 지금처럼 — 머리는 남은 자리의 절반까지(그 안에서 스크롤), 나머지는 본문 칸', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
put(
|
||||
'm1',
|
||||
isRead: true,
|
||||
detail: manyFiles(<String, Object?>{'textBody': '본문'}),
|
||||
);
|
||||
await open(tester, 'm1');
|
||||
|
||||
expect(find.byType(MailBodyView), findsOneWidget);
|
||||
final Rect scroll = tester.getRect(head());
|
||||
final Rect body = tester.getRect(find.byType(MailBodyView));
|
||||
final Rect bar = actionBar(tester);
|
||||
expect(body.top, moreOrLessEquals(scroll.bottom));
|
||||
expect(body.bottom, moreOrLessEquals(bar.top));
|
||||
expect(
|
||||
scroll.height,
|
||||
moreOrLessEquals((bar.top - scroll.top) * kReaderHeadMaxFraction),
|
||||
);
|
||||
|
||||
// 마지막 첨부도 머리 안에서 스크롤해 닿는다 — 위 절반 안이다
|
||||
await tester.drag(head(), const Offset(0, -2000));
|
||||
await tester.pumpAndSettle();
|
||||
final Rect row = tester.getRect(find.text('file-$fileCount.pdf'));
|
||||
expect(row.bottom, lessThanOrEqualTo(scroll.bottom));
|
||||
expect(find.byType(MailBodyView), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('본문을 만드는 동안은 지금처럼 본문 자리에 진행 줄 — 다 만들어 비었으면 본문 칸이 없어진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
put('m1', isRead: true, detail: manyFiles());
|
||||
final Completer<SanitizedMailBody> building =
|
||||
Completer<SanitizedMailBody>();
|
||||
h.overrides.add(
|
||||
mailBodyBuilderProvider.overrideWithValue(
|
||||
({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
required bool imagesShown,
|
||||
required List<SearchRange> textHighlights,
|
||||
}) => building.future,
|
||||
),
|
||||
);
|
||||
await h.pump(tester);
|
||||
unawaited(h.router.push<void>(MailRoutes.message('m1')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(
|
||||
find.bySemanticsLabel(MailReaderTexts.bodyLoading),
|
||||
findsOneWidget,
|
||||
);
|
||||
final Rect loading = tester.getRect(head());
|
||||
expect(
|
||||
loading.height,
|
||||
moreOrLessEquals(
|
||||
(actionBar(tester).top - loading.top) * kReaderHeadMaxFraction,
|
||||
),
|
||||
);
|
||||
|
||||
building.complete(
|
||||
mailBodyFragment(htmlBody: null, textBody: '', imagesShown: false),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.bySemanticsLabel(MailReaderTexts.bodyLoading), findsNothing);
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
expect(
|
||||
tester.getRect(head()).bottom,
|
||||
moreOrLessEquals(actionBar(tester).top),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('빈 본문에서 [이미지 보기] — 그림이 생기면 본문 칸이 생기고 펼친 받는 사람은 그대로 남는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// 서버가 막은 바깥 그림을 지워 공백만 남은 서식 본문 — 글자 본문(빈 글)을 보인다
|
||||
put(
|
||||
'm1',
|
||||
isRead: true,
|
||||
to: <Map<String, Object?>>[addressJson('김아라', me)],
|
||||
detail: <String, Object?>{
|
||||
'htmlBody': '\n',
|
||||
'textBody': '',
|
||||
'hasBlockedImages': true,
|
||||
},
|
||||
);
|
||||
h.mail.shownImageDetails['m1'] = <String, Object?>{
|
||||
'htmlBody': '<img src="/api/mail-images/t1">',
|
||||
'hasBlockedImages': false,
|
||||
'imagesShown': true,
|
||||
};
|
||||
h.mail.mailImages['t1'] = FakeResponse.bytes(
|
||||
kTinyPng,
|
||||
contentType: 'image/png',
|
||||
);
|
||||
await open(tester, 'm1');
|
||||
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
expect(findCopy(MailReaderTexts.imagesBlocked), findsOneWidget);
|
||||
await tester.tap(
|
||||
find.text(MailReaderTexts.recipients(MailReaderTexts.me, 0)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(MailReaderTexts.fromLabel), findsOneWidget);
|
||||
|
||||
await tester.ensureVisible(find.text(MailReaderTexts.showImages));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(MailReaderTexts.showImages));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MailBodyView), findsOneWidget);
|
||||
expect(lastDocument(), contains('data:image/png;base64,'));
|
||||
expect(findCopy(MailReaderTexts.imagesBlocked), findsNothing);
|
||||
// 머리는 같은 자리의 같은 위젯이라 펼친 칸이 남는다
|
||||
expect(find.text(MailReaderTexts.fromLabel), findsOneWidget);
|
||||
expect(
|
||||
tester.getRect(head()).bottom,
|
||||
moreOrLessEquals(tester.getRect(find.byType(MailBodyView)).top),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('아래 막대가 없는 예약 메일 — 머리 끝에 시스템 내비게이션 막대 몫을 더해 마지막 첨부가 그 위에 온다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
put(
|
||||
's1',
|
||||
mailboxId: kSentId,
|
||||
isRead: true,
|
||||
pendingSend: pendingJson(
|
||||
kTestNow.add(const Duration(days: 1)),
|
||||
kind: 'scheduled',
|
||||
),
|
||||
detail: manyFiles(),
|
||||
);
|
||||
// 3버튼 내비게이션 막대(48) — 물리 픽셀(시험대는 3배)
|
||||
tester.view.padding = const FakeViewPadding(bottom: 48 * 3);
|
||||
addTearDown(tester.view.resetPadding);
|
||||
await open(tester, 's1', location: MailRoutes.mailbox(kSentId));
|
||||
|
||||
expect(find.byType(ReaderActionBar), findsNothing);
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
// 머리가 화면 아래 끝까지 닿는다 — 그 밑의 48 은 내비게이션 막대 자리
|
||||
expect(tester.getRect(head()).bottom, kPhoneSize.height);
|
||||
await tester.drag(head(), const Offset(0, -3000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 마지막 첨부 줄 전체가 내비게이션 막대 위에 온다
|
||||
final Rect row = tester.getRect(
|
||||
find.byKey(const ValueKey<String>('attachment-a$fileCount')),
|
||||
);
|
||||
expect(row.bottom, lessThanOrEqualTo(kPhoneSize.height - 48));
|
||||
});
|
||||
});
|
||||
|
||||
group('읽음 처리', () {
|
||||
testWidgets('열면 안 읽은 메일을 읽음으로 — 메일함 수를 먼저 줄이고 목록 줄은 바로 읽음', (
|
||||
WidgetTester tester,
|
||||
|
||||
Reference in New Issue
Block a user