채팅에서 관계 정하기: 가족 촌수 맞추기·친가/외가/처가 나누기, 학교·회사·모임 그룹에 넣기
- kinship: familySide (새 파일, 기존 계산은 그대로) — 친가·외가·처가·가까운 가족 판별 - app: RelateService — 그룹에 넣으며 연결 고리(가족·동문·직장 동료·같은 모임) 남김, 가족은 가계도 잇기(형제자매는 이름 모를 부모 자동), 촌수 모름 모음 - ui: 대화방·사람 화면의 "관계 정하기" 시트, 사람 화면에 들어간 그룹 표시, 그 자리에서 하위 그룹 만들어 넣기 - 시험 14개 추가 (전체 288개) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
fc78d35109
commit
98fb0f2d0d
|
|
@ -31,8 +31,8 @@ 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` 274개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과.
|
- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 288개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과.
|
||||||
- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 관계망 로드맵 1단계(그룹 계층: 학교 › 초등~대학원, 가족 › 친가·외가·처가·시가·촌수 모름, 회사 › 회사 › 팀) 끝. 다음은 2단계(채팅에서 그룹 넣기·촌수 맞추기).
|
- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 관계망 로드맵 1단계(그룹 계층: 학교 › 초등~대학원, 가족 › 친가·외가·처가·시가·촌수 모름, 회사 › 회사 › 팀) 끝. 2단계(채팅·사람 화면의 "관계 정하기": RelateService, 촌수 맞춰 친가·외가·처가, 촌수 모름 모음, 연결 고리) 끝. 다음은 3단계(팀 그룹↔그룹 대화방, 맡은 업무 정리, 긴급 호출), 4단계(이직 알림).
|
||||||
- `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`, `flutter_contacts` 가 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).
|
||||||
|
|
|
||||||
|
|
@ -110,3 +110,10 @@ flutter run
|
||||||
- "주소록에서 가져오기": 이름·초성(ㄱㅁㅅ)·번호로 찾고, 휴대폰 주소록 그룹(라벨)으로 거르고, 이미 있는 사람은 빼고 볼 수 있다. 한 명씩 또는 보이는 사람을 모두 고른 뒤, 넣을 그룹(여러 개)을 골라 가져온다.
|
- "주소록에서 가져오기": 이름·초성(ㄱㅁㅅ)·번호로 찾고, 휴대폰 주소록 그룹(라벨)으로 거르고, 이미 있는 사람은 빼고 볼 수 있다. 한 명씩 또는 보이는 사람을 모두 고른 뒤, 넣을 그룹(여러 개)을 골라 가져온다.
|
||||||
- 번호가 여럿이면 휴대폰(010) 번호를 먼저 쓴다. 가져온 사람은 이 기기 인맥에만 들어가고, "친구 요청도 보내기"를 켰을 때만 상대에게 요청이 간다. 주소록은 읽기만 한다.
|
- 번호가 여럿이면 휴대폰(010) 번호를 먼저 쓴다. 가져온 사람은 이 기기 인맥에만 들어가고, "친구 요청도 보내기"를 켰을 때만 상대에게 요청이 간다. 주소록은 읽기만 한다.
|
||||||
- 그룹은 이 기기에만 저장한다. 안드로이드: `flutter_contacts` 플러그인과 READ_CONTACTS 권한이 필요하다(`tool/android_patch.py` 가 넣는다).
|
- 그룹은 이 기기에만 저장한다. 안드로이드: `flutter_contacts` 플러그인과 READ_CONTACTS 권한이 필요하다(`tool/android_patch.py` 가 넣는다).
|
||||||
|
|
||||||
|
## 관계 정하기 (채팅에서 시작하는 관계망)
|
||||||
|
- 대화방 위의 관계 아이콘, 또는 사람 화면의 "관계 정하기"에서 고른다: 가족·친척 / 학교 동문 / 회사·업무·팀 / 그룹·모임·여행.
|
||||||
|
- 가족: 나(또는 가계도에 있는 다른 가족)의 부모·자녀·배우자·형제자매를 고르면 가계도에 잇고 촌수·호칭을 계산한다. 아버지 쪽은 친가, 어머니 쪽은 외가, 배우자 쪽은 처가·시가 그룹에 들어간다. 모르면 "친척(촌수 모름)"에 모아 두고, 나중에 이으면 모음에서 빠진다.
|
||||||
|
- 형제자매를 고를 때 기준 사람의 부모가 가계도에 없으면 "부모(이름 모름)"를 이 기기 안에서만 쓰는 사람으로 만들어 잇는다.
|
||||||
|
- 학교·회사·모임: 그 종류의 그룹 나무에서 골라 넣거나, + 로 그 아래 새 그룹(예: 학교 › 고등학교 › 가람고 27회, 회사 › 한빛전자 › 결제팀)을 만들어 바로 넣는다.
|
||||||
|
- 넣으면 연결 고리(가족·동문·직장 동료·같은 모임)가 하나 남아, 공개 범위 표가 그 관계로 적용된다. 같은 종류의 그룹에서 모두 빠지면 연결 고리도 지운다.
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export 'src/location_service.dart';
|
||||||
export 'src/network_service.dart';
|
export 'src/network_service.dart';
|
||||||
export 'src/profile_service.dart';
|
export 'src/profile_service.dart';
|
||||||
export 'src/recovery_service.dart';
|
export 'src/recovery_service.dart';
|
||||||
|
export 'src/relate_service.dart';
|
||||||
export 'src/settings_service.dart';
|
export 'src/settings_service.dart';
|
||||||
export 'src/verify_service.dart';
|
export 'src/verify_service.dart';
|
||||||
export 'src/visibility_service.dart';
|
export 'src/visibility_service.dart';
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import 'location_service.dart';
|
||||||
import 'network_service.dart';
|
import 'network_service.dart';
|
||||||
import 'profile_service.dart';
|
import 'profile_service.dart';
|
||||||
import 'recovery_service.dart';
|
import 'recovery_service.dart';
|
||||||
|
import 'relate_service.dart';
|
||||||
import 'settings_service.dart';
|
import 'settings_service.dart';
|
||||||
import 'verify_service.dart';
|
import 'verify_service.dart';
|
||||||
import 'visibility_service.dart';
|
import 'visibility_service.dart';
|
||||||
|
|
@ -58,6 +59,7 @@ class FriendlyApp {
|
||||||
calls = CallService(storage, deps.callLog, deps.phone);
|
calls = CallService(storage, deps.callLog, deps.phone);
|
||||||
groups = GroupService(storage, clock, bus);
|
groups = GroupService(storage, clock, bus);
|
||||||
contacts = ContactImportService(storage, clock, friends, groups, bus, deps.contacts);
|
contacts = ContactImportService(storage, clock, friends, groups, bus, deps.contacts);
|
||||||
|
relate = RelateService(groups, kin, network, bus);
|
||||||
}
|
}
|
||||||
|
|
||||||
final AppDeps deps;
|
final AppDeps deps;
|
||||||
|
|
@ -80,6 +82,7 @@ class FriendlyApp {
|
||||||
late final CallService calls;
|
late final CallService calls;
|
||||||
late final GroupService groups;
|
late final GroupService groups;
|
||||||
late final ContactImportService contacts;
|
late final ContactImportService contacts;
|
||||||
|
late final RelateService relate;
|
||||||
|
|
||||||
StreamSubscription<SyncEvent>? _events;
|
StreamSubscription<SyncEvent>? _events;
|
||||||
final Set<Future<void>> _tasks = {};
|
final Set<Future<void>> _tasks = {};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
import 'package:friendly_map/modules/groups/groups.dart';
|
||||||
|
import 'package:friendly_map/modules/kinship/kinship.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
import 'group_service.dart';
|
||||||
|
import 'kin_service.dart';
|
||||||
|
import 'network_service.dart';
|
||||||
|
|
||||||
|
/// 가족으로 넣을 때 고르는 이음: [to](기본 나)의 부모·자녀·배우자·형제자매.
|
||||||
|
enum KinLink { parent, child, spouse, sibling }
|
||||||
|
|
||||||
|
/// 넣은 결과: 들어간 그룹과 (가족이면) 내가 본 관계.
|
||||||
|
class PlaceResult {
|
||||||
|
const PlaceResult(this.groupId, this.groupPath, {this.relation});
|
||||||
|
final String groupId;
|
||||||
|
final String groupPath;
|
||||||
|
final Relation? relation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 그룹 종류 → 연결 고리 이름. 공개 범위 표가 이 이름으로 관계 묶음을 찾는다.
|
||||||
|
String? linkWordOf(String kind) => switch (kind) {
|
||||||
|
CardKinds.family => '가족',
|
||||||
|
CardKinds.school => '동문',
|
||||||
|
CardKinds.company => '직장 동료',
|
||||||
|
CardKinds.group => '같은 모임',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// "채팅하다 보니 가족이네 / 동문이네 / 같은 팀이네" 를 한 번에 정한다.
|
||||||
|
///
|
||||||
|
/// 고른 그룹에 넣고, 연결 고리(가족·동문·직장 동료·같은 모임)를 남긴다. 가족은 가계도에 이어 촌수를 맞추고
|
||||||
|
/// 친가·외가·처가·시가로 나눠 넣는다. 촌수를 모르면 "친척(촌수 모름)" 에 모은다. 모두 이 기기 안에서만 한다.
|
||||||
|
class RelateService {
|
||||||
|
RelateService(this._groups, this._kin, this._network, this._bus);
|
||||||
|
|
||||||
|
static const placeholderParent = '부모(이름 모름)';
|
||||||
|
|
||||||
|
final GroupService _groups;
|
||||||
|
final KinService _kin;
|
||||||
|
final NetworkService _network;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
|
||||||
|
/// 이 사람을 [groupId] 그룹에 넣고 연결 고리를 남긴다. 그룹이 없거나 닫혀 있으면 null.
|
||||||
|
Future<PlaceResult?> into(String personId, String groupId) async {
|
||||||
|
final v = await _groups.view(groupId);
|
||||||
|
if (v == null || !v.open) return null;
|
||||||
|
await _groups.addMembers(groupId, [personId]);
|
||||||
|
final word = linkWordOf(v.group.kind);
|
||||||
|
if (word != null) await _relink(personId, word, v.path);
|
||||||
|
_bus.notify();
|
||||||
|
return PlaceResult(groupId, v.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가족으로 넣는다. [link] 가 없으면 촌수 모름으로 모은다. 가계도에 이을 수 없으면 null.
|
||||||
|
Future<PlaceResult?> asFamily(String personId, {KinLink? link, String to = KinGraph.meId, Gender gender = Gender.unknown}) async {
|
||||||
|
if (link == null) {
|
||||||
|
final id = await _familyGroup(FamilySide.unknown, unknownBucket: true);
|
||||||
|
if (id == null) return null;
|
||||||
|
await _groups.addMembers(id, [personId]);
|
||||||
|
await _relink(personId, '가족', '촌수 모름');
|
||||||
|
_bus.notify();
|
||||||
|
return PlaceResult(id, (await _groups.view(id))!.path);
|
||||||
|
}
|
||||||
|
await _kin.include(personId, gender: gender);
|
||||||
|
final ok = switch (link) {
|
||||||
|
KinLink.parent => await _kin.setParent(to, personId),
|
||||||
|
KinLink.child => await _kin.setParent(personId, to),
|
||||||
|
KinLink.spouse => await _kin.setSpouse(to, personId),
|
||||||
|
KinLink.sibling => await _sibling(to, personId),
|
||||||
|
};
|
||||||
|
if (!ok) return null;
|
||||||
|
final g = await _kin.graph();
|
||||||
|
final rel = relate(g, KinGraph.meId, personId);
|
||||||
|
final id = await _familyGroup(familySide(g, KinGraph.meId, personId));
|
||||||
|
if (id == null) return null;
|
||||||
|
await _groups.removeMember(Group.unknownRelatives, personId);
|
||||||
|
await _groups.addMembers(id, [personId]);
|
||||||
|
await _relink(personId, '가족', rel.related ? rel.title : '가족');
|
||||||
|
_bus.notify();
|
||||||
|
return PlaceResult(id, (await _groups.view(id))!.path, relation: rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 형제자매: [to] 의 부모를 함께 쓴다. 부모가 가계도에 없으면 이름 모를 부모를 하나 만든다.
|
||||||
|
Future<bool> _sibling(String to, String personId) async {
|
||||||
|
var parents = (await _kin.graph()).parentsOf(to);
|
||||||
|
if (parents.isEmpty) {
|
||||||
|
final p = await _kin.addRelative(placeholderParent);
|
||||||
|
if (p == null || !await _kin.setParent(to, p.id)) return false;
|
||||||
|
parents = {p.id};
|
||||||
|
}
|
||||||
|
for (final p in parents) {
|
||||||
|
if (!await _kin.setParent(personId, p)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 쪽에 맞는 가족 하위 그룹. 지워졌으면 가족 그룹, 그것도 없으면 가족 종류의 첫 그룹.
|
||||||
|
Future<String?> _familyGroup(FamilySide side, {bool unknownBucket = false}) async {
|
||||||
|
final want = unknownBucket
|
||||||
|
? Group.unknownRelatives
|
||||||
|
: switch (side) {
|
||||||
|
FamilySide.paternal => 'family.paternal',
|
||||||
|
FamilySide.maternal => 'family.maternal',
|
||||||
|
FamilySide.inlaw => 'family.inlaw',
|
||||||
|
_ => CardKinds.family,
|
||||||
|
};
|
||||||
|
for (final id in [want, CardKinds.family]) {
|
||||||
|
final v = await _groups.view(id);
|
||||||
|
if (v != null && v.open) return id;
|
||||||
|
}
|
||||||
|
for (final v in await _groups.all()) {
|
||||||
|
if (v.group.kind == CardKinds.family && v.open) return v.group.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 같은 이름의 연결 고리는 하나만 남긴다 (새 근거로 바꾼다).
|
||||||
|
Future<void> _relink(String personId, String word, String detail) async {
|
||||||
|
for (final l in await _network.links(personId)) {
|
||||||
|
if (l.relation == word) await _network.removeLink(l.id);
|
||||||
|
}
|
||||||
|
await _network.addLink(personId, word, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이 사람이 들어 있는 (지금 볼 수 있는) 그룹들의 경로 이름.
|
||||||
|
Future<List<String>> placesOf(String personId) async {
|
||||||
|
final out = <String>[];
|
||||||
|
for (final g in await _groups.groupsOf(personId)) {
|
||||||
|
final v = await _groups.view(g.id);
|
||||||
|
if (v != null) out.add(v.path);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 그룹에서 뺀다. 그 종류의 그룹에 더 남아 있지 않으면 연결 고리도 지운다.
|
||||||
|
Future<void> leave(String personId, String groupId) async {
|
||||||
|
final v = await _groups.view(groupId);
|
||||||
|
if (v == null || !v.open) return;
|
||||||
|
await _groups.removeMember(groupId, personId);
|
||||||
|
final still = (await _groups.groupsOf(personId)).any((g) => g.kind == v.group.kind);
|
||||||
|
final word = linkWordOf(v.group.kind);
|
||||||
|
if (!still && word != null) {
|
||||||
|
for (final l in await _network.links(personId)) {
|
||||||
|
if (l.relation == word) await _network.removeLink(l.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,3 +6,4 @@ library;
|
||||||
|
|
||||||
export 'src/graph.dart';
|
export 'src/graph.dart';
|
||||||
export 'src/relation.dart' show Relation, relate;
|
export 'src/relation.dart' show Relation, relate;
|
||||||
|
export 'src/side.dart';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import 'graph.dart';
|
||||||
|
import 'relation.dart';
|
||||||
|
|
||||||
|
/// 가족 모음을 나눌 때의 쪽.
|
||||||
|
enum FamilySide {
|
||||||
|
/// 나와 아주 가까운 가족 (부모·자녀·배우자·형제자매·조카·후손)
|
||||||
|
close,
|
||||||
|
|
||||||
|
/// 아버지 쪽 (친가)
|
||||||
|
paternal,
|
||||||
|
|
||||||
|
/// 어머니 쪽 (외가)
|
||||||
|
maternal,
|
||||||
|
|
||||||
|
/// 배우자 쪽 (처가·시가)
|
||||||
|
inlaw,
|
||||||
|
|
||||||
|
/// 이어졌지만 쪽을 모름 (부모 성별을 모를 때 등)
|
||||||
|
unknown,
|
||||||
|
|
||||||
|
/// 가계도에서 이어지지 않음
|
||||||
|
none,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [a] 가 본 [b] 의 쪽. 조부모 위로 올라가는 혈족은 처음 올라간 부모의 성별로 친가·외가를 가른다.
|
||||||
|
/// 배우자의 혈족은 처가·시가, 내 혈족의 배우자는 그 혈족의 쪽을 따른다.
|
||||||
|
FamilySide familySide(KinGraph g, String a, String b) {
|
||||||
|
if (!g.has(a) || !g.has(b) || a == b) return FamilySide.none;
|
||||||
|
final blood = bloodPath(g, a, b);
|
||||||
|
if (blood != null) return _bloodSide(g, blood.up, blood.aVia);
|
||||||
|
if (g.spousesOf(a).contains(b)) return FamilySide.close;
|
||||||
|
for (final s in g.spousesOf(a)) {
|
||||||
|
if (bloodPath(g, s, b) != null) return FamilySide.inlaw;
|
||||||
|
}
|
||||||
|
for (final s in g.spousesOf(b)) {
|
||||||
|
final p = bloodPath(g, a, s);
|
||||||
|
if (p != null) return _bloodSide(g, p.up, p.aVia);
|
||||||
|
}
|
||||||
|
return FamilySide.none;
|
||||||
|
}
|
||||||
|
|
||||||
|
FamilySide _bloodSide(KinGraph g, int up, String? via) {
|
||||||
|
if (up <= 1) return FamilySide.close;
|
||||||
|
return switch (via == null ? Gender.unknown : g.person(via)?.gender ?? Gender.unknown) {
|
||||||
|
Gender.male => FamilySide.paternal,
|
||||||
|
Gender.female => FamilySide.maternal,
|
||||||
|
Gender.unknown => FamilySide.unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
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 'package:friendly_map/modules/core/core.dart';
|
||||||
|
|
@ -7,6 +9,7 @@ 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/relate_sheet.dart';
|
||||||
import 'archive_screen.dart';
|
import 'archive_screen.dart';
|
||||||
|
|
||||||
class _RoomData {
|
class _RoomData {
|
||||||
|
|
@ -66,6 +69,17 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||||
KindChip(room.contextKind, CardKinds.label(room.contextKind)),
|
KindChip(room.contextKind, CardKinds.label(room.contextKind)),
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
|
actions: [
|
||||||
|
if (room != null && room.kind == 'direct')
|
||||||
|
IconButton(
|
||||||
|
tooltip: '관계 정하기',
|
||||||
|
icon: const Icon(Icons.hub_outlined),
|
||||||
|
onPressed: () {
|
||||||
|
final ids = (jsonDecode(room.membersJson) as List).whereType<String>();
|
||||||
|
if (ids.isNotEmpty) showRelateSheet(context, ids.first, room.title);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: Column(children: [
|
body: Column(children: [
|
||||||
if (d.advice.recommended)
|
if (d.advice.recommended)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import '../widgets/async_view.dart';
|
||||||
import '../widgets/common.dart';
|
import '../widgets/common.dart';
|
||||||
import '../widgets/labels.dart';
|
import '../widgets/labels.dart';
|
||||||
import '../widgets/person_summary.dart';
|
import '../widgets/person_summary.dart';
|
||||||
|
import '../widgets/relate_sheet.dart';
|
||||||
import 'chat_room_screen.dart';
|
import 'chat_room_screen.dart';
|
||||||
import 'person_facts_screen.dart';
|
import 'person_facts_screen.dart';
|
||||||
import 'infer_screen.dart';
|
import 'infer_screen.dart';
|
||||||
|
|
@ -62,6 +63,7 @@ class PersonScreen extends StatelessWidget {
|
||||||
if (p == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('찾을 수 없는 사람이에요.'));
|
if (p == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('찾을 수 없는 사람이에요.'));
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(p.name), actions: [
|
appBar: AppBar(title: Text(p.name), actions: [
|
||||||
|
IconButton(tooltip: '관계 정하기', onPressed: () => showRelateSheet(context, p.id, p.name), icon: const Icon(Icons.hub_outlined)),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: '정보 고치기',
|
tooltip: '정보 고치기',
|
||||||
onPressed: () => pushPage(context, PersonFactsScreen(personId: p.id)),
|
onPressed: () => pushPage(context, PersonFactsScreen(personId: p.id)),
|
||||||
|
|
@ -127,6 +129,7 @@ class PersonScreen extends StatelessWidget {
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
PlacesPanel(personId: p.id, name: p.name),
|
||||||
RelationPanel(d.info.relations, onEdit: () => pushPage(context, PersonFactsScreen(personId: p.id))),
|
RelationPanel(d.info.relations, onEdit: () => pushPage(context, PersonFactsScreen(personId: p.id))),
|
||||||
CallPanel(d.info),
|
CallPanel(d.info),
|
||||||
EventsPanel(d.info.events),
|
EventsPanel(d.info.events),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:friendly_map/modules/app/app.dart';
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
import 'package:friendly_map/modules/kinship/kinship.dart';
|
||||||
|
|
||||||
|
import '../app_scope.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
import 'async_view.dart';
|
||||||
|
import 'common.dart';
|
||||||
|
import 'group_dialogs.dart';
|
||||||
|
import 'pin_dialog.dart';
|
||||||
|
|
||||||
|
/// "채팅하다 보니 가족이네 / 동문이네 / 같은 팀이네": 이 사람의 관계를 정해 그룹에 넣는다.
|
||||||
|
Future<void> showRelateSheet(BuildContext context, String personId, String name) async {
|
||||||
|
final kind = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (c) => SafeArea(
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||||
|
child: Align(alignment: Alignment.centerLeft, child: Text('$name님은 나와…', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700))),
|
||||||
|
),
|
||||||
|
for (final (k, icon, text) in const [
|
||||||
|
(CardKinds.family, Icons.family_restroom, '가족 · 친척'),
|
||||||
|
(CardKinds.school, Icons.school_outlined, '학교 동문 (초·중·고·대 …)'),
|
||||||
|
(CardKinds.company, Icons.business_center_outlined, '회사 · 업무 · 팀'),
|
||||||
|
(CardKinds.group, Icons.groups_outlined, '그룹 · 모임 · 여행'),
|
||||||
|
])
|
||||||
|
ListTile(minTileHeight: 56, leading: Icon(icon, color: Pal.ofKind(k)), title: Text(text), onTap: () => Navigator.pop(c, k)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (kind == null || !context.mounted) return;
|
||||||
|
if (kind == CardKinds.family) {
|
||||||
|
await showModalBottomSheet<void>(context: context, showDragHandle: true, isScrollControlled: true, builder: (_) => _FamilySheet(personId, name));
|
||||||
|
} else {
|
||||||
|
await showModalBottomSheet<void>(context: context, showDragHandle: true, isScrollControlled: true, builder: (_) => _GroupPickSheet(personId, name, kind));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가족: 누구의 무엇인지 고르면 가계도에 잇고 촌수를 맞춰 친가·외가·처가로 넣는다.
|
||||||
|
class _FamilySheet extends StatefulWidget {
|
||||||
|
const _FamilySheet(this.personId, this.name);
|
||||||
|
final String personId;
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_FamilySheet> createState() => _FamilySheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FamilySheetState extends State<_FamilySheet> {
|
||||||
|
Gender _gender = Gender.unknown;
|
||||||
|
String _to = KinGraph.meId;
|
||||||
|
String _toName = '나';
|
||||||
|
List<KinEntry>? _family;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_family == null) {
|
||||||
|
AppScope.of(context).app.kin.everyone().then((f) {
|
||||||
|
if (mounted) setState(() => _family = [for (final e in f) if (e.person.id != widget.personId) e]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _base(String id, String name) => setState(() {
|
||||||
|
_to = id;
|
||||||
|
_toName = name;
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> _place(KinLink? link) async {
|
||||||
|
final app = AppScope.of(context).app;
|
||||||
|
final r = await app.relate.asFamily(widget.personId, link: link, to: _to, gender: _gender);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context);
|
||||||
|
if (r == null) {
|
||||||
|
showNote(context, '가계도에 이을 수 없어요 (부모가 이미 둘이거나 돌고 도는 관계예요)');
|
||||||
|
} else {
|
||||||
|
final rel = r.relation;
|
||||||
|
showNote(context, '${r.groupPath}에 넣었어요${rel != null && rel.related ? ' · ${rel.label}' : ''}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final family = _family ?? const <KinEntry>[];
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text('${widget.name}님은 $_toName의…', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text('누구를 기준으로', style: TextStyle(fontSize: 12, color: Pal.muted)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: ListView(scrollDirection: Axis.horizontal, children: [
|
||||||
|
_choice('나', _to == KinGraph.meId, () => _base(KinGraph.meId, '나')),
|
||||||
|
for (final e in family)
|
||||||
|
_choice('${e.person.name} (${e.relation.title.isEmpty ? '가족' : e.relation.title})', _to == e.person.id,
|
||||||
|
() => _base(e.person.id, e.person.name)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text('성별 (호칭에 써요)', style: TextStyle(fontSize: 12, color: Pal.muted)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(spacing: 6, children: [
|
||||||
|
for (final (g, t) in const [(Gender.male, '남'), (Gender.female, '여'), (Gender.unknown, '모름')])
|
||||||
|
_choice(t, _gender == g, () => setState(() => _gender = g)),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(spacing: 8, runSpacing: 8, children: [
|
||||||
|
for (final (l, t) in const [(KinLink.parent, '부모'), (KinLink.child, '자녀'), (KinLink.spouse, '배우자'), (KinLink.sibling, '형제자매')])
|
||||||
|
FilledButton.tonal(onPressed: () => _place(l), child: Text(t)),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(onPressed: () => _place(null), child: const Text('촌수 모름 — 친척 모음에 두기')),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _choice(String text, bool on, VoidCallback tap) => Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 6),
|
||||||
|
child: ChoiceChip(label: Text(text), selected: on, onSelected: (_) => tap()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 학교·회사·모임: 그 종류의 그룹 나무에서 골라 넣는다. 그 자리에서 하위 그룹을 만들 수도 있다.
|
||||||
|
class _GroupPickSheet extends StatelessWidget {
|
||||||
|
const _GroupPickSheet(this.personId, this.name, this.kind);
|
||||||
|
final String personId;
|
||||||
|
final String name;
|
||||||
|
final String kind;
|
||||||
|
|
||||||
|
Future<void> _put(BuildContext context, GroupView v) async {
|
||||||
|
final app = AppScope.of(context).app;
|
||||||
|
if (!await openGroup(context, v) || !context.mounted) return;
|
||||||
|
final r = await app.relate.into(personId, v.group.id);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.pop(context);
|
||||||
|
showNote(context, r == null ? '넣지 못했어요' : '${r.groupPath}에 넣었어요');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _newUnder(BuildContext context, GroupView v) async {
|
||||||
|
final app = AppScope.of(context).app;
|
||||||
|
if (!await openGroup(context, v) || !context.mounted) return;
|
||||||
|
final hint = switch (kind) {
|
||||||
|
CardKinds.school => '예: 가람고 27회',
|
||||||
|
CardKinds.company => '예: 한빛전자, 결제 프로젝트',
|
||||||
|
_ => '예: 등산 모임, 제주 여행',
|
||||||
|
};
|
||||||
|
final r = await askGroupInfo(context, title: '${v.group.name} 아래 새 그룹 ($hint)', kind: v.group.kind);
|
||||||
|
if (r == null || !context.mounted) return;
|
||||||
|
final g = await app.groups.add(r.$1, r.$2, parent: v.group.id);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (g == null) {
|
||||||
|
showNote(context, '이름이 비었거나 같은 이름이 있어요 (4단까지 만들 수 있어요)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final placed = await app.relate.into(personId, g.id);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.pop(context);
|
||||||
|
showNote(context, placed == null ? '넣지 못했어요' : '${placed.groupPath}에 넣었어요');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.6,
|
||||||
|
child: Column(children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
|
||||||
|
child: Align(alignment: Alignment.centerLeft, child: Text('$name님을 어디에 넣을까요?', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700))),
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||||
|
child: Align(alignment: Alignment.centerLeft, child: Text('눌러서 넣고, + 로 그 아래 새 그룹을 만들어 바로 넣어요.', style: TextStyle(fontSize: 12, color: Pal.muted))),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: AsyncView<List<GroupView>>(
|
||||||
|
load: (a) async => [for (final v in await a.groups.all()) if (v.group.kind == kind) v],
|
||||||
|
builder: (context, list) => list.isEmpty
|
||||||
|
? const EmptyNote('이 종류의 그룹이 없어요. 설정 > 그룹 관리에서 만들어 주세요.')
|
||||||
|
: ListView(children: [
|
||||||
|
for (final v in list)
|
||||||
|
ListTile(
|
||||||
|
minTileHeight: 52,
|
||||||
|
contentPadding: EdgeInsets.only(left: 12.0 + (v.depth - 1) * 20, right: 4),
|
||||||
|
leading: Icon(v.group.locked ? (v.open ? Icons.lock_open : Icons.lock) : Icons.folder_outlined, color: Pal.ofKind(v.group.kind)),
|
||||||
|
title: Text(v.group.name),
|
||||||
|
subtitle: v.open ? Text('${v.memberCount}명', style: const TextStyle(fontSize: 11, color: Pal.muted)) : null,
|
||||||
|
onTap: () => _put(context, v),
|
||||||
|
trailing: v.canAddChild
|
||||||
|
? IconButton(tooltip: '아래에 새 그룹', icon: const Icon(Icons.add), onPressed: () => _newUnder(context, v))
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 사람 화면: 이 사람이 들어 있는 그룹들과 "관계 정하기" 버튼.
|
||||||
|
class PlacesPanel extends StatelessWidget {
|
||||||
|
const PlacesPanel({super.key, required this.personId, required this.name});
|
||||||
|
final String personId;
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AsyncView<List<String>>(
|
||||||
|
load: (a) => a.relate.placesOf(personId),
|
||||||
|
builder: (context, places) => Panel(
|
||||||
|
onTap: () => showRelateSheet(context, personId, name),
|
||||||
|
child: Row(children: [
|
||||||
|
const Icon(Icons.hub_outlined, color: Pal.accent),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: places.isEmpty
|
||||||
|
? const Text('관계 정하기 — 가족·동문·회사·모임으로 묶어요')
|
||||||
|
: Wrap(spacing: 6, runSpacing: 6, children: [
|
||||||
|
for (final p in places)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(color: Pal.line, borderRadius: BorderRadius.circular(10)),
|
||||||
|
child: Text(p, style: const TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
const Icon(Icons.chevron_right, color: Pal.muted),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:friendly_map/modules/kinship/kinship.dart';
|
||||||
|
|
||||||
|
const M = Gender.male, F = Gender.female;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
KinGraph tree() {
|
||||||
|
final g = KinGraph();
|
||||||
|
for (final (id, gen) in [('me', M), ('dad', M), ('mom', F), ('gf', M), ('mgf', M), ('uncle', M), ('cousin', F), ('sis', F), ('wife', F), ('wifeDad', M), ('auntInLaw', F), ('son', M), ('x', M), ('p', Gender.unknown), ('pp', M)]) {
|
||||||
|
g.put(KinPerson(id, gender: gen));
|
||||||
|
}
|
||||||
|
g.addParent('me', 'dad');
|
||||||
|
g.addParent('me', 'mom');
|
||||||
|
g.addParent('sis', 'dad');
|
||||||
|
g.addParent('dad', 'gf');
|
||||||
|
g.addParent('mom', 'mgf');
|
||||||
|
g.addParent('uncle', 'gf');
|
||||||
|
g.addParent('cousin', 'uncle');
|
||||||
|
g.addSpouse('uncle', 'auntInLaw');
|
||||||
|
g.addSpouse('me', 'wife');
|
||||||
|
g.addParent('wife', 'wifeDad');
|
||||||
|
g.addParent('son', 'me');
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('가까운 가족: 부모·형제·배우자·자녀', () {
|
||||||
|
final g = tree();
|
||||||
|
for (final id in ['dad', 'mom', 'sis', 'wife', 'son']) {
|
||||||
|
expect(familySide(g, 'me', id), FamilySide.close, reason: id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('아버지 쪽은 친가, 어머니 쪽은 외가', () {
|
||||||
|
final g = tree();
|
||||||
|
expect(familySide(g, 'me', 'gf'), FamilySide.paternal);
|
||||||
|
expect(familySide(g, 'me', 'uncle'), FamilySide.paternal);
|
||||||
|
expect(familySide(g, 'me', 'cousin'), FamilySide.paternal);
|
||||||
|
expect(familySide(g, 'me', 'mgf'), FamilySide.maternal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('배우자 쪽은 처가·시가, 내 혈족의 배우자는 그 혈족의 쪽', () {
|
||||||
|
final g = tree();
|
||||||
|
expect(familySide(g, 'me', 'wifeDad'), FamilySide.inlaw);
|
||||||
|
expect(familySide(g, 'me', 'auntInLaw'), FamilySide.paternal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('부모 성별을 모르면 쪽을 모름, 이어지지 않으면 none', () {
|
||||||
|
final g = tree();
|
||||||
|
g.addParent('x', 'p');
|
||||||
|
g.addParent('p', 'pp');
|
||||||
|
expect(familySide(g, 'x', 'pp'), FamilySide.unknown);
|
||||||
|
expect(familySide(g, 'me', 'x'), FamilySide.none);
|
||||||
|
expect(familySide(g, 'me', 'me'), FamilySide.none);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:friendly_map/modules/app/app.dart';
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
import 'package:friendly_map/modules/groups/groups.dart';
|
||||||
|
import 'package:friendly_map/modules/kinship/kinship.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
import 'package:friendly_map/modules/transport/transport.dart';
|
||||||
|
import 'package:friendly_map/modules/visibility/visibility.dart';
|
||||||
|
|
||||||
|
import 'support/app_device.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDevice me;
|
||||||
|
late RelateService relate;
|
||||||
|
var n = 0;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
me = await AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '나');
|
||||||
|
relate = me.app.relate;
|
||||||
|
await me.app.kin.setMe(gender: Gender.male, birthYear: 1990);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<Person> person(String name) async => (await me.app.friends.add('+8210${(5000 + n++).toString().padLeft(4, '0')}0000', name))!;
|
||||||
|
Future<List<String>> membersOf(String groupId) async => [for (final p in (await me.app.groups.members(groupId))!) p.name];
|
||||||
|
Future<List<Link>> linksOf(Person p) => me.app.network.links(p.id);
|
||||||
|
|
||||||
|
group('가족', () {
|
||||||
|
test('아버지는 가족 그룹, 아버지의 아버지는 친가, 촌수와 호칭이 맞는다', () async {
|
||||||
|
final dad = await person('아빠');
|
||||||
|
final r = (await relate.asFamily(dad.id, link: KinLink.parent, gender: Gender.male))!;
|
||||||
|
expect(r.groupId, CardKinds.family);
|
||||||
|
expect(r.relation!.title, '아버지');
|
||||||
|
expect(r.relation!.chon, 1);
|
||||||
|
final gp = await person('할배');
|
||||||
|
final r2 = (await relate.asFamily(gp.id, link: KinLink.parent, to: dad.id, gender: Gender.male))!;
|
||||||
|
expect(r2.groupId, 'family.paternal');
|
||||||
|
expect(r2.groupPath, '가족 › 친가');
|
||||||
|
expect(r2.relation!.title, contains('할아버지'));
|
||||||
|
expect(r2.relation!.chon, 2);
|
||||||
|
expect(await membersOf('family.paternal'), ['할배']);
|
||||||
|
final links = await linksOf(dad);
|
||||||
|
expect(links.map((l) => '${l.relation}:${l.detail}'), ['가족:아버지']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('어머니의 형제(부모를 몰라도)는 외가 외삼촌 3촌, 이름 모를 부모가 생긴다', () async {
|
||||||
|
final mom = await person('엄마');
|
||||||
|
await relate.asFamily(mom.id, link: KinLink.parent, gender: Gender.female);
|
||||||
|
final uncle = await person('외삼촌');
|
||||||
|
final r = (await relate.asFamily(uncle.id, link: KinLink.sibling, to: mom.id, gender: Gender.male))!;
|
||||||
|
expect(r.groupId, 'family.maternal');
|
||||||
|
expect(r.relation!.title, '외삼촌');
|
||||||
|
expect(r.relation!.chon, 3);
|
||||||
|
final g = await me.app.kin.graph();
|
||||||
|
final shared = g.parentsOf(mom.id);
|
||||||
|
expect(shared, g.parentsOf(uncle.id));
|
||||||
|
expect((await me.app.friends.person(shared.single))!.name, RelateService.placeholderParent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('아내는 가족, 아내의 아버지는 처가·시가 장인', () async {
|
||||||
|
final wife = await person('아내');
|
||||||
|
expect((await relate.asFamily(wife.id, link: KinLink.spouse, gender: Gender.female))!.groupId, CardKinds.family);
|
||||||
|
final fil = await person('장인어른');
|
||||||
|
final r = (await relate.asFamily(fil.id, link: KinLink.parent, to: wife.id, gender: Gender.male))!;
|
||||||
|
expect(r.groupId, 'family.inlaw');
|
||||||
|
expect(r.relation!.title, '장인');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('촌수를 모르면 친척 모음에, 나중에 이으면 모음에서 빠진다', () async {
|
||||||
|
final x = await person('먼 친척');
|
||||||
|
final r = (await relate.asFamily(x.id))!;
|
||||||
|
expect(r.groupId, Group.unknownRelatives);
|
||||||
|
expect(await membersOf(Group.unknownRelatives), ['먼 친척']);
|
||||||
|
expect((await linksOf(x)).single.detail, '촌수 모름');
|
||||||
|
await relate.asFamily(x.id, link: KinLink.child, gender: Gender.female);
|
||||||
|
expect(await membersOf(Group.unknownRelatives), isEmpty);
|
||||||
|
expect((await linksOf(x)).single.detail, '딸');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('가계도에 이을 수 없으면 null (부모가 이미 둘)', () async {
|
||||||
|
await relate.asFamily((await person('부1')).id, link: KinLink.parent, gender: Gender.male);
|
||||||
|
await relate.asFamily((await person('부2')).id, link: KinLink.parent, gender: Gender.female);
|
||||||
|
expect(await relate.asFamily((await person('부3')).id, link: KinLink.parent), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('쪽을 모르면(부모 성별 모름) 가족 그룹에 둔다', () async {
|
||||||
|
final p = await person('부모');
|
||||||
|
await relate.asFamily(p.id, link: KinLink.parent);
|
||||||
|
final gp = await person('조부모');
|
||||||
|
expect((await relate.asFamily(gp.id, link: KinLink.parent, to: p.id))!.groupId, CardKinds.family);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('학교 · 회사 · 모임', () {
|
||||||
|
test('고등학교에 넣으면 동문 연결 고리가 생기고, 공개 범위가 동문으로 적용된다', () async {
|
||||||
|
final a = await person('이영희');
|
||||||
|
final r = (await relate.into(a.id, 'school.high'))!;
|
||||||
|
expect(r.groupPath, '학교 › 고등학교');
|
||||||
|
expect((await linksOf(a)).map((l) => '${l.relation}:${l.detail}'), ['동문:학교 › 고등학교']);
|
||||||
|
expect(VAudience.ofRelation((await linksOf(a)).single.relation), VAudience.school);
|
||||||
|
expect(await relate.placesOf(a.id), ['학교 › 고등학교']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('회사 › 한빛전자 › 결제팀에 넣으면 직장 동료, 빼면 연결 고리도 지운다', () async {
|
||||||
|
final hb = (await me.app.groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!;
|
||||||
|
final team = (await me.app.groups.add('결제팀', CardKinds.company, parent: hb.id))!;
|
||||||
|
final b = await person('박철수');
|
||||||
|
await relate.into(b.id, team.id);
|
||||||
|
await relate.into(b.id, hb.id);
|
||||||
|
expect((await linksOf(b)).single.detail, '회사 › 한빛전자', reason: '같은 종류는 연결 고리 하나만, 마지막 근거로');
|
||||||
|
await relate.leave(b.id, team.id);
|
||||||
|
expect(await linksOf(b), isNotEmpty, reason: '한빛전자에 아직 있다');
|
||||||
|
await relate.leave(b.id, hb.id);
|
||||||
|
expect(await linksOf(b), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('닫힌 잠긴 그룹에는 넣을 수 없다', () async {
|
||||||
|
await me.app.groups.setPin(CardKinds.group, '1234');
|
||||||
|
me.app.groups.lockAll();
|
||||||
|
expect(await relate.into((await person('누구')).id, CardKinds.group), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:friendly_map/modules/app/app.dart';
|
import 'package:friendly_map/modules/app/app.dart';
|
||||||
import 'package:friendly_map/modules/transport/transport.dart';
|
import 'package:friendly_map/modules/transport/transport.dart';
|
||||||
|
import 'package:friendly_map/modules/ui/src/screens/person_screen.dart';
|
||||||
import 'package:friendly_map/modules/ui/ui.dart';
|
import 'package:friendly_map/modules/ui/ui.dart';
|
||||||
|
|
||||||
import 'support/app_device.dart';
|
import 'support/app_device.dart';
|
||||||
|
|
@ -74,4 +75,23 @@ void main() {
|
||||||
expect(find.text('고등학교'), findsOneWidget);
|
expect(find.text('고등학교'), findsOneWidget);
|
||||||
expect(find.text('대학원'), findsOneWidget);
|
expect(find.text('대학원'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('사람 화면의 관계 정하기로 학교 › 고등학교에 넣으면 들어간 그룹이 보인다', (tester) async {
|
||||||
|
final device = await tester.runAsync(() => AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '이미영', start: false));
|
||||||
|
final app = device!.app;
|
||||||
|
final friend = await tester.runAsync(() => app.friends.add('+821033334444', '김민수'));
|
||||||
|
await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap(keys: true, app: app)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
tester.state<NavigatorState>(find.byType(Navigator).first).push(MaterialPageRoute<void>(builder: (_) => PersonScreen(personId: friend!.id)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('관계 정하기 —'), findsOneWidget);
|
||||||
|
await tester.tap(find.byTooltip('관계 정하기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.textContaining('학교 동문'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('고등학교'));
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('학교 › 고등학교'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ cd "$(dirname "$0")/.."
|
||||||
declare -A T=(
|
declare -A T=(
|
||||||
[keys]="keys_test.dart keys_crypto_test.dart shamir_test.dart recovery_shares_test.dart"
|
[keys]="keys_test.dart keys_crypto_test.dart shamir_test.dart recovery_shares_test.dart"
|
||||||
[visibility]="visibility_test.dart"
|
[visibility]="visibility_test.dart"
|
||||||
[kinship]="kinship_test.dart"
|
[kinship]="kinship_test.dart kinship_side_test.dart"
|
||||||
[groups]="groups_test.dart"
|
[groups]="groups_test.dart"
|
||||||
[events]="events_test.dart lunar_test.dart"
|
[events]="events_test.dart lunar_test.dart"
|
||||||
[storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart"
|
[storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart"
|
||||||
|
|
@ -17,7 +17,7 @@ declare -A T=(
|
||||||
[inference]="inference_test.dart"
|
[inference]="inference_test.dart"
|
||||||
[layout]="layout_test.dart"
|
[layout]="layout_test.dart"
|
||||||
[sharing]="sharing_service_test.dart sharing_status_test.dart"
|
[sharing]="sharing_service_test.dart sharing_status_test.dart"
|
||||||
[app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart group_service_test.dart contact_import_test.dart"
|
[app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart group_service_test.dart contact_import_test.dart relate_service_test.dart"
|
||||||
[platform_android]="df_parser_test.dart"
|
[platform_android]="df_parser_test.dart"
|
||||||
[ui]="widget_test.dart"
|
[ui]="widget_test.dart"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue