From 98fb0f2d0de6b9d3fda455ef1ad31141973dd0e7 Mon Sep 17 00:00:00 2001 From: diadmin Date: Mon, 26 Oct 2026 14:37:07 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B1=84=ED=8C=85=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=EC=A0=95=ED=95=98=EA=B8=B0:=20=EA=B0=80?= =?UTF-8?q?=EC=A1=B1=20=EC=B4=8C=EC=88=98=20=EB=A7=9E=EC=B6=94=EA=B8=B0?= =?UTF-8?q?=C2=B7=EC=B9=9C=EA=B0=80/=EC=99=B8=EA=B0=80/=EC=B2=98=EA=B0=80?= =?UTF-8?q?=20=EB=82=98=EB=88=84=EA=B8=B0,=20=ED=95=99=EA=B5=90=C2=B7?= =?UTF-8?q?=ED=9A=8C=EC=82=AC=C2=B7=EB=AA=A8=EC=9E=84=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=EC=97=90=20=EB=84=A3=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kinship: familySide (새 파일, 기존 계산은 그대로) — 친가·외가·처가·가까운 가족 판별 - app: RelateService — 그룹에 넣으며 연결 고리(가족·동문·직장 동료·같은 모임) 남김, 가족은 가계도 잇기(형제자매는 이름 모를 부모 자동), 촌수 모름 모음 - ui: 대화방·사람 화면의 "관계 정하기" 시트, 사람 화면에 들어간 그룹 표시, 그 자리에서 하위 그룹 만들어 넣기 - 시험 14개 추가 (전체 288개) Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- README.md | 7 + ...otlin-compiler-14262624802283613528.salive | 0 lib/modules/app/app.dart | 1 + lib/modules/app/src/friendly_app.dart | 3 + lib/modules/app/src/relate_service.dart | 150 +++++++++++ lib/modules/kinship/kinship.dart | 1 + lib/modules/kinship/src/side.dart | 49 ++++ .../ui/src/screens/chat_room_screen.dart | 14 + lib/modules/ui/src/screens/person_screen.dart | 3 + lib/modules/ui/src/widgets/relate_sheet.dart | 245 ++++++++++++++++++ test/kinship_side_test.dart | 55 ++++ test/relate_service_test.dart | 123 +++++++++ test/widget_test.dart | 20 ++ tool/test_module.sh | 4 +- 15 files changed, 675 insertions(+), 4 deletions(-) create mode 100644 android/.kotlin/sessions/kotlin-compiler-14262624802283613528.salive create mode 100644 lib/modules/app/src/relate_service.dart create mode 100644 lib/modules/kinship/src/side.dart create mode 100644 lib/modules/ui/src/widgets/relate_sheet.dart create mode 100644 test/kinship_side_test.dart create mode 100644 test/relate_service_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 5f19cce..ce1a258 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,8 +31,8 @@ Android 다크모드 인맥 앱(Flutter/Dart). 옛 "Family MAP" + 카카오톡 - 화면은 `AppScope.of(context).app`(FriendlyApp)의 서비스만 쓴다. ## 상태 (2026-10-26 기준) -- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 274개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과. -- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 관계망 로드맵 1단계(그룹 계층: 학교 › 초등~대학원, 가족 › 친가·외가·처가·시가·촌수 모름, 회사 › 회사 › 팀) 끝. 다음은 2단계(채팅에서 그룹 넣기·촌수 맞추기). +- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 288개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과. +- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 관계망 로드맵 1단계(그룹 계층: 학교 › 초등~대학원, 가족 › 친가·외가·처가·시가·촌수 모름, 회사 › 회사 › 팀) 끝. 2단계(채팅·사람 화면의 "관계 정하기": RelateService, 촌수 맞춰 친가·외가·처가, 촌수 모름 모음, 연결 고리) 끝. 다음은 3단계(팀 그룹↔그룹 대화방, 맡은 업무 정리, 긴급 호출), 4단계(이직 알림). - `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 만 나온다. - 확인 안 된 곳: 실제 기기에서 화면 실행과 플러그인 동작(geolocator, call_log, url_launcher, flutter_local_notifications, timezone, sqflite_sqlcipher, nsd, mobile_scanner, sensors_plus, flutter_secure_storage). diff --git a/README.md b/README.md index e1a6e5a..599962d 100644 --- a/README.md +++ b/README.md @@ -110,3 +110,10 @@ flutter run - "주소록에서 가져오기": 이름·초성(ㄱㅁㅅ)·번호로 찾고, 휴대폰 주소록 그룹(라벨)으로 거르고, 이미 있는 사람은 빼고 볼 수 있다. 한 명씩 또는 보이는 사람을 모두 고른 뒤, 넣을 그룹(여러 개)을 골라 가져온다. - 번호가 여럿이면 휴대폰(010) 번호를 먼저 쓴다. 가져온 사람은 이 기기 인맥에만 들어가고, "친구 요청도 보내기"를 켰을 때만 상대에게 요청이 간다. 주소록은 읽기만 한다. - 그룹은 이 기기에만 저장한다. 안드로이드: `flutter_contacts` 플러그인과 READ_CONTACTS 권한이 필요하다(`tool/android_patch.py` 가 넣는다). + +## 관계 정하기 (채팅에서 시작하는 관계망) +- 대화방 위의 관계 아이콘, 또는 사람 화면의 "관계 정하기"에서 고른다: 가족·친척 / 학교 동문 / 회사·업무·팀 / 그룹·모임·여행. +- 가족: 나(또는 가계도에 있는 다른 가족)의 부모·자녀·배우자·형제자매를 고르면 가계도에 잇고 촌수·호칭을 계산한다. 아버지 쪽은 친가, 어머니 쪽은 외가, 배우자 쪽은 처가·시가 그룹에 들어간다. 모르면 "친척(촌수 모름)"에 모아 두고, 나중에 이으면 모음에서 빠진다. +- 형제자매를 고를 때 기준 사람의 부모가 가계도에 없으면 "부모(이름 모름)"를 이 기기 안에서만 쓰는 사람으로 만들어 잇는다. +- 학교·회사·모임: 그 종류의 그룹 나무에서 골라 넣거나, + 로 그 아래 새 그룹(예: 학교 › 고등학교 › 가람고 27회, 회사 › 한빛전자 › 결제팀)을 만들어 바로 넣는다. +- 넣으면 연결 고리(가족·동문·직장 동료·같은 모임)가 하나 남아, 공개 범위 표가 그 관계로 적용된다. 같은 종류의 그룹에서 모두 빠지면 연결 고리도 지운다. diff --git a/android/.kotlin/sessions/kotlin-compiler-14262624802283613528.salive b/android/.kotlin/sessions/kotlin-compiler-14262624802283613528.salive new file mode 100644 index 0000000..e69de29 diff --git a/lib/modules/app/app.dart b/lib/modules/app/app.dart index dbac7af..1679cf6 100644 --- a/lib/modules/app/app.dart +++ b/lib/modules/app/app.dart @@ -23,6 +23,7 @@ export 'src/location_service.dart'; export 'src/network_service.dart'; export 'src/profile_service.dart'; export 'src/recovery_service.dart'; +export 'src/relate_service.dart'; export 'src/settings_service.dart'; export 'src/verify_service.dart'; export 'src/visibility_service.dart'; diff --git a/lib/modules/app/src/friendly_app.dart b/lib/modules/app/src/friendly_app.dart index ad015b8..ffafc17 100644 --- a/lib/modules/app/src/friendly_app.dart +++ b/lib/modules/app/src/friendly_app.dart @@ -19,6 +19,7 @@ import 'location_service.dart'; import 'network_service.dart'; import 'profile_service.dart'; import 'recovery_service.dart'; +import 'relate_service.dart'; import 'settings_service.dart'; import 'verify_service.dart'; import 'visibility_service.dart'; @@ -58,6 +59,7 @@ class FriendlyApp { calls = CallService(storage, deps.callLog, deps.phone); groups = GroupService(storage, clock, bus); contacts = ContactImportService(storage, clock, friends, groups, bus, deps.contacts); + relate = RelateService(groups, kin, network, bus); } final AppDeps deps; @@ -80,6 +82,7 @@ class FriendlyApp { late final CallService calls; late final GroupService groups; late final ContactImportService contacts; + late final RelateService relate; StreamSubscription? _events; final Set> _tasks = {}; diff --git a/lib/modules/app/src/relate_service.dart b/lib/modules/app/src/relate_service.dart new file mode 100644 index 0000000..c78b19e --- /dev/null +++ b/lib/modules/app/src/relate_service.dart @@ -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 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 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 _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 _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 _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> placesOf(String personId) async { + final out = []; + 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 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(); + } +} diff --git a/lib/modules/kinship/kinship.dart b/lib/modules/kinship/kinship.dart index 0c21d60..38cd9fb 100644 --- a/lib/modules/kinship/kinship.dart +++ b/lib/modules/kinship/kinship.dart @@ -6,3 +6,4 @@ library; export 'src/graph.dart'; export 'src/relation.dart' show Relation, relate; +export 'src/side.dart'; diff --git a/lib/modules/kinship/src/side.dart b/lib/modules/kinship/src/side.dart new file mode 100644 index 0000000..b0d82eb --- /dev/null +++ b/lib/modules/kinship/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, + }; +} diff --git a/lib/modules/ui/src/screens/chat_room_screen.dart b/lib/modules/ui/src/screens/chat_room_screen.dart index fe7a38c..366f377 100644 --- a/lib/modules/ui/src/screens/chat_room_screen.dart +++ b/lib/modules/ui/src/screens/chat_room_screen.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:friendly_map/modules/app/app.dart'; import 'package:friendly_map/modules/core/core.dart'; @@ -7,6 +9,7 @@ import '../app_scope.dart'; import '../theme.dart'; import '../widgets/async_view.dart'; import '../widgets/common.dart'; +import '../widgets/relate_sheet.dart'; import 'archive_screen.dart'; class _RoomData { @@ -66,6 +69,17 @@ class _ChatRoomScreenState extends State { 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(); + if (ids.isNotEmpty) showRelateSheet(context, ids.first, room.title); + }, + ), + ], ), body: Column(children: [ if (d.advice.recommended) diff --git a/lib/modules/ui/src/screens/person_screen.dart b/lib/modules/ui/src/screens/person_screen.dart index c032d65..b469a36 100644 --- a/lib/modules/ui/src/screens/person_screen.dart +++ b/lib/modules/ui/src/screens/person_screen.dart @@ -9,6 +9,7 @@ import '../widgets/async_view.dart'; import '../widgets/common.dart'; import '../widgets/labels.dart'; import '../widgets/person_summary.dart'; +import '../widgets/relate_sheet.dart'; import 'chat_room_screen.dart'; import 'person_facts_screen.dart'; import 'infer_screen.dart'; @@ -62,6 +63,7 @@ class PersonScreen extends StatelessWidget { if (p == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('찾을 수 없는 사람이에요.')); return Scaffold( appBar: AppBar(title: Text(p.name), actions: [ + IconButton(tooltip: '관계 정하기', onPressed: () => showRelateSheet(context, p.id, p.name), icon: const Icon(Icons.hub_outlined)), IconButton( tooltip: '정보 고치기', onPressed: () => pushPage(context, PersonFactsScreen(personId: p.id)), @@ -127,6 +129,7 @@ class PersonScreen extends StatelessWidget { ]), ), const SizedBox(height: 10), + PlacesPanel(personId: p.id, name: p.name), RelationPanel(d.info.relations, onEdit: () => pushPage(context, PersonFactsScreen(personId: p.id))), CallPanel(d.info), EventsPanel(d.info.events), diff --git a/lib/modules/ui/src/widgets/relate_sheet.dart b/lib/modules/ui/src/widgets/relate_sheet.dart new file mode 100644 index 0000000..30305ac --- /dev/null +++ b/lib/modules/ui/src/widgets/relate_sheet.dart @@ -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 showRelateSheet(BuildContext context, String personId, String name) async { + final kind = await showModalBottomSheet( + 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(context: context, showDragHandle: true, isScrollControlled: true, builder: (_) => _FamilySheet(personId, name)); + } else { + await showModalBottomSheet(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? _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 _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 []; + 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 _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 _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>( + 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>( + 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), + ]), + ), + ); + } +} diff --git a/test/kinship_side_test.dart b/test/kinship_side_test.dart new file mode 100644 index 0000000..cf88c9f --- /dev/null +++ b/test/kinship_side_test.dart @@ -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); + }); +} diff --git a/test/relate_service_test.dart b/test/relate_service_test.dart new file mode 100644 index 0000000..92f5893 --- /dev/null +++ b/test/relate_service_test.dart @@ -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(String name) async => (await me.app.friends.add('+8210${(5000 + n++).toString().padLeft(4, '0')}0000', name))!; + Future> membersOf(String groupId) async => [for (final p in (await me.app.groups.members(groupId))!) p.name]; + Future> 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); + }); + }); +} + diff --git a/test/widget_test.dart b/test/widget_test.dart index df98f45..8e7bb13 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:friendly_map/modules/app/app.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 'support/app_device.dart'; @@ -74,4 +75,23 @@ void main() { 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(find.byType(Navigator).first).push(MaterialPageRoute(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.delayed(const Duration(milliseconds: 50))); + await tester.pumpAndSettle(); + expect(find.text('학교 › 고등학교'), findsOneWidget); + }); } diff --git a/tool/test_module.sh b/tool/test_module.sh index 48918c2..376fc7b 100644 --- a/tool/test_module.sh +++ b/tool/test_module.sh @@ -6,7 +6,7 @@ cd "$(dirname "$0")/.." declare -A T=( [keys]="keys_test.dart keys_crypto_test.dart shamir_test.dart recovery_shares_test.dart" [visibility]="visibility_test.dart" - [kinship]="kinship_test.dart" + [kinship]="kinship_test.dart kinship_side_test.dart" [groups]="groups_test.dart" [events]="events_test.dart lunar_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" [layout]="layout_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" [ui]="widget_test.dart" )