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 _openedAt = {}; final Map _guards = {}; Future _load() async => GroupBook.fromJson(await _storage.settings.getText(_key)); Future _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> 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 view(String id) async { final b = await _load(); final g = b.byId(id); return g == null ? null : _view(b, g); } /// 바로 아래 그룹들. 이 그룹이 닫혀 있으면 빈 목록. Future> 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 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 rename(String id, String name) => _edit(id, (b) => b.rename(id, name)); Future setKind(String id, String kind) => _edit(id, (b) => b.setKind(id, kind)); Future move(String id, int to) => _edit(id, (b) => b.move(id, to)); /// 그룹을 하위 그룹과 함께 지운다. 잠긴 그룹은 열려 있을 때만 지울 수 있다 (사람은 남는다). Future 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 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 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 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 _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?> members(String id) async { final b = await _load(); final g = b.byId(id); if (g == null || !_isOpen(b, g)) return null; final out = []; 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 addMembers(String id, Iterable 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 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> groupsOf(String person) async { final b = await _load(); return [for (final g in b.groupsOf(person)) if (_isOpen(b, g)) g]; } /// 닫힌 잠긴 그룹의 사람들 (인맥 목록 등에서 숨긴다). Future> hiddenPeople() async { final b = await _load(); return b.hiddenPeople((g) => !_isOpen(b, g)); } Future relockMinutes() async => (await _load()).relockMinutes; Future setRelockMinutes(int minutes) async { final b = await _load(); b.relockMinutes = minutes.clamp(1, 60); await _save(b); } Future _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; } }