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