263 lines
8.4 KiB
Dart
263 lines
8.4 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'group.dart';
|
||
import 'pin.dart';
|
||
|
||
/// 그룹 나무 전체 (순서·하위 그룹·구성원·잠금·다시 잠그는 시간). 글자(JSON)로 저장했다가 되살린다.
|
||
///
|
||
/// 같은 부모 아래의 순서는 [_groups] 에 놓인 순서를 따른다.
|
||
class GroupBook {
|
||
GroupBook({List<Group>? 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<Group> _groups;
|
||
int _next;
|
||
|
||
/// 잠긴 그룹을 연 뒤 이 시간(분)이 지나면 다시 잠근다.
|
||
int relockMinutes;
|
||
|
||
/// 나무 순서(부모 다음에 그 자식들)로 펼친 전체 목록.
|
||
List<Group> get groups {
|
||
final out = <Group>[];
|
||
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<Group> get roots => children(null);
|
||
|
||
/// [id] 바로 아래 그룹들 (null 이면 맨 위 그룹들).
|
||
List<Group> 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<Group> ancestors(String id) {
|
||
final out = <Group>[];
|
||
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<String> subtree(String id) {
|
||
final out = <String>{};
|
||
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<String> 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<String> 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<String> 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<Group> groupsOf(String person) => [for (final g in groups) if (g.members.contains(person)) g];
|
||
|
||
/// 잠긴 그룹 중 [closed] 인 것(지금 열려 있지 않은 것)과 그 하위 그룹의 구성원.
|
||
/// 다른 그룹에도 있어도 잠긴 쪽을 따른다.
|
||
Set<String> 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 = <Group>[];
|
||
final seen = <String>{};
|
||
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 = <String>{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();
|
||
}
|
||
}
|
||
}
|