import 'dart:convert'; import 'group.dart'; import 'pin.dart'; /// 그룹 나무 전체 (순서·하위 그룹·구성원·잠금·다시 잠그는 시간). 글자(JSON)로 저장했다가 되살린다. /// /// 같은 부모 아래의 순서는 [_groups] 에 놓인 순서를 따른다. class GroupBook { GroupBook({List? groups, this.relockMinutes = defaultRelockMinutes, int nextId = 1}) : _groups = [...(groups ?? Group.defaults())], _next = nextId; static const defaultRelockMinutes = 5; static const maxNameLength = 20; /// 가장 깊은 단계 (맨 위가 1). static const maxDepth = 4; final List _groups; int _next; /// 잠긴 그룹을 연 뒤 이 시간(분)이 지나면 다시 잠근다. int relockMinutes; /// 나무 순서(부모 다음에 그 자식들)로 펼친 전체 목록. List get groups { final out = []; void walk(String? parent) { for (final g in _groups) { if (g.parent == parent) { out.add(g); walk(g.id); } } } walk(null); return List.unmodifiable(out); } List get roots => children(null); /// [id] 바로 아래 그룹들 (null 이면 맨 위 그룹들). List children(String? id) => [for (final g in _groups) if (g.parent == id) g]; Group? byId(String id) { for (final g in _groups) { if (g.id == id) return g; } return null; } /// 위쪽 그룹들 (가까운 부모부터). List ancestors(String id) { final out = []; var p = byId(id)?.parent; while (p != null) { final g = byId(p); if (g == null || out.contains(g)) break; out.add(g); p = g.parent; } return out; } int depth(String id) => ancestors(id).length + 1; /// "학교 › 고등학교" 처럼 맨 위부터 이어 쓴 이름. String path(String id) => [...ancestors(id).reversed.map((g) => g.name), byId(id)?.name ?? ''].join(' › '); /// 자신과 모든 하위 그룹의 id. Set subtree(String id) { final out = {}; void walk(String x) { if (!out.add(x)) return; for (final c in children(x)) { walk(c.id); } } if (byId(id) != null) walk(id); return out; } /// 자신과 하위 그룹 구성원 모두. Set membersDeep(String id) => {for (final x in subtree(id)) ...byId(x)!.members}; /// 이름을 다듬는다. 비었거나 같은 부모 아래에 이미 있는 이름이면 null. String? _cleanName(String name, String? parent, {String? except}) { final n = name.trim(); if (n.isEmpty) return null; final cut = n.length > maxNameLength ? n.substring(0, maxNameLength) : n; for (final g in _groups) { if (g.parent == parent && g.id != except && g.name == cut) return null; } return cut; } /// 새 그룹을 [parent] 아래 맨 뒤에 더한다. 이름이 비었거나 겹치거나, 부모가 없거나 너무 깊으면 null. Group? add(String name, String kind, {String? parent}) { if (parent != null && (byId(parent) == null || depth(parent) >= maxDepth)) return null; final n = _cleanName(name, parent); if (n == null) return null; var id = 'g${_next++}'; while (byId(id) != null) { id = 'g${_next++}'; } final g = Group(id: id, name: n, kind: kind, parent: parent); _groups.add(g); return g; } bool rename(String id, String name) { final i = _index(id); if (i < 0) return false; final n = _cleanName(name, _groups[i].parent, except: id); if (n == null) return false; _groups[i] = _groups[i].copyWith(name: n); return true; } bool setKind(String id, String kind) { final i = _index(id); if (i < 0) return false; _groups[i] = _groups[i].copyWith(kind: kind); return true; } /// 그룹과 그 하위 그룹을 모두 지운다 (사람은 그대로 남는다). 지운 id 들. Set remove(String id) { final gone = subtree(id); _groups.removeWhere((g) => gone.contains(g.id)); return gone; } /// [id] 그룹을 같은 부모 아래 [to] 번째로 옮긴다. bool move(String id, int to) { final g = byId(id); if (g == null) return false; _groups.remove(g); final sib = children(g.parent); if (sib.isEmpty || to >= sib.length) { _groups.add(g); } else { _groups.insert(_groups.indexOf(sib[to < 0 ? 0 : to]), g); } return true; } bool setLock(String id, PinHash? lock) { final i = _index(id); if (i < 0) return false; _groups[i] = _groups[i].withLock(lock); return true; } /// 구성원을 더한다 (이전 구성원이었으면 다시 구성원으로). 새로 들어간 수를 돌려준다. int addMembers(String id, Iterable people) { final i = _index(id); if (i < 0) return 0; final m = {..._groups[i].members}; final before = m.length; m.addAll(people); _groups[i] = _groups[i].copyWith(members: m, formers: {..._groups[i].formers}..removeAll(people)); return m.length - before; } bool removeMember(String id, String person) { final i = _index(id); if (i < 0) return false; final m = {..._groups[i].members}; final ok = m.remove(person); if (ok) _groups[i] = _groups[i].copyWith(members: m); return ok; } /// 구성원을 이전 구성원으로 돌린다 (흐리게 남김). 구성원이 아니었으면 false. bool markFormer(String id, String person) { final i = _index(id); if (i < 0 || !_groups[i].members.contains(person)) return false; _groups[i] = _groups[i].copyWith( members: {..._groups[i].members}..remove(person), formers: {..._groups[i].formers, person}, ); return true; } /// 이전 구성원 표시를 지운다. bool removeFormer(String id, String person) { final i = _index(id); if (i < 0 || !_groups[i].formers.contains(person)) return false; _groups[i] = _groups[i].copyWith(formers: {..._groups[i].formers}..remove(person)); return true; } /// 사람을 지울 때 모든 그룹에서 뺀다 (이전 구성원 표시도). void forgetPerson(String person) { for (var i = 0; i < _groups.length; i++) { final g = _groups[i]; if (g.members.contains(person) || g.formers.contains(person)) { _groups[i] = g.copyWith(members: {...g.members}..remove(person), formers: {...g.formers}..remove(person)); } } } /// 이 사람이 들어 있는 그룹들 (나무 순서). List groupsOf(String person) => [for (final g in groups) if (g.members.contains(person)) g]; /// 잠긴 그룹 중 [closed] 인 것(지금 열려 있지 않은 것)과 그 하위 그룹의 구성원. /// 다른 그룹에도 있어도 잠긴 쪽을 따른다. Set hiddenPeople(bool Function(Group g) closed) => { for (final g in _groups) if (g.locked && closed(g)) ...{ ...membersDeep(g.id), for (final x in subtree(g.id)) ...byId(x)!.formers, }, }; int _index(String id) => _groups.indexWhere((g) => g.id == id); String toJson() => jsonEncode({ 'v': 2, 'next': _next, 'relock': relockMinutes, 'groups': [for (final g in _groups) g.toJson()], }); /// 비었거나 깨진 글자면 처음 그룹들로 시작한다. 없는 부모나 돌고 도는 부모는 맨 위로 올린다. factory GroupBook.fromJson(String? text) { if (text == null || text.isEmpty) return GroupBook(); try { final j = jsonDecode(text); if (j is! Map || j['groups'] is! List) return GroupBook(); final list = []; final seen = {}; for (final x in j['groups'] as List) { final g = Group.fromJson(x); if (g != null && seen.add(g.id)) list.add(g); } final byId = {for (final g in list) g.id: g}; for (var i = 0; i < list.length; i++) { final chain = {list[i].id}; var p = list[i].parent; while (p != null && byId.containsKey(p) && chain.add(p)) { p = byId[p]!.parent; } // 맨 위까지 올라가지 못했으면(없는 부모, 돌고 도는 부모) 맨 위로. if (p != null) list[i] = list[i].withParent(null); } final relock = j['relock']; final next = j['next']; return GroupBook( groups: list, relockMinutes: relock is int ? relock.clamp(1, 60) : defaultRelockMinutes, nextId: next is int && next > 0 ? next : list.length + 1, ); } on FormatException { return GroupBook(); } } }