diff --git a/lib/modules/app/app.dart b/lib/modules/app/app.dart index bfb1bb1..8974716 100644 --- a/lib/modules/app/app.dart +++ b/lib/modules/app/app.dart @@ -15,6 +15,7 @@ export 'src/contact_import_service.dart'; export 'src/display.dart'; export 'src/event_service.dart'; export 'src/facts_service.dart'; +export 'src/family_tree_service.dart'; export 'src/fields.dart'; export 'src/friend_service.dart' show FriendService, NearbyPerson, NewFriendCheck; export 'src/friendly_app.dart'; diff --git a/lib/modules/app/src/family_tree_service.dart b/lib/modules/app/src/family_tree_service.dart new file mode 100644 index 0000000..6d24878 --- /dev/null +++ b/lib/modules/app/src/family_tree_service.dart @@ -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 nodes; +} + +/// 가계도 한 화면: 기준 사람, 세대 줄들, 기준을 바꿀 수 있는 사람들(배우자·자녀). +class FamilyTreeView { + const FamilyTreeView(this.focusId, this.focusName, this.rows, this.spouses, this.children); + final String focusId; + final String focusName; + final List 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 _name(String id, String meName) async => id == KinGraph.meId ? meName : (await _storage.people.get(id))?.name ?? '?'; + + Future 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 = []; + final focusName = await _name(f, meName); + for (final r in familyTree(g, f)) { + final nodes = []; + 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))], + ); + } +} diff --git a/lib/modules/app/src/friendly_app.dart b/lib/modules/app/src/friendly_app.dart index 32887b3..7c0a42c 100644 --- a/lib/modules/app/src/friendly_app.dart +++ b/lib/modules/app/src/friendly_app.dart @@ -13,6 +13,7 @@ import 'chat_service.dart'; import 'contact_import_service.dart'; import 'event_service.dart'; import 'facts_service.dart'; +import 'family_tree_service.dart'; import 'friend_service.dart'; import 'group_service.dart'; import 'kin_service.dart'; @@ -66,6 +67,7 @@ class FriendlyApp { work = WorkService(storage, clock, share, chats, groups, bus, deps.instant); career = CareerService(storage, clock, groups, relate, network, bus); blocks = BlockService(storage, clock, friends, bus); + familyTree = FamilyTreeService(storage, kin, facts, events); } final AppDeps deps; @@ -92,6 +94,7 @@ class FriendlyApp { late final WorkService work; late final CareerService career; late final BlockService blocks; + late final FamilyTreeService familyTree; StreamSubscription? _events; final Set> _tasks = {}; diff --git a/lib/modules/kinship/kinship.dart b/lib/modules/kinship/kinship.dart index 38cd9fb..43fd96f 100644 --- a/lib/modules/kinship/kinship.dart +++ b/lib/modules/kinship/kinship.dart @@ -7,3 +7,4 @@ library; export 'src/graph.dart'; export 'src/relation.dart' show Relation, relate; export 'src/side.dart'; +export 'src/tree.dart'; diff --git a/lib/modules/kinship/src/tree.dart b/lib/modules/kinship/src/tree.dart new file mode 100644 index 0000000..0a79951 --- /dev/null +++ b/lib/modules/kinship/src/tree.dart @@ -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 cells; + + /// "조부모 세대", "부모 세대", "나", "자녀 세대" … + String label(String focusName) => switch (gen) { + 0 => focusName, + -1 => '부모 세대', + -2 => '조부모 세대', + -3 => '증조부모 세대', + 1 => '자녀 세대', + 2 => '손주 세대', + _ => gen < 0 ? '${-gen}대 위' : '$gen대 아래', + }; +} + +/// [focus] 를 가운데 둔 가계도: 위에는 윗세대, 같은 줄 옆에 배우자, 아래에는 자식. +/// +/// 같은 줄에서는 아버지 쪽(친가)을 왼쪽, 가까운 가족을 가운데, 어머니 쪽(외가)과 배우자 쪽(처가·시가)을 오른쪽에 둔다. +/// 부부는 나란히 붙인다. [up]·[down] 세대 밖은 뺀다. +List familyTree(KinGraph g, String focus, {int up = 3, int down = 2}) { + if (!g.has(focus)) return const []; + // 세대 번호: 부모 -1, 자식 +1, 배우자 같은 세대. + final gen = {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 = >{}; + 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 = []; + 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 _pairCouples(KinGraph g, List cells) { + final out = []; + final used = {}; + 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; +} diff --git a/lib/modules/ui/src/screens/family_tree_screen.dart b/lib/modules/ui/src/screens/family_tree_screen.dart new file mode 100644 index 0000000..0e364bd --- /dev/null +++ b/lib/modules/ui/src/screens/family_tree_screen.dart @@ -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 createState() => _FamilyTreeScreenState(); +} + +class _FamilyTreeScreenState extends State { + String _focus = KinGraph.meId; + + Future _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 _tap(TreeNodeView n) async { + final me = n.id == KinGraph.meId; + final pick = await showModalBottomSheet( + 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( + 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 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 onTap; + + @override + Widget build(BuildContext context) { + // 부부는 한 테두리 안에 나란히. + final units = >[]; + 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('더하기')), + ], + ); + } +} diff --git a/lib/modules/ui/src/screens/group_detail_screen.dart b/lib/modules/ui/src/screens/group_detail_screen.dart index 95818be..d5f2e13 100644 --- a/lib/modules/ui/src/screens/group_detail_screen.dart +++ b/lib/modules/ui/src/screens/group_detail_screen.dart @@ -12,6 +12,7 @@ import '../widgets/group_menu.dart'; import '../widgets/pin_dialog.dart'; import 'chat_room_screen.dart'; import 'contact_import_screen.dart'; +import 'family_tree_screen.dart'; import 'person_screen.dart'; class _Data { @@ -116,6 +117,16 @@ class GroupDetailScreen extends StatelessWidget { final app = AppScope.of(context).app; final g = v.group; 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) SectionHeader('하위 그룹', trailing: v.canAddChild diff --git a/lib/modules/ui/src/screens/relations_screen.dart b/lib/modules/ui/src/screens/relations_screen.dart index 7c4ebd6..f57631b 100644 --- a/lib/modules/ui/src/screens/relations_screen.dart +++ b/lib/modules/ui/src/screens/relations_screen.dart @@ -10,7 +10,7 @@ import '../widgets/group_dialogs.dart'; import '../widgets/group_menu.dart'; import 'add_friend_screen.dart'; import 'contact_import_screen.dart'; -import 'gathering_screen.dart'; +import 'family_tree_screen.dart'; import 'group_detail_screen.dart'; import 'people_screen.dart'; import 'person_screen.dart'; @@ -104,7 +104,7 @@ class _RelationsScreenState extends State { padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), child: Row(children: [ _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.vibration, '흔들기', '명함 교환', () => pushPage(context, const ShakeScreen())), ]), diff --git a/test/family_tree_test.dart b/test/family_tree_test.dart new file mode 100644 index 0000000..4aee105 --- /dev/null +++ b/test/family_tree_test.dart @@ -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 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('나'), '나'); + }); +} diff --git a/test_ux/shots/01_chats.png b/test_ux/shots/01_chats.png index 1f6368a..f92738e 100644 Binary files a/test_ux/shots/01_chats.png and b/test_ux/shots/01_chats.png differ diff --git a/test_ux/shots/02_relations.png b/test_ux/shots/02_relations.png index 34cc0e4..949a95a 100644 Binary files a/test_ux/shots/02_relations.png and b/test_ux/shots/02_relations.png differ diff --git a/test_ux/shots/14_family_tree.png b/test_ux/shots/14_family_tree.png new file mode 100644 index 0000000..c7a18cb Binary files /dev/null and b/test_ux/shots/14_family_tree.png differ diff --git a/test_ux/shots/21_person.png b/test_ux/shots/21_person.png index d45ecf6..a038afe 100644 Binary files a/test_ux/shots/21_person.png and b/test_ux/shots/21_person.png differ diff --git a/test_ux/shots/22_relate.png b/test_ux/shots/22_relate.png index fac8b69..0817222 100644 Binary files a/test_ux/shots/22_relate.png and b/test_ux/shots/22_relate.png differ diff --git a/test_ux/shots/23_chat_room.png b/test_ux/shots/23_chat_room.png index 823fff7..7c0e002 100644 Binary files a/test_ux/shots/23_chat_room.png and b/test_ux/shots/23_chat_room.png differ diff --git a/test_ux/shots_test.dart b/test_ux/shots_test.dart index 2ef90ef..883b6a7 100644 --- a/test_ux/shots_test.dart +++ b/test_ux/shots_test.dart @@ -7,11 +7,13 @@ import 'package:flutter/services.dart'; 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/kinship/kinship.dart'; import 'package:friendly_map/modules/platform/platform.dart'; import 'package:friendly_map/modules/storage/storage.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/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/person_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.work.assign(room.id, '결제 API 정리', assigneeId: people['김민수'], due: DateTime(2026, 10, 30)); await app.work.urgent(room.id, '결제 서버가 멈췄어요'); + // 가족: 나(남) · 아내 · 아들 · 부모 · 할아버지 · 장인 + await app.kin.setMe(gender: Gender.male, birthYear: 1990); + Future 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', '김민수 새폰'); await again.app.friends.add(me.phone, '이미영'); @@ -138,6 +156,8 @@ void main() { await shot(tester, '12_group_company'); await push(tester, const SettingsScreen()); await shot(tester, '13_settings'); + await push(tester, const FamilyTreeScreen()); + await shot(tester, '14_family_tree'); }); testWidgets('주소록 · 사람 · 대화', (tester) async { diff --git a/tool/test_module.sh b/tool/test_module.sh index bdc61f9..a50e500 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_side_test.dart" + [kinship]="kinship_test.dart kinship_side_test.dart family_tree_test.dart" [groups]="groups_test.dart" [work]="work_test.dart" [events]="events_test.dart lunar_test.dart" diff --git a/wiki/00 최우선 규칙.md b/wiki/00 최우선 규칙.md index 98b63aa..94f3ced 100644 --- a/wiki/00 최우선 규칙.md +++ b/wiki/00 최우선 규칙.md @@ -22,3 +22,9 @@ - 새 기능은 (1) 순수 모듈 또는 서비스 + 시험 → (2) 화면. 화면을 바꾸면 캡처해서 [[06 화면 점검]]에 붙인다. - 끝낼 때 한글로: 무엇이 바뀌었나, 무엇을 돌려 확인했나, 확인 못 한 것은 무엇인가. 그리고 [[진행 로그/2026-10-26|진행 로그]] 갱신. - 저장소: 평소 Gitea 에 커밋·푸시, GitLab 은 하루 한 번 메이저 업그레이드 때만(원격 미등록). + +## 질문의 규칙 +- 모든 질문은 출력물을 가지고한다. +- 출력물은 개발의 경우 실행파일 등이 실행가능해야된 이를 실행하여 보여주거나 사용자에게 직접 실행햐서 보여줄수 있어야한다. +- 산출물일경우 PPT등을 보여주고 상황 설명을 하고 지시를 받아야 한다. + diff --git a/wiki/02 결정 기록.md b/wiki/02 결정 기록.md index 95b8153..e5cc19c 100644 --- a/wiki/02 결정 기록.md +++ b/wiki/02 결정 기록.md @@ -22,3 +22,4 @@ | 2026-10-26 | 새 폰에서 다시 시작 → 관계를 유지하는 사용자 각자가 **승인**(다시 연결) 또는 **제명**(번호 영구 차단) | 사용자 결정: 관리자 승인 | | 2026-10-26 | 아래 메뉴: 채팅(첫 화면) · 관계 · 업무 · 명함 · 나. 옛 홈 타일·중복 화면 삭제 | UX 점검 결과 | | 2026-10-26 | 질문은 APK·화면을 먼저 보여 준 뒤에만 | 사용자 최우선 지시 | +| 2026-10-26 | 가계도: 세대 줄 방식(위 조상·옆 배우자·아래 자식), 친가 왼쪽·외가/처가 오른쪽, 부부는 남자 왼쪽, 처가 족보 = 배우자 기준으로 다시 보기 | 사용자 요구(01 제품 개요) | diff --git a/wiki/03 로드맵.md b/wiki/03 로드맵.md index d7cbb87..ec3c325 100644 --- a/wiki/03 로드맵.md +++ b/wiki/03 로드맵.md @@ -10,6 +10,12 @@ - [ ] APK 전달 → 사용자 설치·시험 → 화면 보며 피드백 반영 - [ ] 실제 기기 점검(주소록, 알림, 위치, QR, 같은 네트워크) +## 2026-10-26 사용자 추가 요구 ([[01 제품 개요]]) +- [x] 5. 가계도 화면: 나 가운데, 위 윗세대(촌수), 옆 배우자, 아래 자식, 생일·알림, 친가/처가 족보, 자식 누르면 그 자식 기준으로 촌수 일괄 변경 +- [ ] 6. 학교 원형 화면: 나 가운데, 초·중·고·대 둘레, 가까운 사람(대화·통화 많은 순), 선배·후배(연차/연도), 반별/합쳐 보기 설정, 동문·동창 일괄 선택 → 긴급 단체 메시지 +- [ ] 7. 회사 원형 화면: 학교와 같은 방식(설정 가능), 프로젝트 전체·아래 직원 전체·윗사람 전체 메시지(직급 순서) +- [ ] 8. 이직 이력: 회사별 근무 기간(몇 년~몇 년), 그 사람을 누르면 내 기록 속 주변 사람 + ## 현재 상태 (2026-10-26) - podman Flutter(stable)에서 `flutter analyze` 0건, `flutter test` 313개 통과, 서버(Python) 18개 통과, check_modules.py 통과. - `flutter build apk --debug` 성공(약 190MB, 개편 화면). diff --git a/wiki/06 화면 점검.md b/wiki/06 화면 점검.md index c47fc91..69d4f7f 100644 --- a/wiki/06 화면 점검.md +++ b/wiki/06 화면 점검.md @@ -27,3 +27,11 @@ - 나: 설정 목록(공개 범위·요청 받기·막기·위치·잠금 시간·제명한 번호·알림·저장 공간) - 번호는 모든 곳에서 010-0000-0000 - 캡처 글꼴 문제: 테마 글자 모양에 글꼴 이름을 명시해 캡처=폰 화면 + +## 2026-10-26 가계도 (로드맵 5단계) +![[화면 점검/2026-10-26 가계도/14_family_tree.png|300]] +- 기준 사람 가운데, 위로 조부모·부모 세대, 같은 줄에 배우자(부부는 한 테두리, 남자 왼쪽), 아래로 자녀 세대 +- 색: 친가 파랑 · 가까운 가족 노랑 · 외가 분홍 · 처가·시가 초록 +- 위쪽 "OOO 쪽 족보"(배우자 기준 = 처가·시가 족보), "OOO 기준"(자녀 기준)으로 누르면 촌수·호칭이 한꺼번에 바뀜 +- 사람을 누르면: 그 사람 기준으로 보기 / 프로필·생일 적기 / 그 사람의 부모·배우자·자녀·형제자매 더하기 +- 생일이 있으면 "생일 11월 3일 · D-8" diff --git a/wiki/가이드/가족 · 가계도 · 생일.md b/wiki/가이드/가족 · 가계도 · 생일.md index 78d8ba3..973b947 100644 --- a/wiki/가이드/가족 · 가계도 · 생일.md +++ b/wiki/가이드/가족 · 가계도 · 생일.md @@ -1,6 +1,16 @@ # 가족 · 가계도 · 생일 -> 화면: 관계 탭 › 가계도, 프로필, 나 › 알림 · 저장 공간 +> 화면: 관계 탭 › 가계도(또는 가족 그룹 › 가계도로 보기), 프로필, 나 › 알림 · 저장 공간 + +## 가계도 화면 +- 기준 사람을 가운데 두고 위로 윗세대(조부모·부모), 같은 줄 옆에 배우자, 아래로 자녀·손주. +- 친가는 왼쪽(파랑), 외가는 오른쪽(분홍), 처가·시가는 오른쪽 끝(초록). 부부는 한 테두리 안에 남자가 왼쪽. +- 위쪽 버튼: "OOO 쪽 족보"(배우자 기준 = 처가·시가 족보), "OOO 기준"(자녀 기준). 누르면 모든 호칭·촌수가 그 사람 눈으로 바뀐다. "나로 돌아가기". +- 사람을 누르면 그 사람 기준으로 보기, 프로필·생일 적기, 그 사람의 부모·배우자·자녀·형제자매 더하기. +- 생일이 적혀 있으면 칸 아래에 "생일 11월 3일 · D-8". 알림은 나 › 알림 · 저장 공간에서 며칠 전에 울릴지 정한다. +- 오른쪽 위 목록 버튼은 예전 목록 화면(가족 모임). + +## 목록·알림 - 관계 탭 › 가계도에서 가족을 한 분씩 추가한다. 번호가 없는 친척도 넣을 수 있다(이 기기 안에서만 쓰이고 친구 요청은 가지 않는다). - "나의 부모/자녀/배우자"를 고르거나 부모·자녀·배우자를 이어 가계도를 만들면, 나와 내 자녀 각각의 눈으로 본 호칭과 촌수가 나온다(예: 내게는 친사촌 형 4촌, 내 아들에게는 당숙 5촌). diff --git a/wiki/진행 로그/2026-10-26.md b/wiki/진행 로그/2026-10-26.md index 336fa41..41b77ca 100644 --- a/wiki/진행 로그/2026-10-26.md +++ b/wiki/진행 로그/2026-10-26.md @@ -14,3 +14,5 @@ - 20:38 개편 APK 빌드 성공 (app-debug.apk 약 190MB, 커밋 e7982c8) → 사용자 설치·시험 대기 - README 를 wiki 로 정리: 기능 안내를 화면 지도+기능별 노트 10개로 나누고, 모듈 표는 04, 시험·준비는 05, 원칙은 00 으로 - README·CLAUDE.md·server/README 의 내용을 wiki 로 모두 옮기고, 세 파일에는 "무조건 wiki 참조" 지시만 남김 +- wiki 를 읽고 사용자 추가 요구 4가지(가계도, 학교 원형, 회사 원형·직급 메시지, 이직 이력)와 용어 규칙(낯선 외래어·한자어 금지)을 확인 → [[03 로드맵]] 5~8단계로 등록 +- 5단계 가계도 화면: kinship/tree.dart(세대 줄 계산), FamilyTreeService, FamilyTreeScreen. 시험 317개 통과 → [[06 화면 점검]] diff --git a/wiki/화면 점검/2026-10-26 가계도/14_family_tree.png b/wiki/화면 점검/2026-10-26 가계도/14_family_tree.png new file mode 100644 index 0000000..c7a18cb Binary files /dev/null and b/wiki/화면 점검/2026-10-26 가계도/14_family_tree.png differ