From fc78d35109c9a1157028c60dce55ab330326e30a Mon Sep 17 00:00:00 2001 From: diadmin Date: Mon, 26 Oct 2026 14:27:14 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EA=B3=84=EC=B8=B5(?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=EA=B7=B8=EB=A3=B9)=20=EC=B6=94=EA=B0=80:?= =?UTF-8?q?=20=ED=95=99=EA=B5=90=20=E2=80=BA=20=EC=B4=88=EB=93=B1~?= =?UTF-8?q?=EB=8C=80=ED=95=99=EC=9B=90,=20=EA=B0=80=EC=A1=B1=20=E2=80=BA?= =?UTF-8?q?=20=EC=B9=9C=EA=B0=80=C2=B7=EC=99=B8=EA=B0=80=C2=B7=EC=B2=98?= =?UTF-8?q?=EA=B0=80=C2=B7=EC=8B=9C=EA=B0=80=C2=B7=EC=B4=8C=EC=88=98=20?= =?UTF-8?q?=EB=AA=A8=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - groups: parent, 나무 순서, 경로 이름, 하위까지 합친 구성원, 4단 제한, 같은 부모 아래에서만 이름 중복 검사, 지우면 하위도 함께 - 잠긴 부모가 닫히면 하위 그룹·구성원도 닫힘, 깨진 저장값의 없는/도는 부모는 맨 위로 - ui: 그룹 관리는 맨 위 그룹만, 그룹 안에서 하위 그룹 만들기·들어가기, 공용 그룹 메뉴(GroupMenuButton) - 시험 8개 추가 (전체 274개) Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +- README.md | 3 +- lib/modules/app/src/group_service.dart | 61 ++++++-- lib/modules/groups/src/group.dart | 45 ++++-- lib/modules/groups/src/group_book.dart | 132 ++++++++++++++---- .../ui/src/screens/contact_import_screen.dart | 2 +- .../ui/src/screens/group_detail_screen.dart | 131 ++++++++++------- lib/modules/ui/src/screens/groups_screen.dart | 88 ++---------- lib/modules/ui/src/widgets/group_menu.dart | 126 +++++++++++++++++ test/group_service_test.dart | 28 +++- test/groups_test.dart | 82 ++++++++++- test/widget_test.dart | 6 +- 12 files changed, 519 insertions(+), 191 deletions(-) create mode 100644 lib/modules/ui/src/widgets/group_menu.dart diff --git a/CLAUDE.md b/CLAUDE.md index 0a7b770..5f19cce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,10 +31,10 @@ Android 다크모드 인맥 앱(Flutter/Dart). 옛 "Family MAP" + 카카오톡 - 화면은 `AppScope.of(context).app`(FriendlyApp)의 서비스만 쓴다. ## 상태 (2026-10-26 기준) -- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 266개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과. -- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 다음은 "채팅에서 시작하는 관계망" 로드맵 1단계(그룹 계층). +- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 274개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과. +- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 관계망 로드맵 1단계(그룹 계층: 학교 › 초등~대학원, 가족 › 친가·외가·처가·시가·촌수 모름, 회사 › 회사 › 팀) 끝. 다음은 2단계(채팅에서 그룹 넣기·촌수 맞추기). - `flutter build apk --debug` 성공(app-debug.apk 약 190MB). 첫 빌드는 SDK 34·35, CMake 를 내려받느라 약 17분 걸린다. -- 빌드 경고(아직 빌드를 막지는 않음): `nsd_android` 가 Kotlin Gradle Plugin 을 직접 적용한다. 앞으로 Flutter 판에서 빌드가 실패할 수 있으니, 지원 판이 나오면 nsd 버전을 올린다. geolocator_android 는 deprecated API 를 쓴다는 Note 만 나온다. +- 빌드 경고(아직 빌드를 막지는 않음): `nsd_android`, `flutter_contacts` 가 Kotlin Gradle Plugin 을 직접 적용한다. 앞으로 Flutter 판에서 빌드가 실패할 수 있으니, 지원 판이 나오면 nsd 버전을 올린다. geolocator_android 는 deprecated API 를 쓴다는 Note 만 나온다. - 확인 안 된 곳: 실제 기기에서 화면 실행과 플러그인 동작(geolocator, call_log, url_launcher, flutter_local_notifications, timezone, sqflite_sqlcipher, nsd, mobile_scanner, sensors_plus, flutter_secure_storage). - pubspec.lock 은 이 컨테이너(Flutter stable)에서 풀린 판을 기준으로 올린다(2026-10-26 flutter_contacts 추가 때부터). - 빌드 캐시: `-v fm-gradle:/root/.gradle -v fm-pub:/root/.pub-cache` 를 붙이면 두 번째 빌드부터 빨라진다. diff --git a/README.md b/README.md index d903e14..e1a6e5a 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,8 @@ flutter run ## 그룹 · 주소록 가져오기 - 설정(내 정보 → 저장·보안) → "그룹 · 주소록". 처음 그룹은 가족·학교·회사·그룹·여행이고, 더하기·이름과 종류 고치기·순서 바꾸기·지우기가 된다(지워도 사람은 인맥에 남는다). -- 그룹마다 비밀번호(4~16자)를 걸 수 있다. 잠긴 그룹은 열 때 비밀번호를 묻고, 닫혀 있으면 인맥 목록에서도 그 사람들을 숨긴다. 연 뒤 정한 시간(기본 5분, 1~60분)이 지나거나 앱을 내리면 다시 잠근다. +- 그룹 아래에 하위 그룹을 4단까지 만든다. 처음에는 학교 › 초등학교·중학교·고등학교·대학교·대학원, 가족 › 친가·외가·처가·시가·친척(촌수 모름)이 있고, 회사 › 회사 이름 › 프로젝트·팀은 직접 만든다. 같은 부모 아래에서만 이름이 겹치지 않으면 된다. 지우면 하위 그룹도 함께 지워진다(사람은 남는다). +- 그룹마다 비밀번호(4~16자)를 걸 수 있다. 잠긴 그룹은 열 때 비밀번호를 묻고, 닫혀 있으면 하위 그룹과 인맥 목록에서도 그 사람들을 숨긴다. 연 뒤 정한 시간(기본 5분, 1~60분)이 지나거나 앱을 내리면 다시 잠근다. - 5번 틀리면 30초, 그 뒤로 틀릴 때마다 두 배(최대 1시간) 기다려야 한다. 비밀번호는 PBKDF2-SHA256 값만 남긴다. 잊으면 그 그룹은 지우고 다시 만들어야 한다. - "주소록에서 가져오기": 이름·초성(ㄱㅁㅅ)·번호로 찾고, 휴대폰 주소록 그룹(라벨)으로 거르고, 이미 있는 사람은 빼고 볼 수 있다. 한 명씩 또는 보이는 사람을 모두 고른 뒤, 넣을 그룹(여러 개)을 골라 가져온다. - 번호가 여럿이면 휴대폰(010) 번호를 먼저 쓴다. 가져온 사람은 이 기기 인맥에만 들어가고, "친구 요청도 보내기"를 켰을 때만 상대에게 요청이 간다. 주소록은 읽기만 한다. diff --git a/lib/modules/app/src/group_service.dart b/lib/modules/app/src/group_service.dart index a2b6dea..cb571b5 100644 --- a/lib/modules/app/src/group_service.dart +++ b/lib/modules/app/src/group_service.dart @@ -5,13 +5,24 @@ import 'change_bus.dart'; /// 그룹 목록 한 칸. 잠긴 그룹이 닫혀 있으면 구성원 수를 알려 주지 않는다. class GroupView { - const GroupView(this.group, this.open); + 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; } /// 비밀번호로 열기 결과. @@ -48,7 +59,10 @@ class GroupService { _bus.notify(); } - bool _isOpen(GroupBook b, Group g) { + /// 자신과 위쪽 그룹이 모두 열려 있는지. + 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; @@ -59,21 +73,43 @@ class GroupService { return true; } - Future> all() async { + 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) GroupView(g, _isOpen(b, g))]; + 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 : GroupView(g, _isOpen(b, g)); + return g == null ? null : _view(b, g); } - /// 새 그룹. 이름이 비었거나 이미 있으면 null. - Future add(String name, String kind) async { + /// 바로 아래 그룹들. 이 그룹이 닫혀 있으면 빈 목록. + Future> children(String id) async { final b = await _load(); - final g = b.add(name, kind); + 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; } @@ -83,13 +119,14 @@ class GroupService { 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; - b.remove(id); - _openedAt.remove(id); + for (final x in b.remove(id)) { + _openedAt.remove(x); + } await _save(b); return true; } diff --git a/lib/modules/groups/src/group.dart b/lib/modules/groups/src/group.dart index 2eb784e..dbde037 100644 --- a/lib/modules/groups/src/group.dart +++ b/lib/modules/groups/src/group.dart @@ -3,49 +3,70 @@ import 'package:friendly_map/modules/core/core.dart'; import 'pin.dart'; /// 사람을 묶는 그룹 하나. [kind] 는 명함 종류(가족·학교·회사·그룹·개인) 중 하나다. -/// [lock] 이 있으면 비밀번호를 넣어야 구성원을 볼 수 있다. +/// [parent] 가 있으면 그 그룹의 하위 그룹이다 (예: 학교 > 고등학교, 회사 > 한빛전자 > 결제팀). +/// [lock] 이 있으면 비밀번호를 넣어야 구성원과 하위 그룹을 볼 수 있다. class Group { - const Group({required this.id, required this.name, required this.kind, this.members = const {}, this.lock}); + const Group({required this.id, required this.name, required this.kind, this.parent, this.members = const {}, this.lock}); final String id; final String name; final String kind; + final String? parent; final Set members; // 사람 id final PinHash? lock; bool get locked => lock != null; Group copyWith({String? name, String? kind, Set? members}) => - Group(id: id, name: name ?? this.name, kind: kind ?? this.kind, members: members ?? this.members, lock: lock); + Group(id: id, name: name ?? this.name, kind: kind ?? this.kind, parent: parent, members: members ?? this.members, lock: lock); - Group withLock(PinHash? lock) => Group(id: id, name: name, kind: kind, members: members, lock: lock); + Group withLock(PinHash? lock) => Group(id: id, name: name, kind: kind, parent: parent, members: members, lock: lock); + + Group withParent(String? parent) => Group(id: id, name: name, kind: kind, parent: parent, members: members, lock: lock); Map toJson() => { 'id': id, 'name': name, 'kind': kind, + if (parent != null) 'parent': parent, 'members': members.toList()..sort(), if (lock != null) 'lock': lock!.toJson(), }; static Group? fromJson(Object? j) { if (j is! Map) return null; - final id = j['id'], name = j['name'], kind = j['kind'], m = j['members']; + final id = j['id'], name = j['name'], kind = j['kind'], m = j['members'], parent = j['parent']; if (id is! String || name is! String) return null; return Group( id: id, name: name, kind: kind is String && CardKinds.all.contains(kind) ? kind : CardKinds.group, + parent: parent is String && parent != id ? parent : null, members: m is List ? {for (final x in m) if (x is String) x} : const {}, lock: PinHash.fromJson(j['lock']), ); } - /// 처음 쓸 때 만들어 두는 그룹들. - static List defaults() => const [ - Group(id: CardKinds.family, name: '가족', kind: CardKinds.family), - Group(id: CardKinds.school, name: '학교', kind: CardKinds.school), - Group(id: CardKinds.company, name: '회사', kind: CardKinds.company), - Group(id: CardKinds.group, name: '그룹·여행', kind: CardKinds.group), - ]; + /// 촌수를 모르는 친척을 모아 두는 가족 하위 그룹. + static const unknownRelatives = 'family.unknown'; + + /// 처음 쓸 때 만들어 두는 그룹들 (부모 다음에 자식 순서). + static List defaults() { + const f = CardKinds.family, s = CardKinds.school; + return const [ + Group(id: f, name: '가족', kind: f), + Group(id: 'family.paternal', name: '친가', kind: f, parent: f), + Group(id: 'family.maternal', name: '외가', kind: f, parent: f), + Group(id: 'family.inlaw', name: '처가·시가', kind: f, parent: f), + Group(id: unknownRelatives, name: '친척(촌수 모름)', kind: f, parent: f), + Group(id: s, name: '학교', kind: s), + Group(id: 'school.elementary', name: '초등학교', kind: s, parent: s), + Group(id: 'school.middle', name: '중학교', kind: s, parent: s), + Group(id: 'school.high', name: '고등학교', kind: s, parent: s), + Group(id: 'school.university', name: '대학교', kind: s, parent: s), + Group(id: 'school.graduate', name: '대학원', kind: s, parent: s), + Group(id: CardKinds.company, name: '회사', kind: CardKinds.company), + Group(id: CardKinds.group, name: '그룹·여행', kind: CardKinds.group), + ]; + } } diff --git a/lib/modules/groups/src/group_book.dart b/lib/modules/groups/src/group_book.dart index b0bc708..d2c71e1 100644 --- a/lib/modules/groups/src/group_book.dart +++ b/lib/modules/groups/src/group_book.dart @@ -3,7 +3,9 @@ import 'dart:convert'; import 'group.dart'; import 'pin.dart'; -/// 그룹 목록 전체 (순서·구성원·잠금·다시 잠그는 시간). 글자(JSON)로 저장했다가 되살린다. +/// 그룹 나무 전체 (순서·하위 그룹·구성원·잠금·다시 잠그는 시간). 글자(JSON)로 저장했다가 되살린다. +/// +/// 같은 부모 아래의 순서는 [_groups] 에 놓인 순서를 따른다. class GroupBook { GroupBook({List? groups, this.relockMinutes = defaultRelockMinutes, int nextId = 1}) : _groups = [...(groups ?? Group.defaults())], @@ -12,13 +14,35 @@ class GroupBook { static const defaultRelockMinutes = 5; static const maxNameLength = 20; + /// 가장 깊은 단계 (맨 위가 1). + static const maxDepth = 4; + final List _groups; int _next; /// 잠긴 그룹을 연 뒤 이 시간(분)이 지나면 다시 잠근다. int relockMinutes; - List get groups => List.unmodifiable(_groups); + /// 나무 순서(부모 다음에 그 자식들)로 펼친 전체 목록. + 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) { @@ -27,34 +51,71 @@ class GroupBook { return null; } - /// 이름을 다듬는다. 비었거나 이미 있는 이름이면 null. - String? _cleanName(String name, {String? except}) { + /// 위쪽 그룹들 (가까운 부모부터). + 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.id != except && g.name == cut) return null; + if (g.parent == parent && g.id != except && g.name == cut) return null; } return cut; } - /// 새 그룹을 맨 뒤에 더한다. 이름이 비었거나 겹치면 null. - Group? add(String name, String kind) { - final n = _cleanName(name); + /// 새 그룹을 [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); + 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); - final n = _cleanName(name, except: id); - if (i < 0 || n == null) return false; + 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; } @@ -66,20 +127,24 @@ class GroupBook { return true; } - /// 그룹을 지운다 (사람은 그대로 남는다). - bool remove(String id) { - final i = _index(id); - if (i < 0) return false; - _groups.removeAt(i); - return true; + /// 그룹과 그 하위 그룹을 모두 지운다 (사람은 그대로 남는다). 지운 id 들. + Set remove(String id) { + final gone = subtree(id); + _groups.removeWhere((g) => gone.contains(g.id)); + return gone; } - /// [id] 그룹을 [to] 번째로 옮긴다. + /// [id] 그룹을 같은 부모 아래 [to] 번째로 옮긴다. bool move(String id, int to) { - final i = _index(id); - if (i < 0) return false; - final g = _groups.removeAt(i); - _groups.insert(to.clamp(0, _groups.length), g); + 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; } @@ -119,25 +184,26 @@ class GroupBook { } } - /// 이 사람이 들어 있는 그룹들 (목록 순서). - List groupsOf(String person) => [for (final g in _groups) if (g.members.contains(person)) g]; + /// 이 사람이 들어 있는 그룹들 (나무 순서). + List groupsOf(String person) => [for (final g in groups) if (g.members.contains(person)) g]; - /// [closed] 에 든(지금 열려 있지 않은) 잠긴 그룹의 구성원. 다른 그룹에도 있어도 잠긴 쪽을 따른다. + /// 잠긴 그룹 중 [closed] 인 것(지금 열려 있지 않은 것)과 그 하위 그룹의 구성원. + /// 다른 그룹에도 있어도 잠긴 쪽을 따른다. Set hiddenPeople(bool Function(Group g) closed) => { for (final g in _groups) - if (g.locked && closed(g)) ...g.members, + if (g.locked && closed(g)) ...membersDeep(g.id), }; int _index(String id) => _groups.indexWhere((g) => g.id == id); String toJson() => jsonEncode({ - 'v': 1, + '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 { @@ -149,6 +215,16 @@ class GroupBook { 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( diff --git a/lib/modules/ui/src/screens/contact_import_screen.dart b/lib/modules/ui/src/screens/contact_import_screen.dart index f36893c..2abd5e7 100644 --- a/lib/modules/ui/src/screens/contact_import_screen.dart +++ b/lib/modules/ui/src/screens/contact_import_screen.dart @@ -228,7 +228,7 @@ class _OptionsSheetState extends State<_OptionsSheet> { for (final v in views) FilterChip( avatar: v.group.locked ? Icon(v.open ? Icons.lock_open : Icons.lock, size: 16) : null, - label: Text(v.group.name), + label: Text(v.depth > 1 ? v.path : v.group.name), selected: _groups.contains(v.group.id), selectedColor: Pal.ofKind(v.group.kind).withValues(alpha: 0.3), onSelected: (on) async { diff --git a/lib/modules/ui/src/screens/group_detail_screen.dart b/lib/modules/ui/src/screens/group_detail_screen.dart index e1f3a80..c499662 100644 --- a/lib/modules/ui/src/screens/group_detail_screen.dart +++ b/lib/modules/ui/src/screens/group_detail_screen.dart @@ -8,22 +8,35 @@ import '../theme.dart'; import '../widgets/async_view.dart'; import '../widgets/common.dart'; import '../widgets/group_dialogs.dart'; +import '../widgets/group_menu.dart'; import '../widgets/pin_dialog.dart'; import 'contact_import_screen.dart'; import 'person_screen.dart'; class _Data { - const _Data(this.view, this.members); + const _Data(this.view, this.children, this.grandchildren, this.members); final GroupView? view; + final List children; + final Map grandchildren; // 하위 그룹 id → 그 아래 그룹 수 final List? members; // 닫혀 있으면 null } -/// 그룹 한 개: 구성원 보기·넣기·빼기. 잠긴 그룹이 닫히면 구성원을 감춘다. +/// 그룹 한 개: 하위 그룹과 구성원 보기·넣기·빼기. 잠긴 그룹이 닫히면 모두 감춘다. class GroupDetailScreen extends StatelessWidget { const GroupDetailScreen({super.key, required this.groupId}); final String groupId; + Future<_Data> _load(FriendlyApp a) async { + final kids = await a.groups.children(groupId); + return _Data( + await a.groups.view(groupId), + kids, + {for (final k in kids) k.group.id: (await a.groups.children(k.group.id)).length}, + await a.groups.members(groupId), + ); + } + Future _addFromPeople(BuildContext context, List members) async { final app = AppScope.of(context).app; final hidden = await app.groups.hiddenPeople(); @@ -39,7 +52,7 @@ class GroupDetailScreen extends StatelessWidget { Widget build(BuildContext context) { final app = AppScope.of(context).app; return AsyncView<_Data>( - load: (a) async => _Data(await a.groups.view(groupId), await a.groups.members(groupId)), + load: _load, builder: (context, d) { final v = d.view; if (v == null) { @@ -49,60 +62,80 @@ class GroupDetailScreen extends StatelessWidget { final members = d.members; return Scaffold( appBar: AppBar( - title: Row(children: [ - Flexible(child: Text(g.name, overflow: TextOverflow.ellipsis)), - const SizedBox(width: 8), - KindChip(g.kind, CardKinds.label(g.kind)), + title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Flexible(child: Text(g.name, overflow: TextOverflow.ellipsis)), + const SizedBox(width: 8), + KindChip(g.kind, CardKinds.label(g.kind)), + ]), + if (v.depth > 1) Text(v.path, style: const TextStyle(fontSize: 11, color: Pal.muted)), ]), actions: [ if (g.locked && members != null) IconButton(tooltip: '지금 잠그기', onPressed: () => app.groups.lock(groupId), icon: const Icon(Icons.lock_outline)), + GroupMenuButton(v, onDeleted: () { + if (context.mounted) Navigator.of(context).pop(); + }), ], ), - body: members == null - ? Center( - child: Column(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.lock, size: 48, color: Pal.muted), - const SizedBox(height: 12), - const Text('잠긴 그룹이에요', style: TextStyle(color: Pal.muted)), - const SizedBox(height: 12), - FilledButton(onPressed: () => openGroup(context, v), child: const Text('비밀번호로 열기')), - ]), - ) - : ListView(padding: const EdgeInsets.only(top: 4, bottom: 96), children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Row(children: [ - Expanded(child: OutlinedButton.icon(onPressed: () => _addFromPeople(context, members), icon: const Icon(Icons.group_add_outlined), label: const Text('인맥에서 넣기'))), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - onPressed: () => pushPage(context, ContactImportScreen(groupId: groupId)), - icon: const Icon(Icons.contact_phone_outlined), - label: const Text('주소록에서'), - ), - ), - ]), - ), - if (members.isEmpty) const EmptyNote('아직 아무도 없어요.\n위 버튼으로 사람을 넣어 보세요.'), - for (final p in members) - ListTile( - leading: Avatar(p.name, size: 40, color: Pal.ofKind(g.kind)), - title: Text(p.name), - subtitle: Text(isLocalPhone(p.phone) ? '번호 없음' : p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)), - onTap: () => pushPage(context, PersonScreen(personId: p.id)), - trailing: IconButton( - tooltip: '그룹에서 빼기', - icon: const Icon(Icons.remove_circle_outline, color: Pal.muted), - onPressed: () async { - final ok = await askConfirm(context, '${p.name} 빼기', '이 그룹에서만 빼고 인맥에는 남아요.', ok: '빼기'); - if (ok) await app.groups.removeMember(groupId, p.id); - }, - ), - ), - ]), + body: members == null ? _closed(context, v) : _open(context, v, d, members), ); }, ); } + + Widget _closed(BuildContext context, GroupView v) => Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.lock, size: 48, color: Pal.muted), + const SizedBox(height: 12), + const Text('잠긴 그룹이에요', style: TextStyle(color: Pal.muted)), + const SizedBox(height: 12), + FilledButton(onPressed: () => openGroup(context, v), child: const Text('비밀번호로 열기')), + ]), + ); + + Widget _open(BuildContext context, GroupView v, _Data d, List members) { + final app = AppScope.of(context).app; + final g = v.group; + return ListView(padding: const EdgeInsets.only(top: 4, bottom: 96), children: [ + if (d.children.isNotEmpty || v.canAddChild) + SectionHeader('하위 그룹', + trailing: v.canAddChild + ? TextButton.icon(onPressed: () => addChildGroup(context, v), icon: const Icon(Icons.add, size: 18), label: const Text('만들기')) + : null), + for (final c in d.children) + GroupTile(c, children: d.grandchildren[c.group.id] ?? 0, onOpen: () => pushPage(context, GroupDetailScreen(groupId: c.group.id))), + SectionHeader('사람 ${members.length}명'), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Row(children: [ + Expanded(child: OutlinedButton.icon(onPressed: () => _addFromPeople(context, members), icon: const Icon(Icons.group_add_outlined), label: const Text('인맥에서 넣기'))), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () => pushPage(context, ContactImportScreen(groupId: groupId)), + icon: const Icon(Icons.contact_phone_outlined), + label: const Text('주소록에서'), + ), + ), + ]), + ), + if (members.isEmpty) const EmptyNote('아직 아무도 없어요.\n위 버튼으로 사람을 넣어 보세요.'), + for (final p in members) + ListTile( + leading: Avatar(p.name, size: 40, color: Pal.ofKind(g.kind)), + title: Text(p.name), + subtitle: Text(isLocalPhone(p.phone) ? '번호 없음' : p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)), + onTap: () => pushPage(context, PersonScreen(personId: p.id)), + trailing: IconButton( + tooltip: '그룹에서 빼기', + icon: const Icon(Icons.remove_circle_outline, color: Pal.muted), + onPressed: () async { + final ok = await askConfirm(context, '${p.name} 빼기', '이 그룹에서만 빼고 인맥에는 남아요.', ok: '빼기'); + if (ok) await app.groups.removeMember(groupId, p.id); + }, + ), + ), + ]); + } } diff --git a/lib/modules/ui/src/screens/groups_screen.dart b/lib/modules/ui/src/screens/groups_screen.dart index 4cb169c..4b2aec0 100644 --- a/lib/modules/ui/src/screens/groups_screen.dart +++ b/lib/modules/ui/src/screens/groups_screen.dart @@ -1,25 +1,22 @@ import 'package:flutter/material.dart'; import 'package:friendly_map/modules/app/app.dart'; -import 'package:friendly_map/modules/core/core.dart'; import '../app_scope.dart'; import '../theme.dart'; import '../widgets/async_view.dart'; import '../widgets/common.dart'; import '../widgets/group_dialogs.dart'; -import '../widgets/pin_dialog.dart'; +import '../widgets/group_menu.dart'; import 'contact_import_screen.dart'; import 'group_detail_screen.dart'; class _Data { const _Data(this.groups, this.relock); - final List groups; + final List groups; // 펼친 전체 (보이는 것만) final int relock; } -enum _Act { edit, up, down, setPin, clearPin, lockNow, delete } - -/// 설정 > 그룹 관리: 그룹 정의(이름·종류·순서), 비밀번호 잠금, 지우기. +/// 설정 > 그룹 관리: 맨 위 그룹 목록. 누르면 그 그룹 안(하위 그룹·구성원)으로 들어간다. class GroupsScreen extends StatelessWidget { const GroupsScreen({super.key}); @@ -31,39 +28,6 @@ class GroupsScreen extends StatelessWidget { if (context.mounted && g == null) showNote(context, '이름이 비었거나 같은 이름의 그룹이 있어요'); } - Future _act(BuildContext context, GroupView v, int index, _Act a) async { - final app = AppScope.of(context).app; - final id = v.group.id; - // 잠긴 그룹은 무엇을 하든 먼저 연다 (바로 잠그기는 이미 열린 상태에서만 보인다). - if (a != _Act.lockNow && !await openGroup(context, v)) return; - if (!context.mounted) return; - switch (a) { - case _Act.edit: - final r = await askGroupInfo(context, title: '그룹 고치기', name: v.group.name, kind: v.group.kind); - if (r == null || !context.mounted) return; - if (r.$1.trim() != v.group.name && !await app.groups.rename(id, r.$1)) { - if (context.mounted) showNote(context, '이름이 비었거나 같은 이름의 그룹이 있어요'); - return; - } - if (r.$2 != v.group.kind) await app.groups.setKind(id, r.$2); - case _Act.up: - await app.groups.move(id, index - 1); - case _Act.down: - await app.groups.move(id, index + 1); - case _Act.setPin: - final now = await app.groups.view(id); - if (now != null && context.mounted) await setGroupPin(context, now); - case _Act.clearPin: - final now = await app.groups.view(id); - if (now != null && context.mounted) await clearGroupPin(context, now); - case _Act.lockNow: - app.groups.lock(id); - case _Act.delete: - final ok = await askConfirm(context, '${v.group.name} 지우기', '그룹만 지우고 사람은 인맥에 남아요.', ok: '지우기'); - if (ok) await app.groups.remove(id); - } - } - @override Widget build(BuildContext context) { final app = AppScope.of(context).app; @@ -80,10 +44,17 @@ class GroupsScreen extends StatelessWidget { builder: (context, d) => ListView(padding: const EdgeInsets.only(top: 4, bottom: 32), children: [ const Padding( padding: EdgeInsets.fromLTRB(20, 0, 20, 10), - child: Text('가족·학교 같은 그룹을 정하고 사람을 묶어요. 비밀번호를 건 그룹은 열 때마다 물어보고, 닫혀 있으면 인맥 목록에서도 숨겨요. 그룹은 이 기기에만 저장돼요.', + child: Text('가족·학교 같은 그룹을 정하고 그 아래에 하위 그룹(고등학교, 회사 › 팀 …)을 만들어 사람을 묶어요. ' + '비밀번호를 건 그룹은 열 때마다 물어보고, 닫혀 있으면 하위 그룹과 인맥 목록에서도 숨겨요. 그룹은 이 기기에만 저장돼요.', style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)), ), - for (var i = 0; i < d.groups.length; i++) _tile(context, d.groups[i], i, d.groups.length), + for (final v in d.groups) + if (v.depth == 1) + GroupTile( + v, + children: d.groups.where((c) => c.group.parent == v.group.id).length, + onOpen: () => pushPage(context, GroupDetailScreen(groupId: v.group.id)), + ), Panel( child: Row(children: [ const Expanded( @@ -101,39 +72,4 @@ class GroupsScreen extends StatelessWidget { ), ); } - - Widget _tile(BuildContext context, GroupView v, int i, int count) { - final g = v.group; - final color = Pal.ofKind(g.kind); - final sub = !v.open ? '${CardKinds.label(g.kind)} · 잠김' : '${CardKinds.label(g.kind)} · ${v.memberCount}명${g.locked ? ' · 열림' : ''}'; - return Panel( - padding: const EdgeInsets.fromLTRB(14, 6, 4, 6), - onTap: () async { - if (await openGroup(context, v) && context.mounted) pushPage(context, GroupDetailScreen(groupId: g.id)); - }, - child: Row(children: [ - Icon(g.locked ? (v.open ? Icons.lock_open : Icons.lock) : Icons.folder_outlined, color: color), - const SizedBox(width: 12), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(g.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), - Text(sub, style: const TextStyle(fontSize: 12, color: Pal.muted)), - ]), - ), - PopupMenuButton<_Act>( - tooltip: '더 보기', - onSelected: (a) => _act(context, v, i, a), - itemBuilder: (_) => [ - const PopupMenuItem(value: _Act.edit, child: Text('이름·종류 고치기')), - if (i > 0) const PopupMenuItem(value: _Act.up, child: Text('위로')), - if (i < count - 1) const PopupMenuItem(value: _Act.down, child: Text('아래로')), - PopupMenuItem(value: _Act.setPin, child: Text(g.locked ? '비밀번호 바꾸기' : '비밀번호 걸기')), - if (g.locked) const PopupMenuItem(value: _Act.clearPin, child: Text('비밀번호 풀기')), - if (g.locked && v.open) const PopupMenuItem(value: _Act.lockNow, child: Text('지금 잠그기')), - const PopupMenuItem(value: _Act.delete, child: Text('지우기', style: TextStyle(color: Pal.danger))), - ], - ), - ]), - ); - } } diff --git a/lib/modules/ui/src/widgets/group_menu.dart b/lib/modules/ui/src/widgets/group_menu.dart new file mode 100644 index 0000000..d1941e9 --- /dev/null +++ b/lib/modules/ui/src/widgets/group_menu.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import 'common.dart'; +import 'group_dialogs.dart'; +import 'pin_dialog.dart'; + +enum _Act { addChild, edit, up, down, setPin, clearPin, lockNow, delete } + +/// 하위 그룹을 만든다 (종류는 부모를 따른다). 만들었으면 true. +Future addChildGroup(BuildContext context, GroupView parent) async { + final app = AppScope.of(context).app; + if (!await openGroup(context, parent) || !context.mounted) return false; + final r = await askGroupInfo(context, title: '${parent.group.name} 아래 새 그룹', kind: parent.group.kind); + if (r == null || !context.mounted) return false; + final g = await app.groups.add(r.$1, r.$2, parent: parent.group.id); + if (context.mounted && g == null) showNote(context, '이름이 비었거나 같은 이름의 그룹이 있어요'); + return g != null; +} + +/// 그룹 한 칸의 "더 보기" 메뉴: 하위 그룹, 이름·종류, 순서, 비밀번호, 잠그기, 지우기. +class GroupMenuButton extends StatelessWidget { + const GroupMenuButton(this.view, {super.key, this.onDeleted}); + + final GroupView view; + final VoidCallback? onDeleted; + + Future _act(BuildContext context, _Act a) async { + final app = AppScope.of(context).app; + final v = view; + final id = v.group.id; + if (a == _Act.addChild) { + await addChildGroup(context, v); + return; + } + // 잠긴 그룹은 무엇을 하든 먼저 연다 (지금 잠그기는 이미 열린 상태에서만 보인다). + if (a != _Act.lockNow && !await openGroup(context, v)) return; + if (!context.mounted) return; + switch (a) { + case _Act.addChild: + break; + case _Act.edit: + final r = await askGroupInfo(context, title: '그룹 고치기', name: v.group.name, kind: v.group.kind); + if (r == null || !context.mounted) return; + if (r.$1.trim() != v.group.name && !await app.groups.rename(id, r.$1)) { + if (context.mounted) showNote(context, '이름이 비었거나 같은 이름의 그룹이 있어요'); + return; + } + if (r.$2 != v.group.kind) await app.groups.setKind(id, r.$2); + case _Act.up: + await app.groups.move(id, v.index - 1); + case _Act.down: + await app.groups.move(id, v.index + 1); + case _Act.setPin: + final now = await app.groups.view(id); + if (now != null && context.mounted) await setGroupPin(context, now); + case _Act.clearPin: + final now = await app.groups.view(id); + if (now != null && context.mounted) await clearGroupPin(context, now); + case _Act.lockNow: + app.groups.lock(id); + case _Act.delete: + final ok = await askConfirm(context, '${v.group.name} 지우기', '이 그룹과 하위 그룹을 지워요. 사람은 인맥에 남아요.', ok: '지우기'); + if (ok && await app.groups.remove(id)) onDeleted?.call(); + } + } + + @override + Widget build(BuildContext context) { + final v = view; + final g = v.group; + return PopupMenuButton<_Act>( + tooltip: '더 보기', + onSelected: (a) => _act(context, a), + itemBuilder: (_) => [ + if (v.canAddChild) const PopupMenuItem(value: _Act.addChild, child: Text('하위 그룹 만들기')), + const PopupMenuItem(value: _Act.edit, child: Text('이름·종류 고치기')), + if (v.index > 0) const PopupMenuItem(value: _Act.up, child: Text('위로')), + if (v.index < v.siblings - 1) const PopupMenuItem(value: _Act.down, child: Text('아래로')), + PopupMenuItem(value: _Act.setPin, child: Text(g.locked ? '비밀번호 바꾸기' : '비밀번호 걸기')), + if (g.locked) const PopupMenuItem(value: _Act.clearPin, child: Text('비밀번호 풀기')), + if (g.locked && v.open) const PopupMenuItem(value: _Act.lockNow, child: Text('지금 잠그기')), + const PopupMenuItem(value: _Act.delete, child: Text('지우기', style: TextStyle(color: Pal.danger))), + ], + ); + } +} + +/// 그룹 한 칸 (목록에 쓴다). 누르면 [onOpen] (잠겨 있으면 먼저 비밀번호를 묻는다). +class GroupTile extends StatelessWidget { + const GroupTile(this.view, {super.key, required this.onOpen, this.children = 0}); + + final GroupView view; + final VoidCallback onOpen; + final int children; // 하위 그룹 수 + + @override + Widget build(BuildContext context) { + final v = view; + final g = v.group; + final parts = [ + if (!v.open) '잠김' else '${v.totalCount}명', + if (v.open && children > 0) '하위 $children개', + if (g.locked && v.open) '열림', + ]; + return Panel( + padding: const EdgeInsets.fromLTRB(14, 6, 4, 6), + onTap: () async { + if (await openGroup(context, v) && context.mounted) onOpen(); + }, + child: Row(children: [ + Icon(g.locked ? (v.open ? Icons.lock_open : Icons.lock) : Icons.folder_outlined, color: Pal.ofKind(g.kind)), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(g.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + Text(parts.join(' · '), style: const TextStyle(fontSize: 12, color: Pal.muted)), + ]), + ), + GroupMenuButton(v), + ]), + ); + } +} diff --git a/test/group_service_test.dart b/test/group_service_test.dart index 1d41bd4..f5c5ca2 100644 --- a/test/group_service_test.dart +++ b/test/group_service_test.dart @@ -18,7 +18,7 @@ void main() { void later(int minutes) => me.app.deps.clock.offset += minutes * 6000; test('그룹 정의: 처음 네 개, 더하고 이름·종류 바꾸고 지우기', () async { - expect((await groups.all()).map((v) => v.group.name), ['가족', '학교', '회사', '그룹·여행']); + expect([for (final v in await groups.all()) if (v.depth == 1) v.group.name], ['가족', '학교', '회사', '그룹·여행']); final g = (await groups.add('등산 모임', CardKinds.group))!; expect(await groups.add('등산 모임', CardKinds.group), isNull); expect(await groups.rename(g.id, '산악회'), isTrue); @@ -98,4 +98,30 @@ void main() { expect(v.group.locked, isTrue); expect(v.open, isFalse); }); + + test('하위 그룹: 열린 부모 아래에만 만들고, 부모가 잠기면 하위도 닫힌다', () async { + final hb = (await groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!; + final team = (await groups.add('결제팀', CardKinds.company, parent: hb.id))!; + final a = (await me.app.friends.add('+821033334444', '김민수'))!; + await groups.addMembers(team.id, [a.id]); + final v = (await groups.view(CardKinds.company))!; + expect(v.totalCount, 1); + expect((await groups.view(team.id))!.path, '회사 › 한빛전자 › 결제팀'); + expect((await groups.children(CardKinds.company)).map((c) => c.group.name), ['한빛전자']); + + await groups.setPin(CardKinds.company, '7777'); + groups.lockAll(); + expect((await groups.view(team.id))!.open, isFalse, reason: '부모가 잠기면 하위도 닫힘'); + expect(await groups.members(team.id), isNull); + expect(await groups.children(CardKinds.company), isEmpty); + expect(await groups.hiddenPeople(), {a.id}); + expect(await groups.add('새 팀', CardKinds.company, parent: hb.id), isNull); + expect((await groups.all()).any((x) => x.group.id == team.id), isFalse, reason: '닫힌 부모의 하위는 목록에서 뺀다'); + + await groups.unlock(CardKinds.company, '7777'); + expect((await groups.members(team.id))!.single.name, '김민수'); + expect(await groups.remove(CardKinds.company), isTrue); + expect(await groups.view(team.id), isNull, reason: '하위 그룹도 함께 지워진다'); + expect(await me.app.friends.person(a.id), isNotNull, reason: '사람은 남는다'); + }); } diff --git a/test/groups_test.dart b/test/groups_test.dart index 6d35049..e82865d 100644 --- a/test/groups_test.dart +++ b/test/groups_test.dart @@ -4,10 +4,19 @@ import 'package:friendly_map/modules/groups/groups.dart'; void main() { group('그룹 목록', () { - test('처음에는 가족·학교·회사·그룹·여행 네 개', () { + test('처음에는 맨 위에 가족·학교·회사·그룹·여행 네 개', () { final b = GroupBook(); - expect(b.groups.map((g) => g.name), ['가족', '학교', '회사', '그룹·여행']); - expect(b.groups.map((g) => g.kind), [CardKinds.family, CardKinds.school, CardKinds.company, CardKinds.group]); + expect(b.roots.map((g) => g.name), ['가족', '학교', '회사', '그룹·여행']); + expect(b.roots.map((g) => g.kind), [CardKinds.family, CardKinds.school, CardKinds.company, CardKinds.group]); + }); + + test('처음 하위 그룹: 학교는 초등~대학원, 가족은 친가·외가·처가·시가·촌수 모름', () { + final b = GroupBook(); + expect(b.children(CardKinds.school).map((g) => g.name), ['초등학교', '중학교', '고등학교', '대학교', '대학원']); + expect(b.children(CardKinds.family).map((g) => g.name), ['친가', '외가', '처가·시가', '친척(촌수 모름)']); + expect(b.byId(Group.unknownRelatives)!.parent, CardKinds.family); + expect(b.children(CardKinds.company), isEmpty); + expect(b.groups.indexOf(b.byId('school.high')!), greaterThan(b.groups.indexOf(b.byId(CardKinds.school)!))); }); test('더하기·이름 바꾸기·지우기, 빈 이름과 겹치는 이름은 거절', () { @@ -20,9 +29,9 @@ void main() { expect(b.rename(g.id, '학교'), isFalse); expect(b.rename(g.id, '등산 모임'), isTrue, reason: '자기 이름 그대로는 괜찮다'); expect(b.byId(g.id)!.name, '등산 모임'); - expect(b.remove(g.id), isTrue); + expect(b.remove(g.id), {g.id}); expect(b.byId(g.id), isNull); - expect(b.remove('없음'), isFalse); + expect(b.remove('없음'), isEmpty); }); test('이름은 20자까지 자른다', () { @@ -71,8 +80,8 @@ void main() { expect(c.byId(g.id)!.locked, isTrue); expect(c.relockMinutes, 10); expect(c.add('새 그룹', CardKinds.group)!.id, isNot(g.id), reason: '새 번호표는 겹치지 않는다'); - expect(GroupBook.fromJson('{깨짐').groups.length, 4); - expect(GroupBook.fromJson(null).groups.length, 4); + expect(GroupBook.fromJson('{깨짐').roots.length, 4); + expect(GroupBook.fromJson(null).roots.length, 4); }); }); @@ -125,4 +134,63 @@ void main() { expect(g.waitLeft(0), 360000); }); }); + + group('하위 그룹', () { + test('회사 > 한빛전자 > 결제팀, 같은 이름은 부모가 다르면 괜찮다', () { + final b = GroupBook(); + final hb = b.add('한빛전자', CardKinds.company, parent: CardKinds.company)!; + final pay = b.add('결제팀', CardKinds.company, parent: hb.id)!; + final other = b.add('누리소프트', CardKinds.company, parent: CardKinds.company)!; + expect(b.add('결제팀', CardKinds.company, parent: other.id), isNotNull); + expect(b.add('결제팀', CardKinds.company, parent: hb.id), isNull); + expect(b.path(pay.id), '회사 › 한빛전자 › 결제팀'); + expect(b.depth(pay.id), 3); + expect(b.ancestors(pay.id).map((g) => g.id), [hb.id, CardKinds.company]); + expect(b.add('없는 부모 아래', CardKinds.group, parent: '없음'), isNull); + }); + + test('4단까지만 만들 수 있다', () { + final b = GroupBook(); + final a = b.add('2단', CardKinds.group, parent: CardKinds.group)!; + final c = b.add('3단', CardKinds.group, parent: a.id)!; + final d = b.add('4단', CardKinds.group, parent: c.id)!; + expect(b.add('5단', CardKinds.group, parent: d.id), isNull); + }); + + test('하위까지 합친 구성원, 지우면 하위 그룹도 함께', () { + final b = GroupBook(); + b.addMembers(CardKinds.school, ['a']); + b.addMembers('school.high', ['b', 'a']); + b.addMembers('school.university', ['c']); + expect(b.membersDeep(CardKinds.school), {'a', 'b', 'c'}); + expect(b.remove(CardKinds.school), containsAll([CardKinds.school, 'school.high', 'school.graduate'])); + expect(b.byId('school.high'), isNull); + expect(b.roots.length, 3); + }); + + test('잠긴 부모가 닫히면 하위 그룹 사람도 숨긴다', () async { + final b = GroupBook(); + b.addMembers('family.maternal', ['m']); + b.setLock(CardKinds.family, await Pin.make('1234', iterations: 10)); + expect(b.hiddenPeople((g) => true), {'m'}); + }); + + test('같은 부모 아래에서만 순서가 바뀐다', () { + final b = GroupBook(); + b.move('school.graduate', 0); + expect(b.children(CardKinds.school).first.id, 'school.graduate'); + expect(b.roots.first.id, CardKinds.family); + }); + + test('저장하면 나무 모양이 유지되고, 없는 부모·돌고 도는 부모는 맨 위로', () { + final b = GroupBook(); + final hb = b.add('한빛전자', CardKinds.company, parent: CardKinds.company)!; + final c = GroupBook.fromJson(b.toJson()); + expect(c.byId(hb.id)!.parent, CardKinds.company); + const bad = '{"groups":[{"id":"x","name":"X","parent":"y"},{"id":"y","name":"Y","parent":"x"},{"id":"z","name":"Z","parent":"없음"}]}'; + final d = GroupBook.fromJson(bad); + expect(d.groups.map((g) => g.id).toSet(), {'x', 'y', 'z'}); + expect(d.byId('z')!.parent, isNull); + }); + }); } diff --git a/test/widget_test.dart b/test/widget_test.dart index b2cfdbb..df98f45 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -48,7 +48,7 @@ void main() { expect(find.text('내 명함'), findsWidgets); }); - testWidgets('설정에서 그룹 관리를 열면 처음 그룹 네 개가 보이고, 새 그룹을 더할 수 있다', (tester) async { + testWidgets('설정에서 그룹 관리를 열면 처음 그룹 네 개가 보이고, 새 그룹을 더하고, 학교 안에 하위 그룹이 보인다', (tester) async { final device = await tester.runAsync(() => AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '이미영', start: false)); await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap(keys: true, app: device!.app))); await tester.pumpAndSettle(); @@ -69,5 +69,9 @@ void main() { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); await tester.pumpAndSettle(); expect(find.text('등산 모임'), findsOneWidget); + await tester.tap(find.text('학교')); + await tester.pumpAndSettle(); + expect(find.text('고등학교'), findsOneWidget); + expect(find.text('대학원'), findsOneWidget); }); }