가계도 화면: 나 가운데, 위 윗세대·옆 배우자·아래 자식, 친가/외가/처가 색, 기준 바꾸기
- kinship: familyTree (새 파일) — 세대 번호, 쪽 순서(친가 왼쪽·외가/처가 오른쪽), 부부 나란히(남자 왼쪽) - app: FamilyTreeService — 이름·호칭·촌수·다가오는 생일 - ui: FamilyTreeScreen — 배우자 쪽 족보·자녀 기준 보기, 사람 눌러 기준 바꾸기·가족 더하기, 관계 탭·가족 그룹에서 열기 - 시험 4개 추가 (전체 317개) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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))],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = {};
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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('더하기')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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())),
|
||||||
]),
|
]),
|
||||||
|
|
|
||||||
|
|
@ -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('나'), '나');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 102 KiB |
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -22,3 +22,9 @@
|
||||||
- 새 기능은 (1) 순수 모듈 또는 서비스 + 시험 → (2) 화면. 화면을 바꾸면 캡처해서 [[06 화면 점검]]에 붙인다.
|
- 새 기능은 (1) 순수 모듈 또는 서비스 + 시험 → (2) 화면. 화면을 바꾸면 캡처해서 [[06 화면 점검]]에 붙인다.
|
||||||
- 끝낼 때 한글로: 무엇이 바뀌었나, 무엇을 돌려 확인했나, 확인 못 한 것은 무엇인가. 그리고 [[진행 로그/2026-10-26|진행 로그]] 갱신.
|
- 끝낼 때 한글로: 무엇이 바뀌었나, 무엇을 돌려 확인했나, 확인 못 한 것은 무엇인가. 그리고 [[진행 로그/2026-10-26|진행 로그]] 갱신.
|
||||||
- 저장소: 평소 Gitea 에 커밋·푸시, GitLab 은 하루 한 번 메이저 업그레이드 때만(원격 미등록).
|
- 저장소: 평소 Gitea 에 커밋·푸시, GitLab 은 하루 한 번 메이저 업그레이드 때만(원격 미등록).
|
||||||
|
|
||||||
|
## 질문의 규칙
|
||||||
|
- 모든 질문은 출력물을 가지고한다.
|
||||||
|
- 출력물은 개발의 경우 실행파일 등이 실행가능해야된 이를 실행하여 보여주거나 사용자에게 직접 실행햐서 보여줄수 있어야한다.
|
||||||
|
- 산출물일경우 PPT등을 보여주고 상황 설명을 하고 지시를 받아야 한다.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 제품 개요) |
|
||||||
|
|
|
||||||
|
|
@ -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, 개편 화면).
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
# 가족 · 가계도 · 생일
|
# 가족 · 가계도 · 생일
|
||||||
|
|
||||||
> 화면: 관계 탭 › 가계도, 프로필, 나 › 알림 · 저장 공간
|
> 화면: 관계 탭 › 가계도(또는 가족 그룹 › 가계도로 보기), 프로필, 나 › 알림 · 저장 공간
|
||||||
|
|
||||||
|
## 가계도 화면
|
||||||
|
- 기준 사람을 가운데 두고 위로 윗세대(조부모·부모), 같은 줄 옆에 배우자, 아래로 자녀·손주.
|
||||||
|
- 친가는 왼쪽(파랑), 외가는 오른쪽(분홍), 처가·시가는 오른쪽 끝(초록). 부부는 한 테두리 안에 남자가 왼쪽.
|
||||||
|
- 위쪽 버튼: "OOO 쪽 족보"(배우자 기준 = 처가·시가 족보), "OOO 기준"(자녀 기준). 누르면 모든 호칭·촌수가 그 사람 눈으로 바뀐다. "나로 돌아가기".
|
||||||
|
- 사람을 누르면 그 사람 기준으로 보기, 프로필·생일 적기, 그 사람의 부모·배우자·자녀·형제자매 더하기.
|
||||||
|
- 생일이 적혀 있으면 칸 아래에 "생일 11월 3일 · D-8". 알림은 나 › 알림 · 저장 공간에서 며칠 전에 울릴지 정한다.
|
||||||
|
- 오른쪽 위 목록 버튼은 예전 목록 화면(가족 모임).
|
||||||
|
|
||||||
|
## 목록·알림
|
||||||
|
|
||||||
- 관계 탭 › 가계도에서 가족을 한 분씩 추가한다. 번호가 없는 친척도 넣을 수 있다(이 기기 안에서만 쓰이고 친구 요청은 가지 않는다).
|
- 관계 탭 › 가계도에서 가족을 한 분씩 추가한다. 번호가 없는 친척도 넣을 수 있다(이 기기 안에서만 쓰이고 친구 요청은 가지 않는다).
|
||||||
- "나의 부모/자녀/배우자"를 고르거나 부모·자녀·배우자를 이어 가계도를 만들면, 나와 내 자녀 각각의 눈으로 본 호칭과 촌수가 나온다(예: 내게는 친사촌 형 4촌, 내 아들에게는 당숙 5촌).
|
- "나의 부모/자녀/배우자"를 고르거나 부모·자녀·배우자를 이어 가계도를 만들면, 나와 내 자녀 각각의 눈으로 본 호칭과 촌수가 나온다(예: 내게는 친사촌 형 4촌, 내 아들에게는 당숙 5촌).
|
||||||
|
|
|
||||||
|
|
@ -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 화면 점검]]
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 152 KiB |