175 lines
6.1 KiB
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);
|
|
}
|
|
}
|