그룹 계층(하위 그룹) 추가: 학교 › 초등~대학원, 가족 › 친가·외가·처가·시가·촌수 모름

- groups: parent, 나무 순서, 경로 이름, 하위까지 합친 구성원, 4단 제한, 같은 부모 아래에서만 이름 중복 검사, 지우면 하위도 함께
- 잠긴 부모가 닫히면 하위 그룹·구성원도 닫힘, 깨진 저장값의 없는/도는 부모는 맨 위로
- ui: 그룹 관리는 맨 위 그룹만, 그룹 안에서 하위 그룹 만들기·들어가기, 공용 그룹 메뉴(GroupMenuButton)
- 시험 8개 추가 (전체 274개)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
diadmin 2026-10-26 14:27:14 +09:00
parent 413489f49c
commit fc78d35109
12 changed files with 519 additions and 191 deletions

View File

@ -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` 를 붙이면 두 번째 빌드부터 빨라진다.

View File

@ -104,7 +104,8 @@ flutter run
## 그룹 · 주소록 가져오기
- 설정(내 정보 → 저장·보안) → "그룹 · 주소록". 처음 그룹은 가족·학교·회사·그룹·여행이고, 더하기·이름과 종류 고치기·순서 바꾸기·지우기가 된다(지워도 사람은 인맥에 남는다).
- 그룹마다 비밀번호(4~16자)를 걸 수 있다. 잠긴 그룹은 열 때 비밀번호를 묻고, 닫혀 있으면 인맥 목록에서도 그 사람들을 숨긴다. 연 뒤 정한 시간(기본 5분, 1~60분)이 지나거나 앱을 내리면 다시 잠근다.
- 그룹 아래에 하위 그룹을 4단까지 만든다. 처음에는 학교 › 초등학교·중학교·고등학교·대학교·대학원, 가족 › 친가·외가·처가·시가·친척(촌수 모름)이 있고, 회사 › 회사 이름 › 프로젝트·팀은 직접 만든다. 같은 부모 아래에서만 이름이 겹치지 않으면 된다. 지우면 하위 그룹도 함께 지워진다(사람은 남는다).
- 그룹마다 비밀번호(4~16자)를 걸 수 있다. 잠긴 그룹은 열 때 비밀번호를 묻고, 닫혀 있으면 하위 그룹과 인맥 목록에서도 그 사람들을 숨긴다. 연 뒤 정한 시간(기본 5분, 1~60분)이 지나거나 앱을 내리면 다시 잠근다.
- 5번 틀리면 30초, 그 뒤로 틀릴 때마다 두 배(최대 1시간) 기다려야 한다. 비밀번호는 PBKDF2-SHA256 값만 남긴다. 잊으면 그 그룹은 지우고 다시 만들어야 한다.
- "주소록에서 가져오기": 이름·초성(ㄱㅁㅅ)·번호로 찾고, 휴대폰 주소록 그룹(라벨)으로 거르고, 이미 있는 사람은 빼고 볼 수 있다. 한 명씩 또는 보이는 사람을 모두 고른 뒤, 넣을 그룹(여러 개)을 골라 가져온다.
- 번호가 여럿이면 휴대폰(010) 번호를 먼저 쓴다. 가져온 사람은 이 기기 인맥에만 들어가고, "친구 요청도 보내기"를 켰을 때만 상대에게 요청이 간다. 주소록은 읽기만 한다.

View File

@ -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<List<GroupView>> 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<List<GroupView>> 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<GroupView?> 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<Group?> add(String name, String kind) async {
/// 바로 아래 그룹들. 이 그룹이 닫혀 있으면 빈 목록.
Future<List<GroupView>> 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<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;
}
@ -83,13 +119,14 @@ class GroupService {
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;
b.remove(id);
_openedAt.remove(id);
for (final x in b.remove(id)) {
_openedAt.remove(x);
}
await _save(b);
return true;
}

View File

@ -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<String> members; // 사람 id
final PinHash? lock;
bool get locked => lock != null;
Group copyWith({String? name, String? kind, Set<String>? 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<String, Object?> 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<Group> 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<Group> 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),
];
}
}

View File

@ -3,7 +3,9 @@ import 'dart:convert';
import 'group.dart';
import 'pin.dart';
/// 그룹 목록 전체 (순서·구성원·잠금·다시 잠그는 시간). 글자(JSON)로 저장했다가 되살린다.
/// 그룹 나무 전체 (순서·하위 그룹·구성원·잠금·다시 잠그는 시간). 글자(JSON)로 저장했다가 되살린다.
///
/// 같은 부모 아래의 순서는 [_groups] 에 놓인 순서를 따른다.
class GroupBook {
GroupBook({List<Group>? 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<Group> _groups;
int _next;
/// 잠긴 그룹을 연 뒤 이 시간(분)이 지나면 다시 잠근다.
int relockMinutes;
List<Group> get groups => List.unmodifiable(_groups);
/// 나무 순서(부모 다음에 그 자식들)로 펼친 전체 목록.
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) {
@ -27,34 +51,71 @@ class GroupBook {
return null;
}
/// 이름을 다듬는다. 비었거나 이미 있는 이름이면 null.
String? _cleanName(String name, {String? except}) {
/// 위쪽 그룹들 (가까운 부모부터).
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.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<String> 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<Group> groupsOf(String person) => [for (final g in _groups) if (g.members.contains(person)) g];
/// 이 사람이 들어 있는 그룹들 (나무 순서).
List<Group> groupsOf(String person) => [for (final g in groups) if (g.members.contains(person)) g];
/// [closed] 에 든(지금 열려 있지 않은) 잠긴 그룹의 구성원. 다른 그룹에도 있어도 잠긴 쪽을 따른다.
/// 잠긴 그룹 중 [closed] 인 것(지금 열려 있지 않은 것)과 그 하위 그룹의 구성원.
/// 다른 그룹에도 있어도 잠긴 쪽을 따른다.
Set<String> 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 = <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(

View File

@ -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 {

View File

@ -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<GroupView> children;
final Map<String, int> grandchildren; // 하위 그룹 id → 그 아래 그룹 수
final List<Person>? 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<void> _addFromPeople(BuildContext context, List<Person> 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<Person> 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);
},
),
),
]);
}
}

View File

@ -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<GroupView> groups;
final List<GroupView> 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<void> _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))),
],
),
]),
);
}
}

View File

@ -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<bool> 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<void> _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 = <String>[
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),
]),
);
}
}

View File

@ -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: '사람은 남는다');
});
}

View File

@ -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);
});
});
}

View File

@ -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<void>.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);
});
}