frindlymap/lib/modules/app/src/contact_import_service.dart

175 lines
6.1 KiB
Dart

import 'package:friendly_map/modules/platform/platform.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import 'change_bus.dart';
import 'friend_service.dart';
import 'group_service.dart';
/// 주소록 한 사람을 가져올 수 있는지 살핀 결과.
class ContactCandidate {
const ContactCandidate(this.contact, this.phone, this.known, {this.isMe = false});
final DeviceContact contact;
/// 가져올 번호 (표준 형태). 쓸 수 있는 번호가 없으면 null.
final String? phone;
/// 이미 인맥에 있는 사람.
final Person? known;
final bool isMe;
String get name => contact.name;
bool get importable => phone != null && !isMe;
}
/// 주소록 거르기 조건.
class ContactFilter {
const ContactFilter({this.query = '', this.label, this.hideKnown = false});
/// 이름 일부, 초성(예: ㄱㅁㅅ), 번호 일부.
final String query;
/// 주소록 그룹(라벨). null 이면 전부.
final String? label;
/// 이미 인맥에 있는 사람은 빼고 보기.
final bool hideKnown;
}
/// 가져오기 결과.
class ImportSummary {
const ImportSummary({this.added = 0, this.existing = 0, this.skipped = 0, this.grouped = 0, this.requested = 0});
final int added; // 새로 인맥에 넣은 사람
final int existing; // 이미 있던 사람 (그룹에만 넣음)
final int skipped; // 번호가 없거나 내 번호라 건너뜀
final int grouped; // 그룹에 새로 들어간 횟수
final int requested; // 친구 요청을 보낸 사람
}
const _choseong = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
/// 한글 이름의 초성 ("김민수" → "ㄱㅁㅅ"). 한글이 아닌 글자는 그대로.
String choseongOf(String s) {
final b = StringBuffer();
for (final r in s.runes) {
if (r >= 0xAC00 && r <= 0xD7A3) {
b.write(_choseong[(r - 0xAC00) ~/ 588]);
} else {
b.writeCharCode(r);
}
}
return b.toString();
}
bool _onlyChoseong(String s) => s.isNotEmpty && s.runes.every((r) => _choseong.contains(String.fromCharCode(r)));
/// 거르기 조건에 맞는지 (순수 계산).
bool matchesContact(ContactCandidate c, ContactFilter f) {
if (f.hideKnown && c.known != null) return false;
if (f.label != null && !c.contact.labels.contains(f.label)) return false;
final q = f.query.replaceAll(' ', '').toLowerCase();
if (q.isEmpty) return true;
final name = c.name.replaceAll(' ', '').toLowerCase();
if (name.contains(q)) return true;
if (_onlyChoseong(q) && choseongOf(name).contains(q)) return true;
final digits = q.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.length >= 3 && digits.length == q.replaceAll('-', '').length) {
for (final p in c.contact.phones) {
if (p.replaceAll(RegExp(r'[^0-9]'), '').contains(digits)) return true;
}
}
return false;
}
/// 여러 번호 중 가져올 번호: 휴대폰(+8210)을 먼저, 없으면 처음으로 읽히는 번호.
String? bestPhone(List<String> raw) {
String? first;
for (final r in raw) {
final n = normalizePhone(r);
if (n == null) continue;
if (n.startsWith('+8210')) return n;
first ??= n;
}
return first;
}
/// 기기 주소록에서 사람을 골라 인맥과 그룹에 넣는다. 주소록은 읽기만 하고 고치지 않는다.
///
/// 가져온 사람은 이 기기 인맥에만 들어가고, [import] 의 sendRequest 를 켰을 때만 친구 요청이 간다.
class ContactImportService {
ContactImportService(this._storage, this._clock, this._friends, this._groups, this._bus, this._provider);
final Storage _storage;
final Clock _clock;
final FriendService _friends;
final GroupService _groups;
final ChangeBus _bus;
final ContactsProvider? _provider;
/// 이 기기에서 주소록을 읽을 수 있는지.
bool get available => _provider != null;
Future<bool> requestPermission() async => await _provider?.requestPermission() ?? false;
/// 주소록 전부를 살펴 가져올 수 있는지 표시한다 (이름순).
Future<List<ContactCandidate>> load() async {
final p = _provider;
if (p == null) return const [];
final me = (await _storage.settings.loadProfile())?.phone;
final out = <ContactCandidate>[];
for (final c in await p.all()) {
final phone = bestPhone(c.phones);
final known = phone == null ? null : await _storage.people.byPhone(phone);
out.add(ContactCandidate(c, phone, known, isMe: phone != null && phone == me));
}
out.sort((a, b) => a.name.compareTo(b.name));
return out;
}
/// 조건에 맞는 사람만 (순서 유지).
static List<ContactCandidate> filter(List<ContactCandidate> all, ContactFilter f) =>
[for (final c in all) if (matchesContact(c, f)) c];
/// 주소록 그룹(라벨) 이름들 (가나다순).
static List<String> labels(List<ContactCandidate> all) =>
{for (final c in all) ...c.contact.labels}.toList()..sort();
/// 고른 사람들을 가져와 [groupIds] 그룹에 넣는다. 같은 번호는 한 번만 넣는다.
Future<ImportSummary> import(List<ContactCandidate> picked, {Set<String> groupIds = const {}, bool sendRequest = false}) async {
var added = 0, existing = 0, skipped = 0, grouped = 0, requested = 0;
final ids = <String>[];
final done = <String>{};
for (final c in picked) {
final phone = c.phone;
if (!c.importable || phone == null) {
skipped++;
continue;
}
if (!done.add(phone)) continue;
final known = await _storage.people.byPhone(phone);
Person? p;
if (sendRequest && known?.publicKey == null) {
p = await _friends.add(phone, known?.name ?? c.name);
if (p != null) requested++;
} else {
p = known ?? await _storage.people.register(phone, c.name.trim(), _clock.now());
}
if (p == null) {
skipped++;
continue;
}
if (known == null) {
added++;
} else {
existing++;
}
ids.add(p.id);
}
for (final g in groupIds) {
grouped += await _groups.addMembers(g, ids);
}
_bus.notify();
return ImportSummary(added: added, existing: existing, skipped: skipped, grouped: grouped, requested: requested);
}
}