feat(flutter): 앱 첫 판 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 검색 · 새 메일 알림 #21

Merged
ttipo merged 34 commits from feat/app-push into main 2026-11-08 16:34:38 +09:00
4 changed files with 123 additions and 26 deletions
Showing only changes of commit 3278e1abc1 - Show all commits
+78 -24
View File
@@ -39,39 +39,93 @@ const int _noJongseong = 0;
/// ㄹ 받침 — 방향 조사가 "로"다(서울로 · 메일로)
const int _jongseongRieul = 8;
/// 마지막 글자의 받침 번호 — 한글 음절로 끝나지 않으면(영문 · 숫자 · 빈 글) null
int? _lastJongseong(String word) {
/// 한글 호환 자모의 자음 ㄱ~ㅎ(겹자음 포함) · 모음 ㅏ~ㅣ (U+3131~U+3163)
const int _jamoConsonantFirst = 0x3131;
const int _jamoConsonantLast = 0x314E;
const int _jamoVowelFirst = 0x314F;
const int _jamoVowelLast = 0x3163;
/// 자음 ㄹ(리을) — 자음 가운데 방향 조사가 "로"인 것
const int _jamoRieul = 0x3139;
/// 숫자 0~9 (ASCII)
const int _digitZero = 0x30;
const int _digitNine = 0x39;
/// 끝소리 — 조사를 고르는 데 쓴다
enum _Coda {
/// 받침 없음 (프로젝트 · ㅏ · 2)
none,
/// ㄹ 받침 — 방향 조사가 "로"다 (메일 · ㄹ · 1)
rieul,
/// 그 밖의 받침 (휴지통 · ㄱ · 0)
other,
}
/// 숫자 0~9 의 끝소리 — 읽는 소리 영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구
const List<_Coda> _digitCodas = <_Coda>[
_Coda.other, // 영
_Coda.rieul, // 일
_Coda.none, // 이
_Coda.other, // 삼
_Coda.none, // 사
_Coda.none, // 오
_Coda.other, // 육
_Coda.rieul, // 칠
_Coda.rieul, // 팔
_Coda.none, // 구
];
/// 마지막 글자의 끝소리 — 앞뒤 공백은 떼고 본다(따옴표 · 괄호는 떼지 않는다). 소리를 알 수 없으면(라틴 글자 · 기호 · 빈 글) null
/// - 한글 음절: 그 받침
/// - 한글 호환 자모: 자음은 받침 있음(이름 기역 · 니은 · 리을 … 이 모두 받침으로 끝난다 — ㄹ 은 [_Coda.rieul]), 모음은 받침 없음
/// - 숫자: 읽는 소리로 ([_digitCodas])
/// (웹 `korean.ts` 와 같은 규칙 — 2026-11-07 자모 · 숫자를 함께 넓혔다)
_Coda? _lastCoda(String word) {
final String trimmed = word.trim();
if (trimmed.isEmpty) return null;
final int last = trimmed.runes.last;
if (!_isHangulSyllable(last)) return null;
return (last - _hangulFirst) % _jongseongCount;
if (_isHangulSyllable(last)) {
final int jongseong = (last - _hangulFirst) % _jongseongCount;
if (jongseong == _noJongseong) return _Coda.none;
return jongseong == _jongseongRieul ? _Coda.rieul : _Coda.other;
}
if (last >= _jamoConsonantFirst && last <= _jamoConsonantLast) {
return last == _jamoRieul ? _Coda.rieul : _Coda.other;
}
if (last >= _jamoVowelFirst && last <= _jamoVowelLast) return _Coda.none;
if (last >= _digitZero && last <= _digitNine) {
return _digitCodas[last - _digitZero];
}
return null;
}
/// 주격 조사 "이/가" — 받침이 있으면 "이", 없으면 "가"(휴지통이 · 받은편지함이 · 프로젝트가).
/// 한글로 끝나지 않는 이름(영문 · 숫자)은 소리를 알 수 없어 "이(가)" 다 (웹 `korean.ts` 의 `subjectParticle` 과 같다).
String subjectParticle(String word) {
final int? jongseong = _lastJongseong(word);
if (jongseong == null) return '이(가)';
return jongseong == _noJongseong ? '가' : '이';
}
/// 주격 조사 "이/가" — 받침이 있으면 "이", 없으면 "가"(휴지통이 · 받은편지함이 · 프로젝트가 · ‘ㄱㅁ’이 · 2가).
/// 소리를 알 수 없는 끝 글자(라틴 글자 · 기호)면 "이(가)" 다 (웹 `korean.ts` 의 `subjectParticle` 과 같다).
String subjectParticle(String word) => switch (_lastCoda(word)) {
null => '이(가)',
_Coda.none => '가',
_Coda.rieul || _Coda.other => '이',
};
/// 방향 조사 "으로/로" — 받침이 있으면 "으로", 없거나 ㄹ 받침이면 "로"(휴지통으로 · 보낸편지함으로 · 프로젝트로).
/// 한글로 끝나지 않는 이름은 "(으)로" 다 (웹 `korean.ts` 의 `directionParticle` 과 같다).
String directionParticle(String word) {
final int? jongseong = _lastJongseong(word);
if (jongseong == null) return '(으)로';
return jongseong == _noJongseong || jongseong == _jongseongRieul ? '로' : '으로';
}
/// 방향 조사 "으로/로" — 받침이 있으면 "으로", 없거나 ㄹ 받침이면 "로"(휴지통으로 · 보낸편지함으로 · 프로젝트로 · 프로젝트1로).
/// 소리를 알 수 없는 끝 글자면 "(으)로" 다 (웹 `korean.ts` 의 `directionParticle` 과 같다).
String directionParticle(String word) => switch (_lastCoda(word)) {
null => '(으)로',
_Coda.none || _Coda.rieul => '로',
_Coda.other => '으로',
};
/// 보조사 "은/는" — 받침이 있으면 "은", 없으면 "는"(사진은 · 보고서는 · 외 2개는).
/// 한글로 끝나지 않는 이름(영문 · 숫자 — 확장자로 끝나는 파일 이름)은 소리를 알 수 없어 "은(는)" 이다
/// 소리를 알 수 없는 끝 글자(라틴 글자 · 기호 — 확장자로 끝나는 파일 이름)면 "은(는)" 이다
/// (앱에서 더함 — 웹 `korean.ts` 에는 없다. [subjectParticle] 과 같은 규칙)
String topicParticle(String word) {
final int? jongseong = _lastJongseong(word);
if (jongseong == null) return '은(는)';
return jongseong == _noJongseong ? '는' : '은';
}
String topicParticle(String word) => switch (_lastCoda(word)) {
null => '은(는)',
_Coda.none => '는',
_Coda.rieul || _Coda.other => '은',
};
/// 띄어쓰기 · 제어 문자를 뺀 ASCII 글자 — 영문 · 숫자 · 기호
bool _isAsciiGraphic(int rune) => rune >= 0x21 && rune <= 0x7E;
@@ -87,7 +87,7 @@ abstract final class MailComposeTexts {
'$limit보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.';
/// 합계 한도를 넘게 만드는 파일 — "첨부를 모두 합치면 25MB를 넘어 ‘사진.jpg’은(는) 넣지 못했습니다. …" (계획).
/// 여럿이면 "‘사진.jpg’ 외 2개는"(앱에서 더함). 조사는 `topicParticle` — 확장자로 끝나는 이름은 소리를 몰라 "은(는)"
/// 여럿이면 "‘사진.jpg’ 외 2개는"(앱에서 더함). 조사는 `topicParticle` — 라틴 글자 확장자로 끝나는 이름은 소리를 몰라 "은(는)"(숫자로 끝나면 그 숫자의 소리로)
static String overTotal(String total, String name, {int others = 0}) {
final String target = others > 0
? '‘$name’ 외 ${formatCount(others)}개'
@@ -250,7 +250,7 @@ void main() {
);
});
test('은/는 — 받침에 맞추고, 한글로 끝나지 않으면 "은(는)"', () {
test('은/는 — 받침에 맞추고, 소리를 모르는 끝 글자(라틴 글자 · 기호)면 "은(는)"', () {
expect(topicParticle('사진'), '은');
expect(topicParticle('보고서'), '는');
expect(topicParticle('외 2개'), '는');
@@ -108,6 +108,49 @@ void main() {
expect(directionParticle('프로젝트'), '로');
expect(directionParticle('Archive'), '(으)로');
});
test('한글 자모 · 숫자는 읽는 소리로 — 자음은 받침 있음(ㄹ 은 "로"), 모음은 받침 없음', () {
// 끝 글자 → 이/가 · 으로/로 · 은/는
for (final (String last, String subject, String direction, String topic)
in <(String, String, String, String)>[
('ㄱ', '이', '으로', '은'), // 기역
('ㄹ', '이', '로', '은'), // 리을
('ㅁ', '이', '으로', '은'), // 미음
('ㄳ', '이', '으로', '은'), // 기역시옷 — 겹자음도 받침 있음
('ㅏ', '가', '로', '는'), // 아
('0', '이', '으로', '은'), // 영
('1', '이', '로', '은'), // 일
('2', '가', '로', '는'), // 이
('3', '이', '으로', '은'), // 삼
('4', '가', '로', '는'), // 사
('5', '가', '로', '는'), // 오
('6', '이', '으로', '은'), // 육
('7', '이', '로', '은'), // 칠
('8', '이', '로', '은'), // 팔
('9', '가', '로', '는'), // 구
]) {
expect(subjectParticle(last), subject, reason: last);
expect(directionParticle(last), direction, reason: last);
expect(topicParticle(last), topic, reason: last);
}
// 낱말의 끝 글자로 본다 — 초성 검색어 · 번호로 끝나는 이름
expect(directionParticle('ㄱㅁ'), '으로');
expect(subjectParticle('ㄱㅁ'), '이');
expect(directionParticle('프로젝트1'), '로');
expect(subjectParticle('2026'), '이');
// 라틴 글자 · 기호는 소리를 몰라 지금처럼
expect(subjectParticle('ARA'), '이(가)');
expect(directionParticle('100%'), '(으)로');
expect(topicParticle('v'), '은(는)');
});
test('끝의 공백은 떼고 보고, 따옴표 · 괄호는 떼지 않는다 — 그 글자로 끝나면 소리를 몰라 "이(가)" 꼴', () {
expect(subjectParticle('계약 '), '이');
expect(directionParticle('ㄹ '), '로');
expect(subjectParticle('계약)'), '이(가)');
expect(subjectParticle('"계약"'), '이(가)');
expect(directionParticle('(2)'), '(으)로');
});
});
group('여러 통 처리의 알림 글 (웹 mail-bulk.ts 와 같다)', () {