generated from ttipo/project-base
feat(flutter): 사람 고르기(core/people) — 주소 칩 · 받는 사람 추천 맞추기를 더한다
쓰기 화면의 받는 사람 칸이 쓰고, 나중의 캘린더 · 파일 모듈도 함께 쓰게 core 에 둔다. 주소 검사는 웹 mail-address.ts, 추천 맞추기(초성 · 겹자음 · 조합 중 글자)는 웹 recipient-match.ts 와 짝이고, 표본 표는 웹 · 백엔드와 같은 70행을 같은 차례로 옮겼다(세 쪽 짝 — 한쪽을 고치면 모두). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
/// 받는 사람 칸의 주소 — 나누기 · 읽기 · 검사 · 겹침 (순수 함수).
|
||||
///
|
||||
/// **짝: 웹 `frontend/src/shared/utils/mail-address.ts`** — 같은 정규식 · 같은 차례로 옮겼다(한쪽을 고치면 둘 다).
|
||||
/// 옮긴 것: `isValidEmail` · `normalizeEmail` · `sameAddress` · `uniqueAddresses` · `formatAddress` · `formatAddressList` ·
|
||||
/// `parseRecipient` · `tokenFromAddress` · `hasOpenRecipientGroup` · `splitRecipientInput` · `recipientTokenKey` · `mergeRecipientTokens`.
|
||||
/// 저장용 엄격 검사(`isStrictEmail` — 주소록 · 필터)는 쓰는 화면이 없어 옮기지 않았다.
|
||||
/// 앱에만 있는 것: [splitPendingRecipients] — 웹은 키(쉼표 · Enter)를 받아 그 자리에서 나누지만, 휴대폰 자판은 키를 알려 주지 않아
|
||||
/// 칸의 글이 바뀔 때마다 구분자까지를 떼어 낸다.
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/people/person_address.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 형식 검사 — 화면은 이 기준으로 바로 보이고 마지막 판단은 서버가 한다(`MAIL_002`)
|
||||
final RegExp _emailPattern = RegExp(r'^[^@\s<>]+@[^@\s<>]+\.[^@\s<>]+$');
|
||||
|
||||
/// "이름 <주소>" 꼴
|
||||
final RegExp _namedAddressPattern = RegExp(r'^(.*?)\s*<([^<>]*)>$');
|
||||
|
||||
/// 여러 주소를 한 번에 넣을 때의 구분자
|
||||
const Set<int> _separators = <int>{0x2C, 0x3B, 0x0A, 0x0D}; // , ; \n \r
|
||||
|
||||
const int _quote = 0x22; // "
|
||||
const int _angleOpen = 0x3C; // <
|
||||
const int _angleClose = 0x3E; // >
|
||||
|
||||
bool isValidEmail(String email) => _emailPattern.hasMatch(email.trim());
|
||||
|
||||
String normalizeEmail(String email) => email.trim().toLowerCase();
|
||||
|
||||
bool sameAddress(PersonAddress a, PersonAddress b) =>
|
||||
normalizeEmail(a.email) == normalizeEmail(b.email);
|
||||
|
||||
/// 같은 주소(대소문자 무시)는 처음 것만 남긴다
|
||||
List<PersonAddress> uniqueAddresses(Iterable<PersonAddress> list) {
|
||||
final Set<String> seen = <String>{};
|
||||
return <PersonAddress>[
|
||||
for (final PersonAddress address in list)
|
||||
if (seen.add(normalizeEmail(address.email))) address,
|
||||
];
|
||||
}
|
||||
|
||||
/// "이름 <주소>" — 이름이 없으면 주소만
|
||||
String formatAddress(PersonAddress address) {
|
||||
final String? name = address.name?.trim();
|
||||
return name != null && name.isNotEmpty
|
||||
? '$name <${address.email}>'
|
||||
: address.email;
|
||||
}
|
||||
|
||||
String formatAddressList(Iterable<PersonAddress> list) =>
|
||||
list.map(formatAddress).join(', ');
|
||||
|
||||
/// 받는 사람 칸의 칩 하나
|
||||
@immutable
|
||||
class RecipientToken {
|
||||
const RecipientToken({
|
||||
required this.label,
|
||||
required this.address,
|
||||
required this.valid,
|
||||
});
|
||||
|
||||
/// 칩에 보이는 글 — 넣은 그대로
|
||||
final String label;
|
||||
final PersonAddress address;
|
||||
|
||||
/// 형식이 맞는가 — 틀리면 칩을 빨갛게 보인다
|
||||
final bool valid;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is RecipientToken &&
|
||||
other.label == label &&
|
||||
other.address == address &&
|
||||
other.valid == valid;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(label, address, valid);
|
||||
}
|
||||
|
||||
String _unquote(String value) {
|
||||
final String trimmed = value.trim();
|
||||
final bool quoted =
|
||||
trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"');
|
||||
return quoted ? trimmed.substring(1, trimmed.length - 1).trim() : trimmed;
|
||||
}
|
||||
|
||||
/// 넣은 글 한 덩어리 → 칩. 빈 글이면 null — "이름 <주소>" · "\"이름\" <주소>" · "<주소>" · "주소" 를 받는다
|
||||
RecipientToken? parseRecipient(String raw) {
|
||||
final String label = raw.trim();
|
||||
if (label.isEmpty) return null;
|
||||
final RegExpMatch? named = _namedAddressPattern.firstMatch(label);
|
||||
final String email = (named?.group(2) ?? label).trim();
|
||||
final String name = named == null ? '' : _unquote(named.group(1) ?? '');
|
||||
return RecipientToken(
|
||||
label: label,
|
||||
address: PersonAddress(email: email, name: name.isEmpty ? null : name),
|
||||
valid: isValidEmail(email),
|
||||
);
|
||||
}
|
||||
|
||||
/// 미리 채울 때(답장 · 저장본 · 추천) — 서버가 준 주소를 칩으로
|
||||
RecipientToken tokenFromAddress(PersonAddress address) => RecipientToken(
|
||||
label: formatAddress(address),
|
||||
address: address,
|
||||
valid: isValidEmail(address.email),
|
||||
);
|
||||
|
||||
/// 따옴표나 꺾쇠가 열린 채인가 — 그 안의 쉼표는 구분자가 아니다(`"Kim, Ara" <a@b.kr>`)
|
||||
bool hasOpenRecipientGroup(String text) {
|
||||
bool inQuotes = false;
|
||||
bool inAngle = false;
|
||||
for (final int rune in text.runes) {
|
||||
if (rune == _quote && !inAngle) {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (rune == _angleOpen && !inQuotes) {
|
||||
inAngle = true;
|
||||
} else if (rune == _angleClose && !inQuotes) {
|
||||
inAngle = false;
|
||||
}
|
||||
}
|
||||
return inQuotes || inAngle;
|
||||
}
|
||||
|
||||
/// 붙여 넣은 글을 주소 단위로 나눈다(따옴표 · 꺾쇠 안의 구분자는 무시) — 앞뒤 공백을 빼고 빈 조각은 버린다
|
||||
List<String> splitRecipientInput(String text) {
|
||||
final List<String> parts = <String>[];
|
||||
final StringBuffer current = StringBuffer();
|
||||
bool inQuotes = false;
|
||||
bool inAngle = false;
|
||||
for (final int rune in text.runes) {
|
||||
if (rune == _quote && !inAngle) {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (rune == _angleOpen && !inQuotes) {
|
||||
inAngle = true;
|
||||
} else if (rune == _angleClose && !inQuotes) {
|
||||
inAngle = false;
|
||||
}
|
||||
if (_separators.contains(rune) && !inQuotes && !inAngle) {
|
||||
parts.add(current.toString());
|
||||
current.clear();
|
||||
continue;
|
||||
}
|
||||
current.writeCharCode(rune);
|
||||
}
|
||||
parts.add(current.toString());
|
||||
return <String>[
|
||||
for (final String part in parts)
|
||||
if (part.trim().isNotEmpty) part.trim(),
|
||||
];
|
||||
}
|
||||
|
||||
/// 칸의 글에서 구분자(따옴표 · 꺾쇠 밖의 `,` `;` 줄바꿈)까지를 떼어 낸다 — 다 쓴 주소들과 이어 쓰는 글.
|
||||
/// 구분자가 없으면 다 쓴 주소가 없다(글 그대로 이어 쓴다). 이어 쓰는 글의 앞 공백은 지운다("a@b.kr, " → 다 쓴 주소 하나 · 빈 글)
|
||||
({List<String> complete, String rest}) splitPendingRecipients(String text) {
|
||||
bool inQuotes = false;
|
||||
bool inAngle = false;
|
||||
int last = -1;
|
||||
for (int index = 0; index < text.length; index++) {
|
||||
final int unit = text.codeUnitAt(index);
|
||||
if (unit == _quote && !inAngle) {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (unit == _angleOpen && !inQuotes) {
|
||||
inAngle = true;
|
||||
} else if (unit == _angleClose && !inQuotes) {
|
||||
inAngle = false;
|
||||
} else if (_separators.contains(unit) && !inQuotes && !inAngle) {
|
||||
last = index;
|
||||
}
|
||||
}
|
||||
if (last < 0) return (complete: const <String>[], rest: text);
|
||||
return (
|
||||
complete: splitRecipientInput(text.substring(0, last)),
|
||||
rest: text.substring(last + 1).trimLeft(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 겹침을 가르는 키 — 형식이 맞으면 주소(대소문자 무시), 틀리면 넣은 글
|
||||
String recipientTokenKey(RecipientToken token) =>
|
||||
token.valid ? normalizeEmail(token.address.email) : token.label;
|
||||
|
||||
/// 이미 있는 칩과 겹치는 것은 빼고 뒤에 붙인다
|
||||
List<RecipientToken> mergeRecipientTokens(
|
||||
List<RecipientToken> existing,
|
||||
Iterable<RecipientToken> incoming,
|
||||
) {
|
||||
final Set<String> seen = existing.map(recipientTokenKey).toSet();
|
||||
final List<RecipientToken> merged = <RecipientToken>[...existing];
|
||||
for (final RecipientToken token in incoming) {
|
||||
if (seen.add(recipientTokenKey(token))) merged.add(token);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/// 사람 고르기(받는 사람 추천)의 화면 글 — 웹 상수(`frontend/src/shared/constants/contacts.ts` 의 `RECIPIENT_CONTACT_TEXT` ·
|
||||
/// `CONTACT_EMAIL_LABEL_TEXT`, `RecipientSuggestionList.vue` 의 묶음 이름)를 그대로 옮겼다 — 웹과 앱을 함께 쓰는 직원에게 같은 말.
|
||||
/// 말투는 디자인 README 「화면 문구 — 사용자에게 말하듯」.
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/people/recipient_match.dart';
|
||||
import 'package:arasystem_app/core/people/recipient_suggestion.dart';
|
||||
import 'package:arasystem_app/core/utils/korean_format.dart';
|
||||
|
||||
abstract final class PeopleTexts {
|
||||
/// 추천 목록의 묶음 이름 — 사내와 최근 주고받은 주소를 나눠 보인다(계약 2절 — 바깥 사람이 동료 이름을 쓰는 경우)
|
||||
static String groupLabel(RecipientOptionGroup group) => switch (group) {
|
||||
RecipientOptionGroup.contact => '주소록',
|
||||
RecipientOptionGroup.group => '그룹',
|
||||
RecipientOptionGroup.directory => '사내',
|
||||
RecipientOptionGroup.recent => '최근 주고받은 주소',
|
||||
};
|
||||
|
||||
/// 주소록 줄의 주소 종류
|
||||
static String emailLabel(ContactEmailLabel label) => switch (label) {
|
||||
ContactEmailLabel.work => '회사',
|
||||
ContactEmailLabel.personal => '개인',
|
||||
ContactEmailLabel.other => '기타',
|
||||
};
|
||||
|
||||
/// 그룹 줄의 설명 — "고르면 4명을 한꺼번에 넣습니다"
|
||||
static String groupDetail(int count) =>
|
||||
'고르면 ${formatCount(count)}명을 한꺼번에 넣습니다';
|
||||
|
||||
/// 구성원을 받는 동안 칸 아래 글
|
||||
static String groupLoading(String group) => '‘$group’ 그룹의 구성원을 불러오는 중…';
|
||||
|
||||
/// 그룹을 넣은 뒤 칸 아래 글 — "‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다. 주소가 없는 1명은 뺐습니다."
|
||||
static String groupInserted(
|
||||
String group,
|
||||
String field, {
|
||||
required int added,
|
||||
required int withoutEmail,
|
||||
required int already,
|
||||
}) {
|
||||
final String head = added > 0
|
||||
? '‘$group’ 그룹의 ${formatCount(added)}명을 $field에 넣었습니다.'
|
||||
: '‘$group’ 그룹에서 $field에 더 넣을 주소가 없습니다.';
|
||||
final List<String> skipped = <String>[
|
||||
if (withoutEmail > 0) '주소가 없는 ${formatCount(withoutEmail)}명',
|
||||
if (already > 0) '이미 넣은 ${formatCount(already)}명',
|
||||
];
|
||||
return skipped.isEmpty ? head : '$head ${skipped.join(' · ')}은 뺐습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
/// 주소록 줄의 주소 뒤 설명 — 회사 주소는 회사 이름, 그 밖은 종류("개인"). 주소록 줄이 아니면 null
|
||||
/// (짝: 웹 `shared/utils/contacts.ts` 의 `contactSuggestionDetail`)
|
||||
String? contactSuggestionDetail(RecipientSuggestion suggestion) {
|
||||
if (suggestion.source != RecipientSource.contact) return null;
|
||||
final String? company = suggestion.company?.trim();
|
||||
final String? shownCompany = company == null || company.isEmpty
|
||||
? null
|
||||
: company;
|
||||
final ContactEmailLabel? label = suggestion.emailLabel;
|
||||
if (label == null) return shownCompany;
|
||||
if (label == ContactEmailLabel.work) {
|
||||
return shownCompany ?? PeopleTexts.emailLabel(label);
|
||||
}
|
||||
return PeopleTexts.emailLabel(label);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 사람 한 명의 이름 · 메일 주소 — 받는 사람 칸 · 받는 사람 추천 · 그룹 구성원이 함께 쓴다(모듈 공통 — 메일 · 나중의 주소록).
|
||||
///
|
||||
/// 메일 모듈의 `MailAddress`(계약 2절 `Address`)와 같은 모양이다. 모듈끼리 import 하지 않으려고 core 에 따로 둔다
|
||||
/// (flutter 스킬 "모듈끼리 서로 import 하지 않는다" — 사람 고르기는 core).
|
||||
@immutable
|
||||
class PersonAddress {
|
||||
const PersonAddress({required this.email, this.name});
|
||||
|
||||
/// 보이는 이름 — 없으면 null
|
||||
final String? name;
|
||||
final String email;
|
||||
|
||||
/// 계약의 `Address` 꼴 `{ name, email }`
|
||||
Map<String, Object?> toJson() => <String, Object?>{
|
||||
'name': name,
|
||||
'email': email,
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is PersonAddress && other.email == email && other.name == name;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(email, name);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/// 받는 사람 추천의 거르기 — 순수 함수.
|
||||
///
|
||||
/// **짝 셋: 웹 `frontend/src/shared/utils/recipient-match.ts` · 백엔드 `backend/src/modules/mail/recipient-match.ts`(서버의 `q` 맞추기).**
|
||||
/// 같은 규칙을 그대로 옮겼다 — 표본 표도 같다(웹 `frontend/src/test-utils/recipient-match-samples.ts` · 백엔드 spec 의 표 ·
|
||||
/// 앱 `test/core/people/recipient_match_samples.dart` — 행과 차례까지 같다. 한쪽을 고치면 셋 다).
|
||||
///
|
||||
/// - 이름 · 주소에 친 글자가 들어 있으면 맞는다(대소문자 무시). 맞은 부분은 굵게 보이도록 범위를 준다.
|
||||
/// - 이름은 초성으로도 찾는다: ㄷㅇ → 이"도윤". 초성과 글자를 섞어도 된다: 도ㅇ → 이"도윤".
|
||||
/// - 마지막 글자는 아직 조합 중일 수 있다 — "이도유"(윤을 치는 중) · "이동"(윤의 ㅇ 이 받침으로 붙은 순간)도 "이도윤"에 맞는다.
|
||||
/// Flutter 는 칸의 글(`TextEditingController.text`)에 조합 중인 글자가 들어 있어 그대로 맞춘다.
|
||||
/// - 친 글자가 완전한 주소면 같은 아이디(@ 앞)의 주소도 보인다 — 쓰기 화면은 그때 아무것도 미리 고르지 않는다.
|
||||
/// - 이미 넣은 주소는 뺀다. 묶음은 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 묶음 안은 받은 차례 그대로, 모두 합쳐 최대 8개.
|
||||
/// - 대소문자는 JS 의 `toLowerCase` 와 같게 낮춘다([jsLowerCase] — Dart 는 'İ' 를 한 글자로 낮춘다). 그리스 문자의 낱말 끝 시그마(ς)는 가르지 않는다(알려진 한계).
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/people/address_input.dart';
|
||||
import 'package:arasystem_app/core/people/recipient_suggestion.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 추천 목록에 보이는 최대 개수
|
||||
const int kRecipientSuggestionLimit = 8;
|
||||
|
||||
/// 글자 범위 — UTF-16 위치, [end] 는 들지 않는다
|
||||
@immutable
|
||||
class MatchRange {
|
||||
const MatchRange(this.start, this.end);
|
||||
|
||||
final int start;
|
||||
final int end;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MatchRange && other.start == start && other.end == end;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(start, end);
|
||||
|
||||
@override
|
||||
String toString() => 'MatchRange($start, $end)';
|
||||
}
|
||||
|
||||
/// 친 글자에 맞은 추천 하나
|
||||
@immutable
|
||||
class RecipientMatch {
|
||||
const RecipientMatch({required this.suggestion, this.name, this.email});
|
||||
|
||||
final RecipientSuggestion suggestion;
|
||||
|
||||
/// 이름에서 맞은 범위 — 없으면 null
|
||||
final MatchRange? name;
|
||||
|
||||
/// 주소에서 맞은 범위 — 없으면 null
|
||||
final MatchRange? email;
|
||||
}
|
||||
|
||||
/// 그룹 이름에 맞은 그룹
|
||||
@immutable
|
||||
class RecipientGroupMatch {
|
||||
const RecipientGroupMatch({required this.group, required this.name});
|
||||
|
||||
final ContactGroupSummary group;
|
||||
final MatchRange name;
|
||||
}
|
||||
|
||||
/// 추천 목록의 한 줄 — 주소 하나 또는 그룹 하나
|
||||
sealed class RecipientOption {
|
||||
const RecipientOption();
|
||||
}
|
||||
|
||||
final class AddressOption extends RecipientOption {
|
||||
const AddressOption(this.match);
|
||||
|
||||
final RecipientMatch match;
|
||||
}
|
||||
|
||||
final class GroupOption extends RecipientOption {
|
||||
const GroupOption(this.match);
|
||||
|
||||
final RecipientGroupMatch match;
|
||||
}
|
||||
|
||||
/// 목록의 묶음 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소
|
||||
enum RecipientOptionGroup { contact, group, directory, recent }
|
||||
|
||||
RecipientOptionGroup recipientOptionGroupOf(RecipientOption option) =>
|
||||
switch (option) {
|
||||
GroupOption() => RecipientOptionGroup.group,
|
||||
AddressOption(:final RecipientMatch match) =>
|
||||
switch (match.suggestion.source) {
|
||||
RecipientSource.contact => RecipientOptionGroup.contact,
|
||||
RecipientSource.directory => RecipientOptionGroup.directory,
|
||||
RecipientSource.recent => RecipientOptionGroup.recent,
|
||||
},
|
||||
};
|
||||
|
||||
/// 굵게 보일 조각 — 맞은 부분만 [strong]
|
||||
typedef HighlightPart = ({String text, bool strong});
|
||||
|
||||
// ── 한글 ─────────────────────────────────────────────────
|
||||
|
||||
const int _syllableStart = 0xAC00;
|
||||
const int _syllableEnd = 0xD7A3;
|
||||
const int _medialCount = 21;
|
||||
const int _finalCount = 28;
|
||||
|
||||
/// 초성 — 호환 자모
|
||||
const List<String> _initials = <String>[
|
||||
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', //
|
||||
'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ',
|
||||
];
|
||||
|
||||
/// 받침 — 0 은 받침 없음
|
||||
const List<String> _finals = <String>[
|
||||
'', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', //
|
||||
'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', //
|
||||
'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ',
|
||||
];
|
||||
|
||||
/// 겹받침을 나눈다 — 뒤 자음이 다음 글자의 초성으로 넘어가는 순간이 있다(닭 → 달 + ㄱ)
|
||||
const Map<String, (String, String)> _splitFinals = <String, (String, String)>{
|
||||
'ㄳ': ('ㄱ', 'ㅅ'),
|
||||
'ㄵ': ('ㄴ', 'ㅈ'),
|
||||
'ㄶ': ('ㄴ', 'ㅎ'),
|
||||
'ㄺ': ('ㄹ', 'ㄱ'),
|
||||
'ㄻ': ('ㄹ', 'ㅁ'),
|
||||
'ㄼ': ('ㄹ', 'ㅂ'),
|
||||
'ㄽ': ('ㄹ', 'ㅅ'),
|
||||
'ㄾ': ('ㄹ', 'ㅌ'),
|
||||
'ㄿ': ('ㄹ', 'ㅍ'),
|
||||
'ㅀ': ('ㄹ', 'ㅎ'),
|
||||
'ㅄ': ('ㅂ', 'ㅅ'),
|
||||
};
|
||||
|
||||
typedef _Syllable = ({int initial, int medial, int finalIndex});
|
||||
|
||||
_Syllable? _syllableOf(String char) {
|
||||
if (char.length != 1) return null;
|
||||
final int code = char.codeUnitAt(0);
|
||||
if (code < _syllableStart || code > _syllableEnd) return null;
|
||||
final int offset = code - _syllableStart;
|
||||
return (
|
||||
initial: offset ~/ (_medialCount * _finalCount),
|
||||
medial: (offset % (_medialCount * _finalCount)) ~/ _finalCount,
|
||||
finalIndex: offset % _finalCount,
|
||||
);
|
||||
}
|
||||
|
||||
String _composeSyllable(int initial, int medial, int finalIndex) =>
|
||||
String.fromCharCode(
|
||||
_syllableStart +
|
||||
(initial * _medialCount + medial) * _finalCount +
|
||||
finalIndex,
|
||||
);
|
||||
|
||||
bool _isInitialJamo(String char) => _initials.contains(char);
|
||||
|
||||
String? _initialOf(String char) {
|
||||
final _Syllable? syllable = _syllableOf(char);
|
||||
return syllable == null ? null : _initials[syllable.initial];
|
||||
}
|
||||
|
||||
/// JS 의 `String.prototype.toLowerCase` 와 같은 결과 — 'İ'(U+0130) 는 두 글자(i + 윗점 U+0307)로 낮춘다.
|
||||
/// Dart 의 `toLowerCase` 는 'i' 한 글자로 낮춰, 주소 길이가 달라지는 글자를 웹 · 서버와 다르게 맞추게 된다
|
||||
String jsLowerCase(String value) => value.replaceAll('İ', 'i̇').toLowerCase();
|
||||
|
||||
/// 글자(코드 포인트) 단위로 나눈다 — 이모지 같은 짝 글자를 가르지 않는다(JS `Array.from`)
|
||||
List<String> _codePoints(String value) => <String>[
|
||||
for (final int rune in value.runes) String.fromCharCode(rune),
|
||||
];
|
||||
|
||||
// ── 맞추기 ───────────────────────────────────────────────
|
||||
|
||||
/// 친 글자 하나가 이름 글자 하나에 맞는가 — 같은 글자(대소문자 무시) 또는 초성
|
||||
bool _matchesChar(String typed, String char) {
|
||||
if (jsLowerCase(typed) == jsLowerCase(char)) return true;
|
||||
return _isInitialJamo(typed) && _initialOf(char) == typed;
|
||||
}
|
||||
|
||||
/// 마지막으로 친 글자 — 조합 중일 수 있다. 맞으면 이름에서 쓴 글자 수(1 · 2), 아니면 0
|
||||
int _matchesLastChar(String typed, String char, String? next) {
|
||||
if (_matchesChar(typed, char)) return 1;
|
||||
final _Syllable? typedSyllable = _syllableOf(typed);
|
||||
final _Syllable? syllable = _syllableOf(char);
|
||||
if (typedSyllable == null || syllable == null) return 0;
|
||||
if (typedSyllable.initial != syllable.initial ||
|
||||
typedSyllable.medial != syllable.medial) {
|
||||
return 0;
|
||||
}
|
||||
if (typedSyllable.finalIndex == 0) return 1;
|
||||
if (next == null) return 0;
|
||||
final String finalJamo = _finals[typedSyllable.finalIndex];
|
||||
final (String rest, String moved) =
|
||||
_splitFinals[finalJamo] ?? ('', finalJamo);
|
||||
final String restSyllable = _composeSyllable(
|
||||
typedSyllable.initial,
|
||||
typedSyllable.medial,
|
||||
_finals.indexOf(rest),
|
||||
);
|
||||
return restSyllable == char && _initialOf(next) == moved ? 2 : 0;
|
||||
}
|
||||
|
||||
/// chars[start] 부터 친 글자가 맞으면 맞은 끝(들지 않는 위치), 아니면 null
|
||||
int? _matchFrom(List<String> typed, List<String> chars, int start) {
|
||||
int index = start;
|
||||
for (int at = 0; at < typed.length; at++) {
|
||||
if (index >= chars.length) return null;
|
||||
final String char = chars[index];
|
||||
final String typedChar = typed[at];
|
||||
if (at == typed.length - 1) {
|
||||
final String? next = index + 1 < chars.length ? chars[index + 1] : null;
|
||||
final int used = _matchesLastChar(typedChar, char, next);
|
||||
return used == 0 ? null : index + used;
|
||||
}
|
||||
if (!_matchesChar(typedChar, char)) return null;
|
||||
index++;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/// 글자 단위 위치 → UTF-16 위치
|
||||
int _utf16Offset(List<String> chars, int index) {
|
||||
int offset = 0;
|
||||
for (int at = 0; at < index; at++) {
|
||||
offset += chars[at].length;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
/// 이름에서 친 글자(초성 · 조합 중 포함)가 처음 맞는 범위
|
||||
MatchRange? matchName(String query, String name) {
|
||||
final List<String> typed = _codePoints(query.trim());
|
||||
final List<String> chars = _codePoints(name);
|
||||
if (typed.isEmpty) return null;
|
||||
for (int start = 0; start < chars.length; start++) {
|
||||
final int? end = _matchFrom(typed, chars, start);
|
||||
if (end != null) {
|
||||
return MatchRange(_utf16Offset(chars, start), _utf16Offset(chars, end));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 주소에서 친 글자가 처음 들어 있는 범위(대소문자 무시). 완전한 주소를 쳤으면 아이디(@ 앞, @ 포함)로도 찾는다
|
||||
MatchRange? matchEmail(String query, String email) {
|
||||
final String typed = jsLowerCase(query.trim());
|
||||
final String target = jsLowerCase(email);
|
||||
// 대소문자를 바꾸며 길이가 달라지는 글자(드물다)면 범위를 믿을 수 없다 — 맞추지 않는다
|
||||
if (typed.isEmpty || target.length != email.length) return null;
|
||||
final int at = target.indexOf(typed);
|
||||
if (at >= 0) return MatchRange(at, at + typed.length);
|
||||
final int localEnd = typed.indexOf('@');
|
||||
if (localEnd <= 0) return null;
|
||||
final String local = typed.substring(0, localEnd + 1);
|
||||
final int localAt = target.indexOf(local);
|
||||
return localAt >= 0 ? MatchRange(localAt, localAt + local.length) : null;
|
||||
}
|
||||
|
||||
/// 추천 하나가 친 글자에 맞는가 — 이름 · 주소 가운데 하나라도 맞으면
|
||||
RecipientMatch? matchRecipient(String query, RecipientSuggestion suggestion) {
|
||||
final String? suggestionName = suggestion.name;
|
||||
final MatchRange? name = suggestionName == null || suggestionName.isEmpty
|
||||
? null
|
||||
: matchName(query, suggestionName);
|
||||
final MatchRange? email = matchEmail(query, suggestion.email);
|
||||
if (name == null && email == null) return null;
|
||||
return RecipientMatch(suggestion: suggestion, name: name, email: email);
|
||||
}
|
||||
|
||||
/// 친 글자에 맞는 추천 — 이미 넣은 주소([excluded] — 정규화한 주소)는 빼고, 같은 주소는 처음 것만,
|
||||
/// 주소록 → 사내 → 최근 주고받은 주소 차례로(묶음 안은 받은 차례 그대로), 최대 [limit] 개. 빈 글자면 아무것도 보이지 않는다
|
||||
List<RecipientMatch> filterRecipientSuggestions(
|
||||
String query,
|
||||
List<RecipientSuggestion> suggestions,
|
||||
Set<String> excluded, {
|
||||
int limit = kRecipientSuggestionLimit,
|
||||
}) {
|
||||
if (query.trim().isEmpty) return const <RecipientMatch>[];
|
||||
final Set<String> seen = <String>{...excluded};
|
||||
final List<RecipientMatch> matches = <RecipientMatch>[];
|
||||
for (final RecipientSuggestion suggestion in suggestions) {
|
||||
final String key = normalizeEmail(suggestion.email);
|
||||
if (seen.contains(key)) continue;
|
||||
final RecipientMatch? match = matchRecipient(query, suggestion);
|
||||
if (match == null) continue;
|
||||
seen.add(key);
|
||||
matches.add(match);
|
||||
}
|
||||
// 묶음 차례로 — 같은 묶음 안은 받은 차례를 지킨다(Dart 의 sort 는 안정 정렬이 아니라 묶음마다 모은다)
|
||||
return <RecipientMatch>[
|
||||
for (final RecipientSource source in RecipientSource.values)
|
||||
...matches.where(
|
||||
(RecipientMatch match) => match.suggestion.source == source,
|
||||
),
|
||||
].take(limit).toList();
|
||||
}
|
||||
|
||||
/// 친 글자가 이름에 맞는 그룹 — 구성원이 없는 그룹은 뺀다(넣을 주소가 없다). 받은 차례(이름 차례) 그대로
|
||||
List<RecipientGroupMatch> matchContactGroups(
|
||||
String query,
|
||||
List<ContactGroupSummary> groups,
|
||||
) {
|
||||
if (query.trim().isEmpty) return const <RecipientGroupMatch>[];
|
||||
return <RecipientGroupMatch>[
|
||||
for (final ContactGroupSummary group in groups)
|
||||
if (group.memberCount > 0)
|
||||
if (matchName(query, group.name) case final MatchRange name)
|
||||
RecipientGroupMatch(group: group, name: name),
|
||||
];
|
||||
}
|
||||
|
||||
/// 추천 목록의 줄 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 모두 합쳐 최대 [limit] 개
|
||||
List<RecipientOption> arrangeRecipientOptions(
|
||||
List<RecipientMatch> addresses,
|
||||
List<RecipientGroupMatch> groups, {
|
||||
int limit = kRecipientSuggestionLimit,
|
||||
}) {
|
||||
return <RecipientOption>[
|
||||
for (final RecipientMatch match in addresses)
|
||||
if (match.suggestion.source == RecipientSource.contact)
|
||||
AddressOption(match),
|
||||
for (final RecipientGroupMatch match in groups) GroupOption(match),
|
||||
for (final RecipientMatch match in addresses)
|
||||
if (match.suggestion.source != RecipientSource.contact)
|
||||
AddressOption(match),
|
||||
].take(limit).toList();
|
||||
}
|
||||
|
||||
/// 굵게 보일 조각으로 나눈다
|
||||
List<HighlightPart> highlightParts(String text, MatchRange? range) {
|
||||
if (range == null ||
|
||||
range.start >= range.end ||
|
||||
range.start < 0 ||
|
||||
range.end > text.length) {
|
||||
return <HighlightPart>[(text: text, strong: false)];
|
||||
}
|
||||
return <HighlightPart>[
|
||||
(text: text.substring(0, range.start), strong: false),
|
||||
(text: text.substring(range.start, range.end), strong: true),
|
||||
(text: text.substring(range.end), strong: false),
|
||||
].where((HighlightPart part) => part.text.isNotEmpty).toList();
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/// 받는 사람 추천 · 주소록 그룹의 모양 — 계약 2절 `RecipientSuggestion` · 3절 `ContactGroup` · `Contact`(구성원의 대표 주소).
|
||||
///
|
||||
/// 모델은 손으로 쓴다(flutter 스킬 "모델 생성기"). 모양이 틀린 항목은 [tryParse] 가 null — 부르는 쪽이 그 항목만 버린다
|
||||
/// (추천은 보조 정보라 한 줄이 틀려도 나머지는 보인다).
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/people/address_input.dart';
|
||||
import 'package:arasystem_app/core/people/person_address.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 추천의 묶음 — 주소록 · 사내 · 최근 주고받은 주소 (계약 2절)
|
||||
enum RecipientSource {
|
||||
contact,
|
||||
directory,
|
||||
recent;
|
||||
|
||||
static RecipientSource? parse(Object? value) {
|
||||
for (final RecipientSource source in RecipientSource.values) {
|
||||
if (source.name == value) return source;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 주소록 연락처의 주소 종류 — 회사 · 개인 · 기타 (계약 3절 `ContactEmailLabel`)
|
||||
enum ContactEmailLabel {
|
||||
work,
|
||||
personal,
|
||||
other;
|
||||
|
||||
static ContactEmailLabel? parse(Object? value) {
|
||||
for (final ContactEmailLabel label in ContactEmailLabel.values) {
|
||||
if (label.name == value) return label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 받는 사람 추천 한 줄 — `GET /api/recipient-suggestions`
|
||||
@immutable
|
||||
class RecipientSuggestion {
|
||||
const RecipientSuggestion({
|
||||
required this.email,
|
||||
required this.source,
|
||||
this.name,
|
||||
this.company,
|
||||
this.emailLabel,
|
||||
});
|
||||
|
||||
final String? name;
|
||||
final String email;
|
||||
final RecipientSource source;
|
||||
|
||||
/// 주소록 줄만 — 그 연락처의 회사
|
||||
final String? company;
|
||||
|
||||
/// 주소록 줄만 — 그 주소의 종류
|
||||
final ContactEmailLabel? emailLabel;
|
||||
|
||||
PersonAddress get address => PersonAddress(email: email, name: name);
|
||||
|
||||
/// 모양이 틀리면 null — 모르는 묶음은 최근 주고받은 주소로 본다(웹과 같다). 8단계 앞 서버의 빠진 칸은 null
|
||||
static RecipientSuggestion? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? email = json['email'];
|
||||
final Object? name = json['name'];
|
||||
final Object? company = json['company'];
|
||||
if (email is! String || email.isEmpty) return null;
|
||||
if (name != null && name is! String) return null;
|
||||
return RecipientSuggestion(
|
||||
email: email,
|
||||
name: name as String?,
|
||||
source: RecipientSource.parse(json['source']) ?? RecipientSource.recent,
|
||||
company: company is String ? company : null,
|
||||
emailLabel: ContactEmailLabel.parse(json['emailLabel']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is RecipientSuggestion &&
|
||||
other.name == name &&
|
||||
other.email == email &&
|
||||
other.source == source &&
|
||||
other.company == company &&
|
||||
other.emailLabel == emailLabel;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(name, email, source, company, emailLabel);
|
||||
}
|
||||
|
||||
/// 주소록 그룹 — `GET /api/contact-groups` (계약 3절 `ContactGroup`)
|
||||
@immutable
|
||||
class ContactGroupSummary {
|
||||
const ContactGroupSummary({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.memberCount,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
/// 이 주소록에 있는 구성원 수
|
||||
final int memberCount;
|
||||
|
||||
static ContactGroupSummary? tryParse(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? id = json['id'];
|
||||
final Object? name = json['name'];
|
||||
final Object? count = json['memberCount'];
|
||||
if (id is! String || id.isEmpty || name is! String || count is! num) {
|
||||
return null;
|
||||
}
|
||||
return ContactGroupSummary(id: id, name: name, memberCount: count.toInt());
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is ContactGroupSummary &&
|
||||
other.id == id &&
|
||||
other.name == name &&
|
||||
other.memberCount == memberCount;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, name, memberCount);
|
||||
}
|
||||
|
||||
/// 그룹 구성원 한 사람 — 이름과 대표 주소(`Contact.emails` 의 첫 주소 — 없으면 null)
|
||||
typedef GroupMember = ({String? name, String? email});
|
||||
|
||||
/// 구성원(`Contact`) 한 사람을 읽는다 — 모양이 틀리면 null
|
||||
GroupMember? groupMemberOf(Object? json) {
|
||||
if (json is! Map<String, Object?>) return null;
|
||||
final Object? name = json['name'];
|
||||
final Object? emails = json['emails'];
|
||||
if ((name != null && name is! String) || emails is! List<Object?>) {
|
||||
return null;
|
||||
}
|
||||
String? email;
|
||||
if (emails.isNotEmpty) {
|
||||
final Object? first = emails.first;
|
||||
final Object? address = first is Map<String, Object?>
|
||||
? first['address']
|
||||
: null;
|
||||
if (address is String && address.trim().isNotEmpty) email = address.trim();
|
||||
}
|
||||
return (name: name as String?, email: email);
|
||||
}
|
||||
|
||||
/// 그룹 → 넣을 대표 주소(겹치면 한 번)와 이메일이 없는 구성원 수 (짝: 웹 `shared/utils/contacts.ts` 의 `groupRecipientsOf`)
|
||||
@immutable
|
||||
class GroupRecipients {
|
||||
const GroupRecipients({required this.addresses, required this.withoutEmail});
|
||||
|
||||
final List<PersonAddress> addresses;
|
||||
final int withoutEmail;
|
||||
}
|
||||
|
||||
GroupRecipients groupRecipientsOf(Iterable<GroupMember> members) {
|
||||
final List<PersonAddress> addresses = <PersonAddress>[];
|
||||
int withoutEmail = 0;
|
||||
for (final GroupMember member in members) {
|
||||
final String? email = member.email;
|
||||
if (email == null) {
|
||||
withoutEmail++;
|
||||
continue;
|
||||
}
|
||||
final String? name = member.name?.trim();
|
||||
addresses.add(
|
||||
PersonAddress(
|
||||
email: email,
|
||||
name: name == null || name.isEmpty ? null : name,
|
||||
),
|
||||
);
|
||||
}
|
||||
return GroupRecipients(
|
||||
addresses: uniqueAddresses(addresses),
|
||||
withoutEmail: withoutEmail,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user