274 lines
9.3 KiB
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;
|
|
}
|
|
}
|