frindlymap/lib/modules/ui/src/screens/family_tree_screen.dart

256 lines
11 KiB
Dart

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