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('더하기')), ], ); } }