가계도 화면: 나 가운데, 위 윗세대·옆 배우자·아래 자식, 친가/외가/처가 색, 기준 바꾸기

- kinship: familyTree (새 파일) — 세대 번호, 쪽 순서(친가 왼쪽·외가/처가 오른쪽), 부부 나란히(남자 왼쪽)
- app: FamilyTreeService — 이름·호칭·촌수·다가오는 생일
- ui: FamilyTreeScreen — 배우자 쪽 족보·자녀 기준 보기, 사람 눌러 기준 바꾸기·가족 더하기, 관계 탭·가족 그룹에서 열기
- 시험 4개 추가 (전체 317개)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
diadmin 2026-10-26 21:12:42 +09:00
parent e6813e39a4
commit e32bf36da0
24 changed files with 610 additions and 4 deletions

View File

@ -15,6 +15,7 @@ export 'src/contact_import_service.dart';
export 'src/display.dart'; export 'src/display.dart';
export 'src/event_service.dart'; export 'src/event_service.dart';
export 'src/facts_service.dart'; export 'src/facts_service.dart';
export 'src/family_tree_service.dart';
export 'src/fields.dart'; export 'src/fields.dart';
export 'src/friend_service.dart' show FriendService, NearbyPerson, NewFriendCheck; export 'src/friend_service.dart' show FriendService, NearbyPerson, NewFriendCheck;
export 'src/friendly_app.dart'; export 'src/friendly_app.dart';

View File

@ -0,0 +1,95 @@
import 'package:friendly_map/modules/kinship/kinship.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import 'event_service.dart';
import 'facts_service.dart';
import 'kin_service.dart';
/// 가계도 칸 하나 (화면용): 이름, 기준 사람이 본 호칭·촌수, 다가오는 생일.
class TreeNodeView {
const TreeNodeView({required this.id, required this.name, required this.title, required this.focus, this.chon, this.spouseOf, this.birthday, this.deceased = false, required this.side});
final String id;
final String name;
final String title; // "아버지", "외삼촌" …
final int? chon;
final bool focus;
final String? spouseOf;
final String? birthday; // "생일 3월 14일 · D-12"
final bool deceased;
final FamilySide side;
}
class TreeRowView {
const TreeRowView(this.gen, this.label, this.nodes);
final int gen;
final String label;
final List<TreeNodeView> nodes;
}
/// 가계도 한 화면: 기준 사람, 세대 줄들, 기준을 바꿀 수 있는 사람들(배우자·자녀).
class FamilyTreeView {
const FamilyTreeView(this.focusId, this.focusName, this.rows, this.spouses, this.children);
final String focusId;
final String focusName;
final List<TreeRowView> rows;
final List<(String, String)> spouses; // (id, 이름) — 처가·시가 족보 보기
final List<(String, String)> children; // (id, 이름) — 자녀 기준 보기
bool get empty => rows.length <= 1 && (rows.isEmpty || rows.first.nodes.length <= 1);
}
/// 가계도 화면 자료를 만든다. 계산은 kinship 모듈, 이름·생일은 저장소에서.
class FamilyTreeService {
FamilyTreeService(this._storage, this._kin, this._facts, this._events);
final Storage _storage;
final KinService _kin;
final FactsService _facts;
final EventService _events;
Future<String> _name(String id, String meName) async => id == KinGraph.meId ? meName : (await _storage.people.get(id))?.name ?? '?';
Future<FamilyTreeView> tree({String focus = KinGraph.meId}) async {
final g = await _kin.graph();
final meName = (await _storage.settings.loadProfile())?.name ?? '나';
final f = g.has(focus) ? focus : KinGraph.meId;
final rows = <TreeRowView>[];
final focusName = await _name(f, meName);
for (final r in familyTree(g, f)) {
final nodes = <TreeNodeView>[];
for (final c in r.cells) {
String? bday;
var gone = false;
if (c.id != KinGraph.meId) {
final facts = await _facts.get(c.id);
gone = facts.deceased;
final next = await _events.forPerson(c.id);
final b = next.where((e) => e.upcoming.event.kind.name == 'birthday').firstOrNull;
if (b != null) {
final d = b.upcoming.date;
bday = '생일 ${d.month}월 ${d.day}일 · ${b.upcoming.daysLeft == 0 ? '오늘' : 'D-${b.upcoming.daysLeft}'}';
}
}
nodes.add(TreeNodeView(
id: c.id,
name: await _name(c.id, meName),
title: c.isFocus ? '기준' : c.relation.title,
chon: c.isFocus ? null : c.relation.chon,
focus: c.isFocus,
spouseOf: c.spouseOf,
birthday: bday,
deceased: gone,
side: c.side,
));
}
rows.add(TreeRowView(r.gen, f == KinGraph.meId && r.gen == 0 ? '나' : r.label(focusName), nodes));
}
return FamilyTreeView(
f,
focusName,
rows,
[for (final s in g.spousesOf(f)) (s, await _name(s, meName))],
[for (final c in g.childrenOf(f)) (c, await _name(c, meName))],
);
}
}

View File

@ -13,6 +13,7 @@ import 'chat_service.dart';
import 'contact_import_service.dart'; import 'contact_import_service.dart';
import 'event_service.dart'; import 'event_service.dart';
import 'facts_service.dart'; import 'facts_service.dart';
import 'family_tree_service.dart';
import 'friend_service.dart'; import 'friend_service.dart';
import 'group_service.dart'; import 'group_service.dart';
import 'kin_service.dart'; import 'kin_service.dart';
@ -66,6 +67,7 @@ class FriendlyApp {
work = WorkService(storage, clock, share, chats, groups, bus, deps.instant); work = WorkService(storage, clock, share, chats, groups, bus, deps.instant);
career = CareerService(storage, clock, groups, relate, network, bus); career = CareerService(storage, clock, groups, relate, network, bus);
blocks = BlockService(storage, clock, friends, bus); blocks = BlockService(storage, clock, friends, bus);
familyTree = FamilyTreeService(storage, kin, facts, events);
} }
final AppDeps deps; final AppDeps deps;
@ -92,6 +94,7 @@ class FriendlyApp {
late final WorkService work; late final WorkService work;
late final CareerService career; late final CareerService career;
late final BlockService blocks; late final BlockService blocks;
late final FamilyTreeService familyTree;
StreamSubscription<SyncEvent>? _events; StreamSubscription<SyncEvent>? _events;
final Set<Future<void>> _tasks = {}; final Set<Future<void>> _tasks = {};

View File

@ -7,3 +7,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'; export 'src/side.dart';
export 'src/tree.dart';

View File

@ -0,0 +1,113 @@
import 'graph.dart';
import 'relation.dart';
import 'side.dart';
/// 가계도 한 칸: 사람과, 기준 사람이 본 관계.
class TreeCell {
const TreeCell(this.id, this.relation, this.side, {this.spouseOf});
final String id;
final Relation relation;
final FamilySide side;
/// 이 칸이 옆 사람의 배우자로 붙어 있으면 그 사람 id.
final String? spouseOf;
bool get isFocus => relation.kind == 'self';
}
/// 세대 한 줄. [gen] 은 기준 사람에서 위(-)·아래(+)로 몇 세대인지.
class TreeRow {
const TreeRow(this.gen, this.cells);
final int gen;
final List<TreeCell> cells;
/// "조부모 세대", "부모 세대", "나", "자녀 세대" …
String label(String focusName) => switch (gen) {
0 => focusName,
-1 => '부모 세대',
-2 => '조부모 세대',
-3 => '증조부모 세대',
1 => '자녀 세대',
2 => '손주 세대',
_ => gen < 0 ? '${-gen}대 위' : '$gen대 아래',
};
}
/// [focus] 를 가운데 둔 가계도: 위에는 윗세대, 같은 줄 옆에 배우자, 아래에는 자식.
///
/// 같은 줄에서는 아버지 쪽(친가)을 왼쪽, 가까운 가족을 가운데, 어머니 쪽(외가)과 배우자 쪽(처가·시가)을 오른쪽에 둔다.
/// 부부는 나란히 붙인다. [up]·[down] 세대 밖은 뺀다.
List<TreeRow> familyTree(KinGraph g, String focus, {int up = 3, int down = 2}) {
if (!g.has(focus)) return const [];
// 세대 번호: 부모 -1, 자식 +1, 배우자 같은 세대.
final gen = <String, int>{focus: 0};
final queue = [focus];
while (queue.isNotEmpty) {
final x = queue.removeAt(0);
final gx = gen[x]!;
void visit(String y, int gy) {
if (gen.containsKey(y)) return;
gen[y] = gy;
queue.add(y);
}
for (final p in g.parentsOf(x)) {
visit(p, gx - 1);
}
for (final c in g.childrenOf(x)) {
visit(c, gx + 1);
}
for (final s in g.spousesOf(x)) {
visit(s, gx);
}
}
final rows = <int, List<TreeCell>>{};
for (final e in gen.entries) {
if (e.value < -up || e.value > down) continue;
final rel = relate(g, focus, e.key);
if (!rel.related) continue;
rows.putIfAbsent(e.value, () => []).add(TreeCell(e.key, rel, e.key == focus ? FamilySide.close : familySide(g, focus, e.key)));
}
int sideRank(FamilySide s) => switch (s) {
FamilySide.paternal => 0,
FamilySide.close || FamilySide.unknown || FamilySide.none => 1,
FamilySide.maternal => 2,
FamilySide.inlaw => 3,
};
int born(String id) => g.person(id)?.birthYear ?? 9999;
final out = <TreeRow>[];
for (final k in rows.keys.toList()..sort()) {
final cells = rows[k]!
..sort((a, b) {
final s = sideRank(a.side).compareTo(sideRank(b.side));
if (s != 0) return s;
if (a.isFocus != b.isFocus) return a.isFocus ? -1 : 1;
final ga = g.person(a.id)?.gender == Gender.male ? 0 : 1, gb = g.person(b.id)?.gender == Gender.male ? 0 : 1;
if (ga != gb) return ga.compareTo(gb); // 부부·형제는 남자를 왼쪽에
final y = born(a.id).compareTo(born(b.id));
return y != 0 ? y : a.id.compareTo(b.id);
});
out.add(TreeRow(k, _pairCouples(g, cells)));
}
return out;
}
/// 부부를 나란히: 배우자를 짝 바로 오른쪽으로 옮기고 spouseOf 를 적는다.
List<TreeCell> _pairCouples(KinGraph g, List<TreeCell> cells) {
final out = <TreeCell>[];
final used = <String>{};
for (final c in cells) {
if (used.contains(c.id)) continue;
// 배우자가 먼저 나올 차례라면, 혈연 쪽(배우자가 아닌 쪽)을 앞에 둔다.
out.add(c);
used.add(c.id);
for (final s in g.spousesOf(c.id)) {
final i = cells.indexWhere((x) => x.id == s);
if (i < 0 || used.contains(s)) continue;
final sc = cells[i];
out.add(TreeCell(sc.id, sc.relation, sc.side, spouseOf: c.id));
used.add(s);
}
}
return out;
}

View File

@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/kinship/kinship.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
import 'gathering_screen.dart';
import 'person_screen.dart';
Color _sideColor(FamilySide s) => switch (s) {
FamilySide.paternal => Pal.company,
FamilySide.maternal => Pal.group,
FamilySide.inlaw => Pal.school,
_ => Pal.accent,
};
/// 가계도: 기준 사람을 가운데 두고 위로 윗세대, 옆에 배우자, 아래로 자식.
/// 사람을 누르면 그 사람 기준으로 다시 보거나, 그 사람의 부모·배우자·자녀·형제자매를 더한다.
class FamilyTreeScreen extends StatefulWidget {
const FamilyTreeScreen({super.key});
@override
State<FamilyTreeScreen> createState() => _FamilyTreeScreenState();
}
class _FamilyTreeScreenState extends State<FamilyTreeScreen> {
String _focus = KinGraph.meId;
Future<void> _addTo(String toId, String toName, KinLink link) async {
final app = AppScope.of(context).app;
final r = await showDialog<(String, Gender)>(context: context, builder: (_) => _NewRelative(toName: toName, link: link));
if (r == null || !mounted) return;
final p = await app.kin.addRelative(r.$1, gender: r.$2);
if (p == null) return;
final placed = await app.relate.asFamily(p.id, link: link, to: toId, gender: r.$2);
if (mounted && placed == null) showNote(context, '이을 수 없어요. 부모는 두 분까지예요.');
}
Future<void> _tap(TreeNodeView n) async {
final me = n.id == KinGraph.meId;
final pick = await showModalBottomSheet<String>(
context: context,
builder: (c) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Text(n.focus ? n.name : '${n.name} · ${n.title}${n.chon == null || n.chon == 0 ? '' : ' (${n.chon}촌)'}', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
),
if (!n.focus) ListTile(leading: const Icon(Icons.center_focus_strong), title: Text('${n.name} 기준으로 보기'), subtitle: const Text('촌수와 호칭이 이 분 기준으로 바뀌어요'), onTap: () => Navigator.pop(c, 'focus')),
if (!me) ListTile(leading: const Icon(Icons.person_outline), title: const Text('프로필 · 생일 적기'), onTap: () => Navigator.pop(c, 'profile')),
const Divider(),
for (final (k, t) in const [('parent', '부모 더하기'), ('spouse', '배우자 더하기'), ('child', '자녀 더하기'), ('sibling', '형제자매 더하기')])
ListTile(leading: const Icon(Icons.add), title: Text('${n.name}의 $t'), onTap: () => Navigator.pop(c, k)),
]),
),
);
if (!mounted || pick == null) return;
switch (pick) {
case 'focus':
setState(() => _focus = n.id);
case 'profile':
pushPage(context, PersonScreen(personId: n.id));
default:
await _addTo(n.id, n.name, KinLink.values.byName(pick));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('가계도'),
actions: [IconButton(tooltip: '목록으로 보기', onPressed: () => pushPage(context, const GatheringScreen()), icon: const Icon(Icons.list))],
),
body: AsyncView<FamilyTreeView>(
key: ValueKey(_focus),
load: (a) => a.familyTree.tree(focus: _focus),
builder: (context, t) => ListView(padding: const EdgeInsets.only(bottom: 40), children: [
_FocusBar(t, current: _focus, onFocus: (id) => setState(() => _focus = id)),
const _Legend(),
if (t.empty)
Panel(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('아직 가족이 없어요', style: TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
const Text('나를 누르거나 아래 버튼으로 부모·배우자·자녀를 더해 보세요.', style: TextStyle(fontSize: 13, color: Pal.muted)),
const SizedBox(height: 10),
Wrap(spacing: 8, children: [
for (final (l, t2) in const [(KinLink.parent, '부모'), (KinLink.spouse, '배우자'), (KinLink.child, '자녀')])
OutlinedButton(onPressed: () => _addTo(t.focusId, t.focusName, l), child: Text('$t2 더하기')),
]),
]),
),
for (final r in t.rows) _GenerationRow(r, onTap: _tap),
]),
),
);
}
}
class _FocusBar extends StatelessWidget {
const _FocusBar(this.t, {required this.current, required this.onFocus});
final FamilyTreeView t;
final String current;
final ValueChanged<String> onFocus;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(current == KinGraph.meId ? '나를 기준으로 보는 중' : '${t.focusName}님을 기준으로 보는 중', style: const TextStyle(fontSize: 13, color: Pal.muted)),
const SizedBox(height: 6),
Wrap(spacing: 6, runSpacing: 6, children: [
if (current != KinGraph.meId) ActionChip(avatar: const Icon(Icons.undo, size: 16), label: const Text('나로 돌아가기'), onPressed: () => onFocus(KinGraph.meId)),
for (final (id, name) in t.spouses) ActionChip(avatar: const Icon(Icons.favorite_border, size: 16), label: Text('$name 쪽 족보'), onPressed: () => onFocus(id)),
for (final (id, name) in t.children) ActionChip(avatar: const Icon(Icons.child_care, size: 16), label: Text('$name 기준'), onPressed: () => onFocus(id)),
]),
]),
);
}
class _Legend extends StatelessWidget {
const _Legend();
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Row(children: [
for (final (s, t) in const [(FamilySide.paternal, '친가'), (FamilySide.close, '가까운 가족'), (FamilySide.maternal, '외가'), (FamilySide.inlaw, '처가·시가')]) ...[
Container(width: 10, height: 10, decoration: BoxDecoration(color: _sideColor(s), shape: BoxShape.circle)),
const SizedBox(width: 4),
Text(t, style: const TextStyle(fontSize: 11, color: Pal.muted)),
const SizedBox(width: 12),
],
]),
);
}
class _GenerationRow extends StatelessWidget {
const _GenerationRow(this.r, {required this.onTap});
final TreeRowView r;
final ValueChanged<TreeNodeView> onTap;
@override
Widget build(BuildContext context) {
// 부부는 한 테두리 안에 나란히.
final units = <List<TreeNodeView>>[];
for (final n in r.nodes) {
if (n.spouseOf != null && units.isNotEmpty && units.last.first.id == n.spouseOf) {
units.last.add(n);
} else {
units.add([n]);
}
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 6),
child: Text(r.label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: r.gen == 0 ? Pal.accent : Pal.muted)),
),
LayoutBuilder(
builder: (context, box) => SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: box.maxWidth - 24),
child: Row(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final u in units)
Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.all(4),
decoration: u.length > 1 ? BoxDecoration(borderRadius: BorderRadius.circular(16), border: Border.all(color: Pal.line)) : null,
child: Row(children: [for (final n in u) _Node(n, onTap: () => onTap(n))]),
),
]),
),
),
),
]);
}
}
class _Node extends StatelessWidget {
const _Node(this.n, {required this.onTap});
final TreeNodeView n;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final color = n.focus ? Pal.accent : _sideColor(n.side);
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: SizedBox(
width: 92,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(children: [
Opacity(opacity: n.deceased ? 0.5 : 1, child: Avatar(n.name, size: n.focus ? 56 : 48, color: color)),
const SizedBox(height: 6),
Text('${n.deceased ? '故 ' : ''}${n.name}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 13, fontWeight: n.focus ? FontWeight.w800 : FontWeight.w600)),
Text(n.focus ? '기준' : '${n.title}${n.chon == null || n.chon == 0 ? '' : ' ${n.chon}촌'}',
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: n.focus ? Pal.accent : Pal.muted)),
if (n.birthday != null)
Text(n.birthday!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 10, color: Pal.accent)),
]),
),
),
);
}
}
/// 새 가족 한 분: 이름과 성별 (번호 없이 이 기기 안에서만 쓰는 사람).
class _NewRelative extends StatefulWidget {
const _NewRelative({required this.toName, required this.link});
final String toName;
final KinLink link;
@override
State<_NewRelative> createState() => _NewRelativeState();
}
class _NewRelativeState extends State<_NewRelative> {
final _name = TextEditingController();
Gender _g = Gender.unknown;
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final what = switch (widget.link) { KinLink.parent => '부모', KinLink.child => '자녀', KinLink.spouse => '배우자', KinLink.sibling => '형제자매' };
return AlertDialog(
title: Text('${widget.toName}의 $what'),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
TextField(controller: _name, autofocus: true, decoration: const InputDecoration(labelText: '이름 (예: 홍길동, 큰아버지)')),
const SizedBox(height: 12),
Wrap(spacing: 6, children: [
for (final (g, t) in const [(Gender.male, '남'), (Gender.female, '여'), (Gender.unknown, '모름')])
ChoiceChip(label: Text(t), selected: _g == g, onSelected: (_) => setState(() => _g = g)),
]),
const SizedBox(height: 8),
const Text('번호는 나중에 프로필에서 적을 수 있어요. 번호가 없으면 이 폰 안에서만 쓰여요.', style: TextStyle(fontSize: 12, color: Pal.muted)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('취소')),
TextButton(onPressed: () => _name.text.trim().isEmpty ? null : Navigator.pop(context, (_name.text.trim(), _g)), child: const Text('더하기')),
],
);
}
}

View File

@ -12,6 +12,7 @@ import '../widgets/group_menu.dart';
import '../widgets/pin_dialog.dart'; import '../widgets/pin_dialog.dart';
import 'chat_room_screen.dart'; import 'chat_room_screen.dart';
import 'contact_import_screen.dart'; import 'contact_import_screen.dart';
import 'family_tree_screen.dart';
import 'person_screen.dart'; import 'person_screen.dart';
class _Data { class _Data {
@ -116,6 +117,16 @@ class GroupDetailScreen extends StatelessWidget {
final app = AppScope.of(context).app; final app = AppScope.of(context).app;
final g = v.group; final g = v.group;
return ListView(padding: const EdgeInsets.only(top: 4, bottom: 96), children: [ return ListView(padding: const EdgeInsets.only(top: 4, bottom: 96), children: [
if (g.kind == CardKinds.family)
Panel(
onTap: () => pushPage(context, const FamilyTreeScreen()),
child: const Row(children: [
Icon(Icons.account_tree_outlined, color: Pal.accent),
SizedBox(width: 12),
Expanded(child: Text('가계도로 보기 — 촌수·호칭·생일', style: TextStyle(fontWeight: FontWeight.w600))),
Icon(Icons.chevron_right, color: Pal.muted),
]),
),
if (d.children.isNotEmpty || v.canAddChild) if (d.children.isNotEmpty || v.canAddChild)
SectionHeader('하위 그룹', SectionHeader('하위 그룹',
trailing: v.canAddChild trailing: v.canAddChild

View File

@ -10,7 +10,7 @@ import '../widgets/group_dialogs.dart';
import '../widgets/group_menu.dart'; import '../widgets/group_menu.dart';
import 'add_friend_screen.dart'; import 'add_friend_screen.dart';
import 'contact_import_screen.dart'; import 'contact_import_screen.dart';
import 'gathering_screen.dart'; import 'family_tree_screen.dart';
import 'group_detail_screen.dart'; import 'group_detail_screen.dart';
import 'people_screen.dart'; import 'people_screen.dart';
import 'person_screen.dart'; import 'person_screen.dart';
@ -104,7 +104,7 @@ class _RelationsScreenState extends State<RelationsScreen> {
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
child: Row(children: [ child: Row(children: [
_Quick(Icons.people_alt_outlined, '모든 사람', '${d.people.length}명', () => pushPage(context, const PeopleScreen())), _Quick(Icons.people_alt_outlined, '모든 사람', '${d.people.length}명', () => pushPage(context, const PeopleScreen())),
_Quick(Icons.account_tree_outlined, '가계도', '촌수', () => pushPage(context, const GatheringScreen())), _Quick(Icons.account_tree_outlined, '가계도', '촌수', () => pushPage(context, const FamilyTreeScreen())),
_Quick(Icons.contact_phone_outlined, '주소록', '불러오기', () => pushPage(context, const ContactImportScreen())), _Quick(Icons.contact_phone_outlined, '주소록', '불러오기', () => pushPage(context, const ContactImportScreen())),
_Quick(Icons.vibration, '흔들기', '명함 교환', () => pushPage(context, const ShakeScreen())), _Quick(Icons.vibration, '흔들기', '명함 교환', () => pushPage(context, const ShakeScreen())),
]), ]),

View File

@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:friendly_map/modules/kinship/kinship.dart';
const M = Gender.male, F = Gender.female;
KinGraph family() {
final g = KinGraph();
for (final (id, gen, y) in [
('me', M, 1990), ('wife', F, 1991), ('son', M, 2020), ('daughter', F, 2022),
('dad', M, 1960), ('mom', F, 1962), ('gf', M, 1930), ('gm', F, 1932), ('mgf', M, 1935),
('uncle', M, 1958), ('sis', F, 1993), ('wifeDad', M, 1963), ('wifeMom', F, 1964),
]) {
g.put(KinPerson(id, gender: gen, birthYear: y));
}
g.addParent('me', 'dad');
g.addParent('me', 'mom');
g.addParent('sis', 'dad');
g.addParent('sis', 'mom');
g.addParent('dad', 'gf');
g.addParent('dad', 'gm');
g.addParent('uncle', 'gf');
g.addParent('mom', 'mgf');
g.addSpouse('dad', 'mom');
g.addSpouse('gf', 'gm');
g.addSpouse('me', 'wife');
g.addParent('son', 'me');
g.addParent('son', 'wife');
g.addParent('daughter', 'me');
g.addParent('wife', 'wifeDad');
g.addParent('wife', 'wifeMom');
g.addSpouse('wifeDad', 'wifeMom');
return g;
}
List<String> ids(TreeRow r) => [for (final c in r.cells) c.id];
void main() {
test('나 가운데: 위로 부모·조부모, 옆에 아내, 아래 자식', () {
final rows = familyTree(family(), 'me');
expect(rows.map((r) => r.gen), [-2, -1, 0, 1]);
final me = rows.firstWhere((r) => r.gen == 0);
expect(ids(me).indexOf('wife'), ids(me).indexOf('me') + 1, reason: '부부는 나란히');
expect(me.cells.firstWhere((c) => c.id == 'wife').spouseOf, 'me');
expect(ids(rows.firstWhere((r) => r.gen == 1)), ['son', 'daughter']);
});
test('친가는 왼쪽, 외가·처가는 오른쪽', () {
final rows = familyTree(family(), 'me');
final parents = ids(rows.firstWhere((r) => r.gen == -1));
expect(parents.indexOf('uncle'), lessThan(parents.indexOf('dad')), reason: '큰아버지(친가)가 왼쪽');
expect(parents.indexOf('wifeDad'), greaterThan(parents.indexOf('mom')), reason: '장인(처가)이 오른쪽');
expect(parents.indexOf('dad'), lessThan(parents.indexOf('mom')), reason: '부부는 남자가 왼쪽');
final gp = ids(rows.firstWhere((r) => r.gen == -2));
expect(gp.indexOf('gf'), lessThan(gp.indexOf('mgf')));
});
test('호칭은 기준 사람 눈으로: 아들을 기준으로 바꾸면 나는 아버지, 내 아버지는 할아버지', () {
final g = family();
final rows = familyTree(g, 'son');
TreeCell cell(String id) => rows.expand((r) => r.cells).firstWhere((c) => c.id == id);
expect(cell('son').isFocus, isTrue);
expect(cell('me').relation.title, '아버지');
expect(cell('dad').relation.title, contains('할아버지'));
expect(cell('daughter').relation.chon, 2);
expect(rows.first.gen, -3, reason: '아들 기준으로는 증조부모까지');
});
test('세대 범위 밖은 뺀다, 없는 사람은 빈 가계도', () {
expect(familyTree(family(), 'me', up: 1, down: 0).map((r) => r.gen), [-1, 0]);
expect(familyTree(family(), '없음'), isEmpty);
expect(const TreeRow(-1, []).label('나'), '부모 세대');
expect(const TreeRow(0, []).label('나'), '나');
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -7,11 +7,13 @@ import 'package:flutter/services.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/core/core.dart'; import 'package:friendly_map/modules/core/core.dart';
import 'package:friendly_map/modules/kinship/kinship.dart';
import 'package:friendly_map/modules/platform/platform.dart'; import 'package:friendly_map/modules/platform/platform.dart';
import 'package:friendly_map/modules/storage/storage.dart'; import 'package:friendly_map/modules/storage/storage.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/chat_room_screen.dart'; import 'package:friendly_map/modules/ui/src/screens/chat_room_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/contact_import_screen.dart'; import 'package:friendly_map/modules/ui/src/screens/contact_import_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/family_tree_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/group_detail_screen.dart'; import 'package:friendly_map/modules/ui/src/screens/group_detail_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/person_screen.dart'; import 'package:friendly_map/modules/ui/src/screens/person_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/requests_screen.dart'; import 'package:friendly_map/modules/ui/src/screens/requests_screen.dart';
@ -73,6 +75,22 @@ void main() {
await app.chats.send(room.id, '안녕하세요, 내일 회의 몇 시죠?'); await app.chats.send(room.id, '안녕하세요, 내일 회의 몇 시죠?');
await app.work.assign(room.id, '결제 API 정리', assigneeId: people['김민수'], due: DateTime(2026, 10, 30)); await app.work.assign(room.id, '결제 API 정리', assigneeId: people['김민수'], due: DateTime(2026, 10, 30));
await app.work.urgent(room.id, '결제 서버가 멈췄어요'); await app.work.urgent(room.id, '결제 서버가 멈췄어요');
// 가족: 나(남) · 아내 · 아들 · 부모 · 할아버지 · 장인
await app.kin.setMe(gender: Gender.male, birthYear: 1990);
Future<String> rel(String n, Gender g) async => (await app.kin.addRelative(n, gender: g))!.id;
final dad = await rel('이정훈', Gender.male);
await app.relate.asFamily(dad, link: KinLink.parent, gender: Gender.male);
final mom = await rel('박순자', Gender.female);
await app.relate.asFamily(mom, link: KinLink.parent, gender: Gender.female);
await app.kin.setSpouse(dad, mom);
await app.relate.asFamily(await rel('이만복', Gender.male), link: KinLink.parent, to: dad, gender: Gender.male);
final wife = await rel('최유진', Gender.female);
await app.relate.asFamily(wife, link: KinLink.spouse, gender: Gender.female);
await app.relate.asFamily(await rel('최상철', Gender.male), link: KinLink.parent, to: wife, gender: Gender.male);
final son = await rel('이도윤', Gender.male);
await app.relate.asFamily(son, link: KinLink.child, gender: Gender.male);
await app.kin.setParent(son, wife);
await app.facts.save(son, (await app.facts.get(son)).copyWith(birthYear: 2020, birthMonth: 11, birthDay: 3));
// 김민수가 새 폰으로 다시 연결을 청함 // 김민수가 새 폰으로 다시 연결을 청함
final again = await AppDevice.create(hub, '010-3333-4444', '+821033334444', '김민수 새폰'); final again = await AppDevice.create(hub, '010-3333-4444', '+821033334444', '김민수 새폰');
await again.app.friends.add(me.phone, '이미영'); await again.app.friends.add(me.phone, '이미영');
@ -138,6 +156,8 @@ void main() {
await shot(tester, '12_group_company'); await shot(tester, '12_group_company');
await push(tester, const SettingsScreen()); await push(tester, const SettingsScreen());
await shot(tester, '13_settings'); await shot(tester, '13_settings');
await push(tester, const FamilyTreeScreen());
await shot(tester, '14_family_tree');
}); });
testWidgets('주소록 · 사람 · 대화', (tester) async { testWidgets('주소록 · 사람 · 대화', (tester) async {

View File

@ -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_side_test.dart" [kinship]="kinship_test.dart kinship_side_test.dart family_tree_test.dart"
[groups]="groups_test.dart" [groups]="groups_test.dart"
[work]="work_test.dart" [work]="work_test.dart"
[events]="events_test.dart lunar_test.dart" [events]="events_test.dart lunar_test.dart"

View File

@ -22,3 +22,9 @@
- 새 기능은 (1) 순수 모듈 또는 서비스 + 시험 → (2) 화면. 화면을 바꾸면 캡처해서 [[06 화면 점검]]에 붙인다. - 새 기능은 (1) 순수 모듈 또는 서비스 + 시험 → (2) 화면. 화면을 바꾸면 캡처해서 [[06 화면 점검]]에 붙인다.
- 끝낼 때 한글로: 무엇이 바뀌었나, 무엇을 돌려 확인했나, 확인 못 한 것은 무엇인가. 그리고 [[진행 로그/2026-10-26|진행 로그]] 갱신. - 끝낼 때 한글로: 무엇이 바뀌었나, 무엇을 돌려 확인했나, 확인 못 한 것은 무엇인가. 그리고 [[진행 로그/2026-10-26|진행 로그]] 갱신.
- 저장소: 평소 Gitea 에 커밋·푸시, GitLab 은 하루 한 번 메이저 업그레이드 때만(원격 미등록). - 저장소: 평소 Gitea 에 커밋·푸시, GitLab 은 하루 한 번 메이저 업그레이드 때만(원격 미등록).
## 질문의 규칙
- 모든 질문은 출력물을 가지고한다.
- 출력물은 개발의 경우 실행파일 등이 실행가능해야된 이를 실행하여 보여주거나 사용자에게 직접 실행햐서 보여줄수 있어야한다.
- 산출물일경우 PPT등을 보여주고 상황 설명을 하고 지시를 받아야 한다.

View File

@ -22,3 +22,4 @@
| 2026-10-26 | 새 폰에서 다시 시작 → 관계를 유지하는 사용자 각자가 **승인**(다시 연결) 또는 **제명**(번호 영구 차단) | 사용자 결정: 관리자 승인 | | 2026-10-26 | 새 폰에서 다시 시작 → 관계를 유지하는 사용자 각자가 **승인**(다시 연결) 또는 **제명**(번호 영구 차단) | 사용자 결정: 관리자 승인 |
| 2026-10-26 | 아래 메뉴: 채팅(첫 화면) · 관계 · 업무 · 명함 · 나. 옛 홈 타일·중복 화면 삭제 | UX 점검 결과 | | 2026-10-26 | 아래 메뉴: 채팅(첫 화면) · 관계 · 업무 · 명함 · 나. 옛 홈 타일·중복 화면 삭제 | UX 점검 결과 |
| 2026-10-26 | 질문은 APK·화면을 먼저 보여 준 뒤에만 | 사용자 최우선 지시 | | 2026-10-26 | 질문은 APK·화면을 먼저 보여 준 뒤에만 | 사용자 최우선 지시 |
| 2026-10-26 | 가계도: 세대 줄 방식(위 조상·옆 배우자·아래 자식), 친가 왼쪽·외가/처가 오른쪽, 부부는 남자 왼쪽, 처가 족보 = 배우자 기준으로 다시 보기 | 사용자 요구(01 제품 개요) |

View File

@ -10,6 +10,12 @@
- [ ] APK 전달 → 사용자 설치·시험 → 화면 보며 피드백 반영 - [ ] APK 전달 → 사용자 설치·시험 → 화면 보며 피드백 반영
- [ ] 실제 기기 점검(주소록, 알림, 위치, QR, 같은 네트워크) - [ ] 실제 기기 점검(주소록, 알림, 위치, QR, 같은 네트워크)
## 2026-10-26 사용자 추가 요구 ([[01 제품 개요]])
- [x] 5. 가계도 화면: 나 가운데, 위 윗세대(촌수), 옆 배우자, 아래 자식, 생일·알림, 친가/처가 족보, 자식 누르면 그 자식 기준으로 촌수 일괄 변경
- [ ] 6. 학교 원형 화면: 나 가운데, 초·중·고·대 둘레, 가까운 사람(대화·통화 많은 순), 선배·후배(연차/연도), 반별/합쳐 보기 설정, 동문·동창 일괄 선택 → 긴급 단체 메시지
- [ ] 7. 회사 원형 화면: 학교와 같은 방식(설정 가능), 프로젝트 전체·아래 직원 전체·윗사람 전체 메시지(직급 순서)
- [ ] 8. 이직 이력: 회사별 근무 기간(몇 년~몇 년), 그 사람을 누르면 내 기록 속 주변 사람
## 현재 상태 (2026-10-26) ## 현재 상태 (2026-10-26)
- podman Flutter(stable)에서 `flutter analyze` 0건, `flutter test` 313개 통과, 서버(Python) 18개 통과, check_modules.py 통과. - podman Flutter(stable)에서 `flutter analyze` 0건, `flutter test` 313개 통과, 서버(Python) 18개 통과, check_modules.py 통과.
- `flutter build apk --debug` 성공(약 190MB, 개편 화면). - `flutter build apk --debug` 성공(약 190MB, 개편 화면).

View File

@ -27,3 +27,11 @@
- 나: 설정 목록(공개 범위·요청 받기·막기·위치·잠금 시간·제명한 번호·알림·저장 공간) - 나: 설정 목록(공개 범위·요청 받기·막기·위치·잠금 시간·제명한 번호·알림·저장 공간)
- 번호는 모든 곳에서 010-0000-0000 - 번호는 모든 곳에서 010-0000-0000
- 캡처 글꼴 문제: 테마 글자 모양에 글꼴 이름을 명시해 캡처=폰 화면 - 캡처 글꼴 문제: 테마 글자 모양에 글꼴 이름을 명시해 캡처=폰 화면
## 2026-10-26 가계도 (로드맵 5단계)
![[화면 점검/2026-10-26 가계도/14_family_tree.png|300]]
- 기준 사람 가운데, 위로 조부모·부모 세대, 같은 줄에 배우자(부부는 한 테두리, 남자 왼쪽), 아래로 자녀 세대
- 색: 친가 파랑 · 가까운 가족 노랑 · 외가 분홍 · 처가·시가 초록
- 위쪽 "OOO 쪽 족보"(배우자 기준 = 처가·시가 족보), "OOO 기준"(자녀 기준)으로 누르면 촌수·호칭이 한꺼번에 바뀜
- 사람을 누르면: 그 사람 기준으로 보기 / 프로필·생일 적기 / 그 사람의 부모·배우자·자녀·형제자매 더하기
- 생일이 있으면 "생일 11월 3일 · D-8"

View File

@ -1,6 +1,16 @@
# 가족 · 가계도 · 생일 # 가족 · 가계도 · 생일
> 화면: 관계 탭 › 가계도, 프로필, 나 › 알림 · 저장 공간 > 화면: 관계 탭 › 가계도(또는 가족 그룹 › 가계도로 보기), 프로필, 나 › 알림 · 저장 공간
## 가계도 화면
- 기준 사람을 가운데 두고 위로 윗세대(조부모·부모), 같은 줄 옆에 배우자, 아래로 자녀·손주.
- 친가는 왼쪽(파랑), 외가는 오른쪽(분홍), 처가·시가는 오른쪽 끝(초록). 부부는 한 테두리 안에 남자가 왼쪽.
- 위쪽 버튼: "OOO 쪽 족보"(배우자 기준 = 처가·시가 족보), "OOO 기준"(자녀 기준). 누르면 모든 호칭·촌수가 그 사람 눈으로 바뀐다. "나로 돌아가기".
- 사람을 누르면 그 사람 기준으로 보기, 프로필·생일 적기, 그 사람의 부모·배우자·자녀·형제자매 더하기.
- 생일이 적혀 있으면 칸 아래에 "생일 11월 3일 · D-8". 알림은 나 › 알림 · 저장 공간에서 며칠 전에 울릴지 정한다.
- 오른쪽 위 목록 버튼은 예전 목록 화면(가족 모임).
## 목록·알림
- 관계 탭 › 가계도에서 가족을 한 분씩 추가한다. 번호가 없는 친척도 넣을 수 있다(이 기기 안에서만 쓰이고 친구 요청은 가지 않는다). - 관계 탭 › 가계도에서 가족을 한 분씩 추가한다. 번호가 없는 친척도 넣을 수 있다(이 기기 안에서만 쓰이고 친구 요청은 가지 않는다).
- "나의 부모/자녀/배우자"를 고르거나 부모·자녀·배우자를 이어 가계도를 만들면, 나와 내 자녀 각각의 눈으로 본 호칭과 촌수가 나온다(예: 내게는 친사촌 형 4촌, 내 아들에게는 당숙 5촌). - "나의 부모/자녀/배우자"를 고르거나 부모·자녀·배우자를 이어 가계도를 만들면, 나와 내 자녀 각각의 눈으로 본 호칭과 촌수가 나온다(예: 내게는 친사촌 형 4촌, 내 아들에게는 당숙 5촌).

View File

@ -14,3 +14,5 @@
- 20:38 개편 APK 빌드 성공 (app-debug.apk 약 190MB, 커밋 e7982c8) → 사용자 설치·시험 대기 - 20:38 개편 APK 빌드 성공 (app-debug.apk 약 190MB, 커밋 e7982c8) → 사용자 설치·시험 대기
- README 를 wiki 로 정리: 기능 안내를 화면 지도+기능별 노트 10개로 나누고, 모듈 표는 04, 시험·준비는 05, 원칙은 00 으로 - README 를 wiki 로 정리: 기능 안내를 화면 지도+기능별 노트 10개로 나누고, 모듈 표는 04, 시험·준비는 05, 원칙은 00 으로
- README·CLAUDE.md·server/README 의 내용을 wiki 로 모두 옮기고, 세 파일에는 "무조건 wiki 참조" 지시만 남김 - README·CLAUDE.md·server/README 의 내용을 wiki 로 모두 옮기고, 세 파일에는 "무조건 wiki 참조" 지시만 남김
- wiki 를 읽고 사용자 추가 요구 4가지(가계도, 학교 원형, 회사 원형·직급 메시지, 이직 이력)와 용어 규칙(낯선 외래어·한자어 금지)을 확인 → [[03 로드맵]] 5~8단계로 등록
- 5단계 가계도 화면: kinship/tree.dart(세대 줄 계산), FamilyTreeService, FamilyTreeScreen. 시험 317개 통과 → [[06 화면 점검]]

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB