그룹 계층(하위 그룹) 추가: 학교 › 초등~대학원, 가족 › 친가·외가·처가·시가·촌수 모름
- groups: parent, 나무 순서, 경로 이름, 하위까지 합친 구성원, 4단 제한, 같은 부모 아래에서만 이름 중복 검사, 지우면 하위도 함께 - 잠긴 부모가 닫히면 하위 그룹·구성원도 닫힘, 깨진 저장값의 없는/도는 부모는 맨 위로 - ui: 그룹 관리는 맨 위 그룹만, 그룹 안에서 하위 그룹 만들기·들어가기, 공용 그룹 메뉴(GroupMenuButton) - 시험 8개 추가 (전체 274개) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
413489f49c
commit
fc78d35109
|
|
@ -31,10 +31,10 @@ Android 다크모드 인맥 앱(Flutter/Dart). 옛 "Family MAP" + 카카오톡
|
||||||
- 화면은 `AppScope.of(context).app`(FriendlyApp)의 서비스만 쓴다.
|
- 화면은 `AppScope.of(context).app`(FriendlyApp)의 서비스만 쓴다.
|
||||||
|
|
||||||
## 상태 (2026-10-26 기준)
|
## 상태 (2026-10-26 기준)
|
||||||
- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 266개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과.
|
- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 274개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과.
|
||||||
- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 다음은 "채팅에서 시작하는 관계망" 로드맵 1단계(그룹 계층).
|
- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 관계망 로드맵 1단계(그룹 계층: 학교 › 초등~대학원, 가족 › 친가·외가·처가·시가·촌수 모름, 회사 › 회사 › 팀) 끝. 다음은 2단계(채팅에서 그룹 넣기·촌수 맞추기).
|
||||||
- `flutter build apk --debug` 성공(app-debug.apk 약 190MB). 첫 빌드는 SDK 34·35, CMake 를 내려받느라 약 17분 걸린다.
|
- `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).
|
- 확인 안 된 곳: 실제 기기에서 화면 실행과 플러그인 동작(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 추가 때부터).
|
- pubspec.lock 은 이 컨테이너(Flutter stable)에서 풀린 판을 기준으로 올린다(2026-10-26 flutter_contacts 추가 때부터).
|
||||||
- 빌드 캐시: `-v fm-gradle:/root/.gradle -v fm-pub:/root/.pub-cache` 를 붙이면 두 번째 빌드부터 빨라진다.
|
- 빌드 캐시: `-v fm-gradle:/root/.gradle -v fm-pub:/root/.pub-cache` 를 붙이면 두 번째 빌드부터 빨라진다.
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,8 @@ flutter run
|
||||||
|
|
||||||
## 그룹 · 주소록 가져오기
|
## 그룹 · 주소록 가져오기
|
||||||
- 설정(내 정보 → 저장·보안) → "그룹 · 주소록". 처음 그룹은 가족·학교·회사·그룹·여행이고, 더하기·이름과 종류 고치기·순서 바꾸기·지우기가 된다(지워도 사람은 인맥에 남는다).
|
- 설정(내 정보 → 저장·보안) → "그룹 · 주소록". 처음 그룹은 가족·학교·회사·그룹·여행이고, 더하기·이름과 종류 고치기·순서 바꾸기·지우기가 된다(지워도 사람은 인맥에 남는다).
|
||||||
- 그룹마다 비밀번호(4~16자)를 걸 수 있다. 잠긴 그룹은 열 때 비밀번호를 묻고, 닫혀 있으면 인맥 목록에서도 그 사람들을 숨긴다. 연 뒤 정한 시간(기본 5분, 1~60분)이 지나거나 앱을 내리면 다시 잠근다.
|
- 그룹 아래에 하위 그룹을 4단까지 만든다. 처음에는 학교 › 초등학교·중학교·고등학교·대학교·대학원, 가족 › 친가·외가·처가·시가·친척(촌수 모름)이 있고, 회사 › 회사 이름 › 프로젝트·팀은 직접 만든다. 같은 부모 아래에서만 이름이 겹치지 않으면 된다. 지우면 하위 그룹도 함께 지워진다(사람은 남는다).
|
||||||
|
- 그룹마다 비밀번호(4~16자)를 걸 수 있다. 잠긴 그룹은 열 때 비밀번호를 묻고, 닫혀 있으면 하위 그룹과 인맥 목록에서도 그 사람들을 숨긴다. 연 뒤 정한 시간(기본 5분, 1~60분)이 지나거나 앱을 내리면 다시 잠근다.
|
||||||
- 5번 틀리면 30초, 그 뒤로 틀릴 때마다 두 배(최대 1시간) 기다려야 한다. 비밀번호는 PBKDF2-SHA256 값만 남긴다. 잊으면 그 그룹은 지우고 다시 만들어야 한다.
|
- 5번 틀리면 30초, 그 뒤로 틀릴 때마다 두 배(최대 1시간) 기다려야 한다. 비밀번호는 PBKDF2-SHA256 값만 남긴다. 잊으면 그 그룹은 지우고 다시 만들어야 한다.
|
||||||
- "주소록에서 가져오기": 이름·초성(ㄱㅁㅅ)·번호로 찾고, 휴대폰 주소록 그룹(라벨)으로 거르고, 이미 있는 사람은 빼고 볼 수 있다. 한 명씩 또는 보이는 사람을 모두 고른 뒤, 넣을 그룹(여러 개)을 골라 가져온다.
|
- "주소록에서 가져오기": 이름·초성(ㄱㅁㅅ)·번호로 찾고, 휴대폰 주소록 그룹(라벨)으로 거르고, 이미 있는 사람은 빼고 볼 수 있다. 한 명씩 또는 보이는 사람을 모두 고른 뒤, 넣을 그룹(여러 개)을 골라 가져온다.
|
||||||
- 번호가 여럿이면 휴대폰(010) 번호를 먼저 쓴다. 가져온 사람은 이 기기 인맥에만 들어가고, "친구 요청도 보내기"를 켰을 때만 상대에게 요청이 간다. 주소록은 읽기만 한다.
|
- 번호가 여럿이면 휴대폰(010) 번호를 먼저 쓴다. 가져온 사람은 이 기기 인맥에만 들어가고, "친구 요청도 보내기"를 켰을 때만 상대에게 요청이 간다. 주소록은 읽기만 한다.
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,24 @@ import 'change_bus.dart';
|
||||||
|
|
||||||
/// 그룹 목록 한 칸. 잠긴 그룹이 닫혀 있으면 구성원 수를 알려 주지 않는다.
|
/// 그룹 목록 한 칸. 잠긴 그룹이 닫혀 있으면 구성원 수를 알려 주지 않는다.
|
||||||
class GroupView {
|
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 Group group;
|
||||||
|
|
||||||
/// 지금 구성원을 볼 수 있는지 (잠기지 않았거나, 비밀번호로 연 뒤 시간이 지나지 않음).
|
/// 지금 구성원을 볼 수 있는지 (자신과 위쪽 잠긴 그룹이 모두 열려 있음).
|
||||||
final bool open;
|
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 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();
|
_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;
|
if (!g.locked) return true;
|
||||||
final at = _openedAt[g.id];
|
final at = _openedAt[g.id];
|
||||||
if (at == null) return false;
|
if (at == null) return false;
|
||||||
|
|
@ -59,21 +73,43 @@ class GroupService {
|
||||||
return true;
|
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();
|
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 {
|
Future<GroupView?> view(String id) async {
|
||||||
final b = await _load();
|
final b = await _load();
|
||||||
final g = b.byId(id);
|
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 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);
|
if (g != null) await _save(b);
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
@ -83,13 +119,14 @@ class GroupService {
|
||||||
Future<bool> setKind(String id, String kind) => _edit(id, (b) => b.setKind(id, kind));
|
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> move(String id, int to) => _edit(id, (b) => b.move(id, to));
|
||||||
|
|
||||||
/// 그룹을 지운다. 잠긴 그룹은 열려 있을 때만 지울 수 있다 (사람은 남는다).
|
/// 그룹을 하위 그룹과 함께 지운다. 잠긴 그룹은 열려 있을 때만 지울 수 있다 (사람은 남는다).
|
||||||
Future<bool> remove(String id) async {
|
Future<bool> remove(String id) async {
|
||||||
final b = await _load();
|
final b = await _load();
|
||||||
final g = b.byId(id);
|
final g = b.byId(id);
|
||||||
if (g == null || !_isOpen(b, g)) return false;
|
if (g == null || !_isOpen(b, g)) return false;
|
||||||
b.remove(id);
|
for (final x in b.remove(id)) {
|
||||||
_openedAt.remove(id);
|
_openedAt.remove(x);
|
||||||
|
}
|
||||||
await _save(b);
|
await _save(b);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,49 +3,70 @@ import 'package:friendly_map/modules/core/core.dart';
|
||||||
import 'pin.dart';
|
import 'pin.dart';
|
||||||
|
|
||||||
/// 사람을 묶는 그룹 하나. [kind] 는 명함 종류(가족·학교·회사·그룹·개인) 중 하나다.
|
/// 사람을 묶는 그룹 하나. [kind] 는 명함 종류(가족·학교·회사·그룹·개인) 중 하나다.
|
||||||
/// [lock] 이 있으면 비밀번호를 넣어야 구성원을 볼 수 있다.
|
/// [parent] 가 있으면 그 그룹의 하위 그룹이다 (예: 학교 > 고등학교, 회사 > 한빛전자 > 결제팀).
|
||||||
|
/// [lock] 이 있으면 비밀번호를 넣어야 구성원과 하위 그룹을 볼 수 있다.
|
||||||
class Group {
|
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 id;
|
||||||
final String name;
|
final String name;
|
||||||
final String kind;
|
final String kind;
|
||||||
|
final String? parent;
|
||||||
final Set<String> members; // 사람 id
|
final Set<String> members; // 사람 id
|
||||||
final PinHash? lock;
|
final PinHash? lock;
|
||||||
|
|
||||||
bool get locked => lock != null;
|
bool get locked => lock != null;
|
||||||
|
|
||||||
Group copyWith({String? name, String? kind, Set<String>? members}) =>
|
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() => {
|
Map<String, Object?> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'name': name,
|
'name': name,
|
||||||
'kind': kind,
|
'kind': kind,
|
||||||
|
if (parent != null) 'parent': parent,
|
||||||
'members': members.toList()..sort(),
|
'members': members.toList()..sort(),
|
||||||
if (lock != null) 'lock': lock!.toJson(),
|
if (lock != null) 'lock': lock!.toJson(),
|
||||||
};
|
};
|
||||||
|
|
||||||
static Group? fromJson(Object? j) {
|
static Group? fromJson(Object? j) {
|
||||||
if (j is! Map) return null;
|
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;
|
if (id is! String || name is! String) return null;
|
||||||
return Group(
|
return Group(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
kind: kind is String && CardKinds.all.contains(kind) ? kind : CardKinds.group,
|
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 {},
|
members: m is List ? {for (final x in m) if (x is String) x} : const {},
|
||||||
lock: PinHash.fromJson(j['lock']),
|
lock: PinHash.fromJson(j['lock']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 처음 쓸 때 만들어 두는 그룹들.
|
/// 촌수를 모르는 친척을 모아 두는 가족 하위 그룹.
|
||||||
static List<Group> defaults() => const [
|
static const unknownRelatives = 'family.unknown';
|
||||||
Group(id: CardKinds.family, name: '가족', kind: CardKinds.family),
|
|
||||||
Group(id: CardKinds.school, name: '학교', kind: CardKinds.school),
|
/// 처음 쓸 때 만들어 두는 그룹들 (부모 다음에 자식 순서).
|
||||||
|
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.company, name: '회사', kind: CardKinds.company),
|
||||||
Group(id: CardKinds.group, name: '그룹·여행', kind: CardKinds.group),
|
Group(id: CardKinds.group, name: '그룹·여행', kind: CardKinds.group),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import 'dart:convert';
|
||||||
import 'group.dart';
|
import 'group.dart';
|
||||||
import 'pin.dart';
|
import 'pin.dart';
|
||||||
|
|
||||||
/// 그룹 목록 전체 (순서·구성원·잠금·다시 잠그는 시간). 글자(JSON)로 저장했다가 되살린다.
|
/// 그룹 나무 전체 (순서·하위 그룹·구성원·잠금·다시 잠그는 시간). 글자(JSON)로 저장했다가 되살린다.
|
||||||
|
///
|
||||||
|
/// 같은 부모 아래의 순서는 [_groups] 에 놓인 순서를 따른다.
|
||||||
class GroupBook {
|
class GroupBook {
|
||||||
GroupBook({List<Group>? groups, this.relockMinutes = defaultRelockMinutes, int nextId = 1})
|
GroupBook({List<Group>? groups, this.relockMinutes = defaultRelockMinutes, int nextId = 1})
|
||||||
: _groups = [...(groups ?? Group.defaults())],
|
: _groups = [...(groups ?? Group.defaults())],
|
||||||
|
|
@ -12,13 +14,35 @@ class GroupBook {
|
||||||
static const defaultRelockMinutes = 5;
|
static const defaultRelockMinutes = 5;
|
||||||
static const maxNameLength = 20;
|
static const maxNameLength = 20;
|
||||||
|
|
||||||
|
/// 가장 깊은 단계 (맨 위가 1).
|
||||||
|
static const maxDepth = 4;
|
||||||
|
|
||||||
final List<Group> _groups;
|
final List<Group> _groups;
|
||||||
int _next;
|
int _next;
|
||||||
|
|
||||||
/// 잠긴 그룹을 연 뒤 이 시간(분)이 지나면 다시 잠근다.
|
/// 잠긴 그룹을 연 뒤 이 시간(분)이 지나면 다시 잠근다.
|
||||||
int relockMinutes;
|
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) {
|
Group? byId(String id) {
|
||||||
for (final g in _groups) {
|
for (final g in _groups) {
|
||||||
|
|
@ -27,34 +51,71 @@ class GroupBook {
|
||||||
return null;
|
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();
|
final n = name.trim();
|
||||||
if (n.isEmpty) return null;
|
if (n.isEmpty) return null;
|
||||||
final cut = n.length > maxNameLength ? n.substring(0, maxNameLength) : n;
|
final cut = n.length > maxNameLength ? n.substring(0, maxNameLength) : n;
|
||||||
for (final g in _groups) {
|
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;
|
return cut;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 새 그룹을 맨 뒤에 더한다. 이름이 비었거나 겹치면 null.
|
/// 새 그룹을 [parent] 아래 맨 뒤에 더한다. 이름이 비었거나 겹치거나, 부모가 없거나 너무 깊으면 null.
|
||||||
Group? add(String name, String kind) {
|
Group? add(String name, String kind, {String? parent}) {
|
||||||
final n = _cleanName(name);
|
if (parent != null && (byId(parent) == null || depth(parent) >= maxDepth)) return null;
|
||||||
|
final n = _cleanName(name, parent);
|
||||||
if (n == null) return null;
|
if (n == null) return null;
|
||||||
var id = 'g${_next++}';
|
var id = 'g${_next++}';
|
||||||
while (byId(id) != null) {
|
while (byId(id) != null) {
|
||||||
id = 'g${_next++}';
|
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);
|
_groups.add(g);
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool rename(String id, String name) {
|
bool rename(String id, String name) {
|
||||||
final i = _index(id);
|
final i = _index(id);
|
||||||
final n = _cleanName(name, except: id);
|
if (i < 0) return false;
|
||||||
if (i < 0 || n == null) return false;
|
final n = _cleanName(name, _groups[i].parent, except: id);
|
||||||
|
if (n == null) return false;
|
||||||
_groups[i] = _groups[i].copyWith(name: n);
|
_groups[i] = _groups[i].copyWith(name: n);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -66,20 +127,24 @@ class GroupBook {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 그룹을 지운다 (사람은 그대로 남는다).
|
/// 그룹과 그 하위 그룹을 모두 지운다 (사람은 그대로 남는다). 지운 id 들.
|
||||||
bool remove(String id) {
|
Set<String> remove(String id) {
|
||||||
final i = _index(id);
|
final gone = subtree(id);
|
||||||
if (i < 0) return false;
|
_groups.removeWhere((g) => gone.contains(g.id));
|
||||||
_groups.removeAt(i);
|
return gone;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [id] 그룹을 [to] 번째로 옮긴다.
|
/// [id] 그룹을 같은 부모 아래 [to] 번째로 옮긴다.
|
||||||
bool move(String id, int to) {
|
bool move(String id, int to) {
|
||||||
final i = _index(id);
|
final g = byId(id);
|
||||||
if (i < 0) return false;
|
if (g == null) return false;
|
||||||
final g = _groups.removeAt(i);
|
_groups.remove(g);
|
||||||
_groups.insert(to.clamp(0, _groups.length), 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;
|
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) => {
|
Set<String> hiddenPeople(bool Function(Group g) closed) => {
|
||||||
for (final g in _groups)
|
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);
|
int _index(String id) => _groups.indexWhere((g) => g.id == id);
|
||||||
|
|
||||||
String toJson() => jsonEncode({
|
String toJson() => jsonEncode({
|
||||||
'v': 1,
|
'v': 2,
|
||||||
'next': _next,
|
'next': _next,
|
||||||
'relock': relockMinutes,
|
'relock': relockMinutes,
|
||||||
'groups': [for (final g in _groups) g.toJson()],
|
'groups': [for (final g in _groups) g.toJson()],
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 비었거나 깨진 글자면 처음 그룹들로 시작한다.
|
/// 비었거나 깨진 글자면 처음 그룹들로 시작한다. 없는 부모나 돌고 도는 부모는 맨 위로 올린다.
|
||||||
factory GroupBook.fromJson(String? text) {
|
factory GroupBook.fromJson(String? text) {
|
||||||
if (text == null || text.isEmpty) return GroupBook();
|
if (text == null || text.isEmpty) return GroupBook();
|
||||||
try {
|
try {
|
||||||
|
|
@ -149,6 +215,16 @@ class GroupBook {
|
||||||
final g = Group.fromJson(x);
|
final g = Group.fromJson(x);
|
||||||
if (g != null && seen.add(g.id)) list.add(g);
|
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 relock = j['relock'];
|
||||||
final next = j['next'];
|
final next = j['next'];
|
||||||
return GroupBook(
|
return GroupBook(
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,7 @@ class _OptionsSheetState extends State<_OptionsSheet> {
|
||||||
for (final v in views)
|
for (final v in views)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
avatar: v.group.locked ? Icon(v.open ? Icons.lock_open : Icons.lock, size: 16) : null,
|
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),
|
selected: _groups.contains(v.group.id),
|
||||||
selectedColor: Pal.ofKind(v.group.kind).withValues(alpha: 0.3),
|
selectedColor: Pal.ofKind(v.group.kind).withValues(alpha: 0.3),
|
||||||
onSelected: (on) async {
|
onSelected: (on) async {
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,35 @@ import '../theme.dart';
|
||||||
import '../widgets/async_view.dart';
|
import '../widgets/async_view.dart';
|
||||||
import '../widgets/common.dart';
|
import '../widgets/common.dart';
|
||||||
import '../widgets/group_dialogs.dart';
|
import '../widgets/group_dialogs.dart';
|
||||||
|
import '../widgets/group_menu.dart';
|
||||||
import '../widgets/pin_dialog.dart';
|
import '../widgets/pin_dialog.dart';
|
||||||
import 'contact_import_screen.dart';
|
import 'contact_import_screen.dart';
|
||||||
import 'person_screen.dart';
|
import 'person_screen.dart';
|
||||||
|
|
||||||
class _Data {
|
class _Data {
|
||||||
const _Data(this.view, this.members);
|
const _Data(this.view, this.children, this.grandchildren, this.members);
|
||||||
final GroupView? view;
|
final GroupView? view;
|
||||||
|
final List<GroupView> children;
|
||||||
|
final Map<String, int> grandchildren; // 하위 그룹 id → 그 아래 그룹 수
|
||||||
final List<Person>? members; // 닫혀 있으면 null
|
final List<Person>? members; // 닫혀 있으면 null
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 그룹 한 개: 구성원 보기·넣기·빼기. 잠긴 그룹이 닫히면 구성원을 감춘다.
|
/// 그룹 한 개: 하위 그룹과 구성원 보기·넣기·빼기. 잠긴 그룹이 닫히면 모두 감춘다.
|
||||||
class GroupDetailScreen extends StatelessWidget {
|
class GroupDetailScreen extends StatelessWidget {
|
||||||
const GroupDetailScreen({super.key, required this.groupId});
|
const GroupDetailScreen({super.key, required this.groupId});
|
||||||
|
|
||||||
final String 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 {
|
Future<void> _addFromPeople(BuildContext context, List<Person> members) async {
|
||||||
final app = AppScope.of(context).app;
|
final app = AppScope.of(context).app;
|
||||||
final hidden = await app.groups.hiddenPeople();
|
final hidden = await app.groups.hiddenPeople();
|
||||||
|
|
@ -39,7 +52,7 @@ class GroupDetailScreen extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final app = AppScope.of(context).app;
|
final app = AppScope.of(context).app;
|
||||||
return AsyncView<_Data>(
|
return AsyncView<_Data>(
|
||||||
load: (a) async => _Data(await a.groups.view(groupId), await a.groups.members(groupId)),
|
load: _load,
|
||||||
builder: (context, d) {
|
builder: (context, d) {
|
||||||
final v = d.view;
|
final v = d.view;
|
||||||
if (v == null) {
|
if (v == null) {
|
||||||
|
|
@ -49,18 +62,29 @@ class GroupDetailScreen extends StatelessWidget {
|
||||||
final members = d.members;
|
final members = d.members;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Row(children: [
|
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
Flexible(child: Text(g.name, overflow: TextOverflow.ellipsis)),
|
Flexible(child: Text(g.name, overflow: TextOverflow.ellipsis)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
KindChip(g.kind, CardKinds.label(g.kind)),
|
KindChip(g.kind, CardKinds.label(g.kind)),
|
||||||
]),
|
]),
|
||||||
|
if (v.depth > 1) Text(v.path, style: const TextStyle(fontSize: 11, color: Pal.muted)),
|
||||||
|
]),
|
||||||
actions: [
|
actions: [
|
||||||
if (g.locked && members != null)
|
if (g.locked && members != null)
|
||||||
IconButton(tooltip: '지금 잠그기', onPressed: () => app.groups.lock(groupId), icon: const Icon(Icons.lock_outline)),
|
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
|
body: members == null ? _closed(context, v) : _open(context, v, d, members),
|
||||||
? Center(
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _closed(BuildContext context, GroupView v) => Center(
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
const Icon(Icons.lock, size: 48, color: Pal.muted),
|
const Icon(Icons.lock, size: 48, color: Pal.muted),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
@ -68,8 +92,20 @@ class GroupDetailScreen extends StatelessWidget {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton(onPressed: () => openGroup(context, v), child: const Text('비밀번호로 열기')),
|
FilledButton(onPressed: () => openGroup(context, v), child: const Text('비밀번호로 열기')),
|
||||||
]),
|
]),
|
||||||
)
|
);
|
||||||
: ListView(padding: const EdgeInsets.only(top: 4, bottom: 96), children: [
|
|
||||||
|
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(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
|
|
@ -100,9 +136,6 @@ class GroupDetailScreen extends StatelessWidget {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]),
|
]);
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,22 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:friendly_map/modules/app/app.dart';
|
import 'package:friendly_map/modules/app/app.dart';
|
||||||
import 'package:friendly_map/modules/core/core.dart';
|
|
||||||
|
|
||||||
import '../app_scope.dart';
|
import '../app_scope.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
import '../widgets/async_view.dart';
|
import '../widgets/async_view.dart';
|
||||||
import '../widgets/common.dart';
|
import '../widgets/common.dart';
|
||||||
import '../widgets/group_dialogs.dart';
|
import '../widgets/group_dialogs.dart';
|
||||||
import '../widgets/pin_dialog.dart';
|
import '../widgets/group_menu.dart';
|
||||||
import 'contact_import_screen.dart';
|
import 'contact_import_screen.dart';
|
||||||
import 'group_detail_screen.dart';
|
import 'group_detail_screen.dart';
|
||||||
|
|
||||||
class _Data {
|
class _Data {
|
||||||
const _Data(this.groups, this.relock);
|
const _Data(this.groups, this.relock);
|
||||||
final List<GroupView> groups;
|
final List<GroupView> groups; // 펼친 전체 (보이는 것만)
|
||||||
final int relock;
|
final int relock;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _Act { edit, up, down, setPin, clearPin, lockNow, delete }
|
/// 설정 > 그룹 관리: 맨 위 그룹 목록. 누르면 그 그룹 안(하위 그룹·구성원)으로 들어간다.
|
||||||
|
|
||||||
/// 설정 > 그룹 관리: 그룹 정의(이름·종류·순서), 비밀번호 잠금, 지우기.
|
|
||||||
class GroupsScreen extends StatelessWidget {
|
class GroupsScreen extends StatelessWidget {
|
||||||
const GroupsScreen({super.key});
|
const GroupsScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -31,39 +28,6 @@ class GroupsScreen extends StatelessWidget {
|
||||||
if (context.mounted && g == null) showNote(context, '이름이 비었거나 같은 이름의 그룹이 있어요');
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final app = AppScope.of(context).app;
|
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: [
|
builder: (context, d) => ListView(padding: const EdgeInsets.only(top: 4, bottom: 32), children: [
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.fromLTRB(20, 0, 20, 10),
|
padding: EdgeInsets.fromLTRB(20, 0, 20, 10),
|
||||||
child: Text('가족·학교 같은 그룹을 정하고 사람을 묶어요. 비밀번호를 건 그룹은 열 때마다 물어보고, 닫혀 있으면 인맥 목록에서도 숨겨요. 그룹은 이 기기에만 저장돼요.',
|
child: Text('가족·학교 같은 그룹을 정하고 그 아래에 하위 그룹(고등학교, 회사 › 팀 …)을 만들어 사람을 묶어요. '
|
||||||
|
'비밀번호를 건 그룹은 열 때마다 물어보고, 닫혀 있으면 하위 그룹과 인맥 목록에서도 숨겨요. 그룹은 이 기기에만 저장돼요.',
|
||||||
style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)),
|
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(
|
Panel(
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
const Expanded(
|
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))),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ void main() {
|
||||||
void later(int minutes) => me.app.deps.clock.offset += minutes * 6000;
|
void later(int minutes) => me.app.deps.clock.offset += minutes * 6000;
|
||||||
|
|
||||||
test('그룹 정의: 처음 네 개, 더하고 이름·종류 바꾸고 지우기', () async {
|
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))!;
|
final g = (await groups.add('등산 모임', CardKinds.group))!;
|
||||||
expect(await groups.add('등산 모임', CardKinds.group), isNull);
|
expect(await groups.add('등산 모임', CardKinds.group), isNull);
|
||||||
expect(await groups.rename(g.id, '산악회'), isTrue);
|
expect(await groups.rename(g.id, '산악회'), isTrue);
|
||||||
|
|
@ -98,4 +98,30 @@ void main() {
|
||||||
expect(v.group.locked, isTrue);
|
expect(v.group.locked, isTrue);
|
||||||
expect(v.open, isFalse);
|
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: '사람은 남는다');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,19 @@ import 'package:friendly_map/modules/groups/groups.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('그룹 목록', () {
|
group('그룹 목록', () {
|
||||||
test('처음에는 가족·학교·회사·그룹·여행 네 개', () {
|
test('처음에는 맨 위에 가족·학교·회사·그룹·여행 네 개', () {
|
||||||
final b = GroupBook();
|
final b = GroupBook();
|
||||||
expect(b.groups.map((g) => g.name), ['가족', '학교', '회사', '그룹·여행']);
|
expect(b.roots.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.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('더하기·이름 바꾸기·지우기, 빈 이름과 겹치는 이름은 거절', () {
|
test('더하기·이름 바꾸기·지우기, 빈 이름과 겹치는 이름은 거절', () {
|
||||||
|
|
@ -20,9 +29,9 @@ void main() {
|
||||||
expect(b.rename(g.id, '학교'), isFalse);
|
expect(b.rename(g.id, '학교'), isFalse);
|
||||||
expect(b.rename(g.id, '등산 모임'), isTrue, reason: '자기 이름 그대로는 괜찮다');
|
expect(b.rename(g.id, '등산 모임'), isTrue, reason: '자기 이름 그대로는 괜찮다');
|
||||||
expect(b.byId(g.id)!.name, '등산 모임');
|
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.byId(g.id), isNull);
|
||||||
expect(b.remove('없음'), isFalse);
|
expect(b.remove('없음'), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('이름은 20자까지 자른다', () {
|
test('이름은 20자까지 자른다', () {
|
||||||
|
|
@ -71,8 +80,8 @@ void main() {
|
||||||
expect(c.byId(g.id)!.locked, isTrue);
|
expect(c.byId(g.id)!.locked, isTrue);
|
||||||
expect(c.relockMinutes, 10);
|
expect(c.relockMinutes, 10);
|
||||||
expect(c.add('새 그룹', CardKinds.group)!.id, isNot(g.id), reason: '새 번호표는 겹치지 않는다');
|
expect(c.add('새 그룹', CardKinds.group)!.id, isNot(g.id), reason: '새 번호표는 겹치지 않는다');
|
||||||
expect(GroupBook.fromJson('{깨짐').groups.length, 4);
|
expect(GroupBook.fromJson('{깨짐').roots.length, 4);
|
||||||
expect(GroupBook.fromJson(null).groups.length, 4);
|
expect(GroupBook.fromJson(null).roots.length, 4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -125,4 +134,63 @@ void main() {
|
||||||
expect(g.waitLeft(0), 360000);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ void main() {
|
||||||
expect(find.text('내 명함'), findsWidgets);
|
expect(find.text('내 명함'), findsWidgets);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('설정에서 그룹 관리를 열면 처음 그룹 네 개가 보이고, 새 그룹을 더할 수 있다', (tester) async {
|
testWidgets('설정에서 그룹 관리를 열면 처음 그룹 네 개가 보이고, 새 그룹을 더하고, 학교 안에 하위 그룹이 보인다', (tester) async {
|
||||||
final device = await tester.runAsync(() => AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '이미영', start: false));
|
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.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap(keys: true, app: device!.app)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -69,5 +69,9 @@ void main() {
|
||||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('등산 모임'), findsOneWidget);
|
expect(find.text('등산 모임'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('학교'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('고등학교'), findsOneWidget);
|
||||||
|
expect(find.text('대학원'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue