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