채팅에서 관계 정하기: 가족 촌수 맞추기·친가/외가/처가 나누기, 학교·회사·모임 그룹에 넣기

- kinship: familySide (새 파일, 기존 계산은 그대로) — 친가·외가·처가·가까운 가족 판별
- app: RelateService — 그룹에 넣으며 연결 고리(가족·동문·직장 동료·같은 모임) 남김, 가족은 가계도 잇기(형제자매는 이름 모를 부모 자동), 촌수 모름 모음
- ui: 대화방·사람 화면의 "관계 정하기" 시트, 사람 화면에 들어간 그룹 표시, 그 자리에서 하위 그룹 만들어 넣기
- 시험 14개 추가 (전체 288개)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
diadmin 2026-10-26 14:37:07 +09:00
parent fc78d35109
commit 98fb0f2d0d
15 changed files with 675 additions and 4 deletions

View File

@ -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).

View File

@ -110,3 +110,10 @@ flutter run
- "주소록에서 가져오기": 이름·초성(ㄱㅁㅅ)·번호로 찾고, 휴대폰 주소록 그룹(라벨)으로 거르고, 이미 있는 사람은 빼고 볼 수 있다. 한 명씩 또는 보이는 사람을 모두 고른 뒤, 넣을 그룹(여러 개)을 골라 가져온다.
- 번호가 여럿이면 휴대폰(010) 번호를 먼저 쓴다. 가져온 사람은 이 기기 인맥에만 들어가고, "친구 요청도 보내기"를 켰을 때만 상대에게 요청이 간다. 주소록은 읽기만 한다.
- 그룹은 이 기기에만 저장한다. 안드로이드: `flutter_contacts` 플러그인과 READ_CONTACTS 권한이 필요하다(`tool/android_patch.py` 가 넣는다).
## 관계 정하기 (채팅에서 시작하는 관계망)
- 대화방 위의 관계 아이콘, 또는 사람 화면의 "관계 정하기"에서 고른다: 가족·친척 / 학교 동문 / 회사·업무·팀 / 그룹·모임·여행.
- 가족: 나(또는 가계도에 있는 다른 가족)의 부모·자녀·배우자·형제자매를 고르면 가계도에 잇고 촌수·호칭을 계산한다. 아버지 쪽은 친가, 어머니 쪽은 외가, 배우자 쪽은 처가·시가 그룹에 들어간다. 모르면 "친척(촌수 모름)"에 모아 두고, 나중에 이으면 모음에서 빠진다.
- 형제자매를 고를 때 기준 사람의 부모가 가계도에 없으면 "부모(이름 모름)"를 이 기기 안에서만 쓰는 사람으로 만들어 잇는다.
- 학교·회사·모임: 그 종류의 그룹 나무에서 골라 넣거나, + 로 그 아래 새 그룹(예: 학교 › 고등학교 › 가람고 27회, 회사 › 한빛전자 › 결제팀)을 만들어 바로 넣는다.
- 넣으면 연결 고리(가족·동문·직장 동료·같은 모임)가 하나 남아, 공개 범위 표가 그 관계로 적용된다. 같은 종류의 그룹에서 모두 빠지면 연결 고리도 지운다.

View File

@ -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';

View File

@ -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<SyncEvent>? _events;
final Set<Future<void>> _tasks = {};

View File

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

View File

@ -6,3 +6,4 @@ library;
export 'src/graph.dart';
export 'src/relation.dart' show Relation, relate;
export 'src/side.dart';

View File

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

View File

@ -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<ChatRoomScreen> {
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: [
if (d.advice.recommended)

View File

@ -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),

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
)