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

274 lines
9.3 KiB
Dart

import 'package:friendly_map/modules/groups/groups.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import 'change_bus.dart';
/// 그룹 목록 한 칸. 잠긴 그룹이 닫혀 있으면 구성원 수를 알려 주지 않는다.
class GroupView {
const GroupView(this.group, this.open, {this.depth = 1, this.path = '', this.index = 0, this.siblings = 1, this.deepCount = 0});
final Group group;
/// 지금 구성원을 볼 수 있는지 (자신과 위쪽 잠긴 그룹이 모두 열려 있음).
final bool open;
/// 나무에서의 단계 (맨 위가 1), 맨 위부터 이은 이름, 같은 부모 아래 순서와 개수.
final int depth;
final String path;
final int index;
final int siblings;
/// 하위 그룹까지 합친 사람 수.
final int deepCount;
int? get memberCount => open ? group.members.length : null;
int? get totalCount => open ? deepCount : null;
bool get canAddChild => depth < GroupBook.maxDepth;
}
/// 비밀번호로 열기 결과.
enum UnlockStatus { opened, wrong, waiting, notLocked, missing }
class UnlockResult {
const UnlockResult(this.status, {this.triesLeft = 0, this.waitSeconds = 0});
final UnlockStatus status;
final int triesLeft;
final int waitSeconds;
bool get ok => status == UnlockStatus.opened || status == UnlockStatus.notLocked;
}
/// 그룹 정의·수정, 구성원, 비밀번호 잠금. 그룹은 이 기기에만 저장하고 어디로도 보내지 않는다.
///
/// 연 그룹은 이 앱이 켜져 있는 동안만 기억하고, 정한 시간(기본 5분)이 지나면 다시 잠근다.
class GroupService {
GroupService(this._storage, this._clock, this._bus);
static const _key = 'groups.v1';
final Storage _storage;
final Clock _clock;
final ChangeBus _bus;
/// 그룹 id → 열린 시각 (1/100초).
final Map<String, int> _openedAt = {};
final Map<String, UnlockGuard> _guards = {};
Future<GroupBook> _load() async => GroupBook.fromJson(await _storage.settings.getText(_key));
Future<void> _save(GroupBook b) async {
await _storage.settings.putText(_key, b.toJson(), _clock.now());
_bus.notify();
}
/// 자신과 위쪽 그룹이 모두 열려 있는지.
bool _isOpen(GroupBook b, Group g) => _selfOpen(b, g) && b.ancestors(g.id).every((a) => _selfOpen(b, a));
bool _selfOpen(GroupBook b, Group g) {
if (!g.locked) return true;
final at = _openedAt[g.id];
if (at == null) return false;
if (_clock.now() - at > b.relockMinutes * 6000) {
_openedAt.remove(g.id);
return false;
}
return true;
}
GroupView _view(GroupBook b, Group g) {
final sib = b.children(g.parent);
return GroupView(g, _isOpen(b, g),
depth: b.depth(g.id), path: b.path(g.id), index: sib.indexOf(g), siblings: sib.length, deepCount: b.membersDeep(g.id).length);
}
/// 나무 순서로 펼친 전체. 닫힌 잠긴 그룹의 하위 그룹은 [includeHidden] 이 아니면 뺀다.
Future<List<GroupView>> all({bool includeHidden = false}) async {
final b = await _load();
return [
for (final g in b.groups)
if (includeHidden || g.parent == null || _isOpen(b, b.byId(g.parent!)!)) _view(b, g),
];
}
Future<GroupView?> view(String id) async {
final b = await _load();
final g = b.byId(id);
return g == null ? null : _view(b, g);
}
/// 바로 아래 그룹들. 이 그룹이 닫혀 있으면 빈 목록.
Future<List<GroupView>> children(String id) async {
final b = await _load();
final g = b.byId(id);
if (g == null || !_isOpen(b, g)) return const [];
return [for (final c in b.children(id)) _view(b, c)];
}
/// 새 그룹. [parent] 를 주면 그 아래에 (부모가 열려 있어야 한다). 이름이 비었거나 겹치면 null.
Future<Group?> add(String name, String kind, {String? parent}) async {
final b = await _load();
if (parent != null) {
final p = b.byId(parent);
if (p == null || !_isOpen(b, p)) return null;
}
final g = b.add(name, kind, parent: parent);
if (g != null) await _save(b);
return g;
}
/// 이름·종류·순서 바꾸기. 잠긴 그룹은 열려 있을 때만.
Future<bool> rename(String id, String name) => _edit(id, (b) => b.rename(id, name));
Future<bool> setKind(String id, String kind) => _edit(id, (b) => b.setKind(id, kind));
Future<bool> move(String id, int to) => _edit(id, (b) => b.move(id, to));
/// 그룹을 하위 그룹과 함께 지운다. 잠긴 그룹은 열려 있을 때만 지울 수 있다 (사람은 남는다).
Future<bool> remove(String id) async {
final b = await _load();
final g = b.byId(id);
if (g == null || !_isOpen(b, g)) return false;
for (final x in b.remove(id)) {
_openedAt.remove(x);
}
await _save(b);
return true;
}
/// 비밀번호를 걸거나 바꾼다. 이미 잠긴 그룹은 [current] 가 맞아야 한다. 문제가 있으면 안내 글.
Future<String?> setPin(String id, String pin, {String? current}) async {
final problem = Pin.problem(pin);
if (problem != null) return problem;
final b = await _load();
final g = b.byId(id);
if (g == null) return '그룹을 찾을 수 없어요';
if (g.locked) {
final r = await _check(g, current ?? '');
if (!r.ok) return _explain(r);
}
b.setLock(id, await Pin.make(pin));
_openedAt[id] = _clock.now();
await _save(b);
return null;
}
/// 비밀번호를 푼다 (누구나 볼 수 있게 됨). [current] 가 맞아야 한다.
Future<String?> clearPin(String id, String current) async {
final b = await _load();
final g = b.byId(id);
if (g == null) return '그룹을 찾을 수 없어요';
if (!g.locked) return null;
final r = await _check(g, current);
if (!r.ok) return _explain(r);
b.setLock(id, null);
_openedAt.remove(id);
await _save(b);
return null;
}
/// 비밀번호로 연다.
Future<UnlockResult> unlock(String id, String pin) async {
final g = (await _load()).byId(id);
if (g == null) return const UnlockResult(UnlockStatus.missing);
final r = await _check(g, pin);
if (r.status == UnlockStatus.opened) {
_openedAt[id] = _clock.now();
_bus.notify();
}
return r;
}
/// 바로 다시 잠근다.
void lock(String id) {
if (_openedAt.remove(id) != null) _bus.notify();
}
/// 연 그룹을 모두 잠근다 (앱이 뒤로 갈 때 등).
void lockAll() {
if (_openedAt.isEmpty) return;
_openedAt.clear();
_bus.notify();
}
Future<UnlockResult> _check(Group g, String pin) async {
final lock = g.lock;
if (lock == null) return const UnlockResult(UnlockStatus.notLocked);
final guard = _guards.putIfAbsent(g.id, UnlockGuard.new);
final wait = guard.waitLeft(_clock.now());
if (wait > 0) return UnlockResult(UnlockStatus.waiting, waitSeconds: (wait + 99) ~/ 100);
if (await Pin.verify(pin, lock)) {
guard.success();
return const UnlockResult(UnlockStatus.opened);
}
guard.failure(_clock.now());
final w = guard.waitLeft(_clock.now());
return w > 0
? UnlockResult(UnlockStatus.waiting, waitSeconds: (w + 99) ~/ 100)
: UnlockResult(UnlockStatus.wrong, triesLeft: guard.triesLeft());
}
String _explain(UnlockResult r) => switch (r.status) {
UnlockStatus.waiting => '여러 번 틀려서 ${r.waitSeconds}초 뒤에 다시 해 주세요',
UnlockStatus.wrong => '비밀번호가 맞지 않아요 (남은 횟수 ${r.triesLeft})',
_ => '그룹을 찾을 수 없어요',
};
/// 구성원 (이름순). 잠겨 있으면 null.
Future<List<Person>?> members(String id) async {
final b = await _load();
final g = b.byId(id);
if (g == null || !_isOpen(b, g)) return null;
final out = <Person>[];
for (final pid in g.members) {
final p = await _storage.people.get(pid);
if (p != null) out.add(p);
}
out.sort((a, c) => a.name.compareTo(c.name));
return out;
}
/// 구성원을 더한다 (잠긴 그룹은 열려 있을 때만). 새로 들어간 수.
Future<int> addMembers(String id, Iterable<String> people) async {
final b = await _load();
final g = b.byId(id);
if (g == null || !_isOpen(b, g)) return 0;
final n = b.addMembers(id, people);
if (n > 0) await _save(b);
return n;
}
Future<bool> removeMember(String id, String person) async {
final b = await _load();
final g = b.byId(id);
if (g == null || !_isOpen(b, g)) return false;
final ok = b.removeMember(id, person);
if (ok) await _save(b);
return ok;
}
/// 이 사람이 든 그룹 중 지금 볼 수 있는 것들.
Future<List<Group>> groupsOf(String person) async {
final b = await _load();
return [for (final g in b.groupsOf(person)) if (_isOpen(b, g)) g];
}
/// 닫힌 잠긴 그룹의 사람들 (인맥 목록 등에서 숨긴다).
Future<Set<String>> hiddenPeople() async {
final b = await _load();
return b.hiddenPeople((g) => !_isOpen(b, g));
}
Future<int> relockMinutes() async => (await _load()).relockMinutes;
Future<void> setRelockMinutes(int minutes) async {
final b = await _load();
b.relockMinutes = minutes.clamp(1, 60);
await _save(b);
}
Future<bool> _edit(String id, bool Function(GroupBook b) f) async {
final b = await _load();
final g = b.byId(id);
if (g == null || !_isOpen(b, g)) return false;
final ok = f(b);
if (ok) await _save(b);
return ok;
}
}