학교·회사 원형 보기와 단체 메시지 (6·7단계)

- 화면: 가운데에 나·둘레에 초중고대(회사들), 칸을 누르면 가까운 순 원형, 목록은 연차·직급별, 선배·동기·후배·윗사람·같은 급·아래 직원 거르기, 반·팀별 나눠 보기 설정
- 고르기 → 메시지 / 긴급 호출 (윗사람·아래 직원 전체 버튼)
- 사람이 많아 원이 화면보다 커지면 통째로 줄여 맞춤
- 관계 탭의 학교·회사 그룹은 원형 화면으로 열림

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
main
diadmin 2026-10-26 21:47:00 +09:00
parent cdfb60d745
commit 1dcf1dcdb7
33 changed files with 582 additions and 4 deletions

View File

@ -10,7 +10,7 @@ String cohortLabel(int? diff) {
}
/// 연차별 묶음 순서: 선배(먼 쪽부터) → 동기 → 후배 → 연도 모름.
int cohortOrder(int? diff) => diff == null ? 1 << 20 : diff;
int cohortOrder(int? diff) => diff ?? 1 << 20;
/// 글자에서 연도를 읽는다 ("2009", "09학번", "27회" 는 회차라 못 읽음). 못 읽으면 null.
int? parseYear(String? text) {

View File

@ -0,0 +1,282 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/circles/circles.dart';
import 'package:friendly_map/modules/core/core.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
import '../widgets/pin_dialog.dart';
import '../widgets/ring_view.dart';
import '../widgets/work_dialogs.dart';
import 'group_detail_screen.dart';
import 'person_screen.dart';
class _Data {
const _Data(this.view, this.people, this.me, this.split);
final GroupView? view;
final List<CirclePerson> people;
final String me;
final bool split; // 반·팀별로 나눠 보기
}
enum _Mode { ring, list }
/// 한 칸(초등학교, 한빛전자 …)의 사람들: 가까운 순으로 원 위에 펼치거나, 연차·직급·반별 목록으로 본다.
/// 사람을 골라 단체 메시지나 긴급 호출을 보낼 수 있다. 보는 방식은 설정으로 바꾼다.
class CircleGroupScreen extends StatefulWidget {
const CircleGroupScreen({super.key, required this.groupId});
final String groupId;
@override
State<CircleGroupScreen> createState() => _CircleGroupScreenState();
}
class _CircleGroupScreenState extends State<CircleGroupScreen> {
_Mode _mode = _Mode.ring;
String _filter = 'all'; // all | senior·same·junior (학교) | above·same·below (회사)
bool _selecting = false;
final Set<String> _picked = {};
Future<_Data> _load(FriendlyApp a) async {
final v = await a.groups.view(widget.groupId);
final kind = v?.group.kind ?? CardKinds.group;
return _Data(v, await a.circles.members(widget.groupId), (await a.profile.me())?.name ?? '나', await a.circles.splitView(kind));
}
bool _match(CirclePerson p, bool school) {
if (_filter == 'all') return true;
if (school) {
final d = p.cohortDiff;
return switch (_filter) { 'senior' => d != null && d < 0, 'same' => d == 0, 'junior' => d != null && d > 0, _ => true };
}
return switch (_filter) {
'above' => p.rankRelation == RankRelation.above,
'same' => p.rankRelation == RankRelation.same,
'below' => p.rankRelation == RankRelation.below,
_ => true,
};
}
void _toggle(String id) => setState(() => _picked.contains(id) ? _picked.remove(id) : _picked.add(id));
void _tap(CirclePerson p) {
if (_selecting) {
_toggle(p.person.id);
} else {
pushPage(context, PersonScreen(personId: p.person.id));
}
}
Future<void> _send({required bool urgent}) async {
final app = AppScope.of(context).app;
final text = urgent ? await askUrgent(context) : await _askText();
if (text == null || !mounted) return;
final n = await app.circles.broadcast(_picked, text, urgent: urgent);
if (!mounted) return;
showNote(context, n == 0 ? '보내지 못했어요 (번호가 없는 사람은 제외돼요)' : '$n명에게 ${urgent ? '긴급 호출을' : '메시지를'} 보냈어요');
setState(() {
_picked.clear();
_selecting = false;
});
}
Future<String?> _askText() async {
final c = TextEditingController();
final r = await showDialog<String>(
context: context,
builder: (d) => AlertDialog(
title: Text('${_picked.length}명에게 보내기'),
content: TextField(controller: c, autofocus: true, minLines: 2, maxLines: 5, decoration: const InputDecoration(hintText: '예: 동창회 이번 토요일 7시예요')),
actions: [
TextButton(onPressed: () => Navigator.pop(d), child: const Text('취소')),
TextButton(onPressed: () => Navigator.pop(d, c.text), child: const Text('보내기')),
],
),
);
c.dispose();
return (r == null || r.trim().isEmpty) ? null : r;
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context).app;
return AsyncView<_Data>(
load: _load,
builder: (context, d) {
final v = d.view;
if (v == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('지워진 그룹이에요.'));
final kind = v.group.kind;
final school = kind == CardKinds.school;
final company = kind == CardKinds.company;
final color = Pal.ofKind(kind);
if (!v.open) {
return Scaffold(
appBar: AppBar(title: Text(v.group.name)),
body: Center(child: FilledButton(onPressed: () => openGroup(context, v), child: const Text('비밀번호로 열기'))),
);
}
final shown = [for (final p in d.people) if (_match(p, school)) p];
return Scaffold(
appBar: AppBar(
title: Text(v.path.replaceAll(' › ', ' · ')),
actions: [
IconButton(tooltip: '목록으로 관리', onPressed: () => pushPage(context, GroupDetailScreen(groupId: widget.groupId)), icon: const Icon(Icons.tune)),
],
),
body: Column(children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: Row(children: [
Expanded(
child: SegmentedButton<_Mode>(
showSelectedIcon: false,
segments: const [
ButtonSegment(value: _Mode.ring, icon: Icon(Icons.radar, size: 18), label: Text('원형')),
ButtonSegment(value: _Mode.list, icon: Icon(Icons.view_list, size: 18), label: Text('목록')),
],
selected: {_mode},
onSelectionChanged: (s) => setState(() => _mode = s.first),
),
),
const SizedBox(width: 8),
FilterChip(
label: Text(_selecting ? '고르는 중 ${_picked.length}' : '고르기'),
selected: _selecting,
onSelected: (on) => setState(() {
_selecting = on;
if (!on) _picked.clear();
}),
),
]),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: SizedBox(width: double.infinity, child: Wrap(spacing: 6, runSpacing: 6, children: [
for (final (k, t) in school
? const [('all', '전체'), ('senior', '선배'), ('same', '동기'), ('junior', '후배')]
: company
? const [('all', '전체'), ('above', '윗사람'), ('same', '같은 급'), ('below', '아래 직원')]
: const [('all', '전체')])
ChoiceChip(label: Text(t), selected: _filter == k, onSelected: (_) => setState(() => _filter = k)),
FilterChip(
label: Text(school ? '반별로 나눠 보기' : '팀별로 나눠 보기'),
selected: d.split,
onSelected: (v2) => app.circles.setSplitView(kind, v2),
),
])),
),
if (_selecting)
Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 2),
child: Row(children: [
TextButton(onPressed: () => setState(() => _picked.addAll(shown.map((p) => p.person.id))), child: Text('보이는 ${shown.length}명 모두')),
if (company) ...[
TextButton(onPressed: () => setState(() => _picked.addAll(d.people.where((p) => p.rankRelation == RankRelation.above).map((p) => p.person.id))), child: const Text('윗사람 전체')),
TextButton(onPressed: () => setState(() => _picked.addAll(d.people.where((p) => p.rankRelation == RankRelation.below).map((p) => p.person.id))), child: const Text('아래 직원 전체')),
],
const Spacer(),
if (_picked.isNotEmpty) TextButton(onPressed: () => setState(_picked.clear), child: const Text('풀기')),
]),
),
Expanded(
child: shown.isEmpty
? EmptyNote(d.people.isEmpty ? '아직 사람이 없어요.\n목록으로 관리에서 사람을 넣어 보세요.' : '맞는 사람이 없어요.')
: _mode == _Mode.ring
? ListView(children: [_ring(shown, d, color, school), const SizedBox(height: 24)])
: _list(shown, d, color, school, company),
),
if (_picked.isNotEmpty)
Container(
color: Pal.surface,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(children: [
Expanded(child: OutlinedButton.icon(onPressed: () => _send(urgent: false), icon: const Icon(Icons.send, size: 18), label: Text('메시지 ${_picked.length}명'))),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: Pal.urgent, foregroundColor: Colors.white),
onPressed: () => _send(urgent: true),
icon: const Icon(Icons.campaign, size: 18),
label: const Text('긴급 호출'),
),
),
]),
),
),
),
]),
);
},
);
}
String _sub(CirclePerson p, bool school) => school ? (p.year != null ? p.cohort : '') : (p.position ?? '');
Widget _ring(List<CirclePerson> shown, _Data d, Color color, bool school) {
final ordered = rankByCloseness([for (final p in shown) Closeness(p.person.id, messages: p.score)]);
final byId = {for (final p in shown) p.person.id: p};
final rs = rings([for (final c in ordered) byId[c.id]!]);
return RingView(
center: MeDot(d.me),
rings: [
for (final r in rs)
[
for (final p in r)
RingDot(
label: p.person.name,
sub: _sub(p, school),
color: color,
selected: _picked.contains(p.person.id),
onTap: () => _tap(p),
),
],
],
);
}
Widget _list(List<CirclePerson> shown, _Data d, Color color, bool school, bool company) {
// 묶는 기준: 나눠 보기면 반·팀, 아니면 학교는 연차, 회사는 직급 관계.
String key(CirclePerson p) {
if (d.split) return p.subgroup ?? '기타';
if (school) return p.cohortDiff == null ? '연도 모름' : p.cohort;
return switch (p.rankRelation) { RankRelation.above => '윗사람', RankRelation.same => '같은 급', RankRelation.below => '아래 직원', RankRelation.unknown => '직급 모름' };
}
final groups = <String, List<CirclePerson>>{};
for (final p in shown) {
groups.putIfAbsent(key(p), () => []).add(p);
}
final keys = groups.keys.toList();
if (!d.split && school) {
keys.sort((a, b) => cohortOrder(groups[a]!.first.cohortDiff).compareTo(cohortOrder(groups[b]!.first.cohortDiff)));
} else if (!d.split && company) {
const order = ['윗사람', '같은 급', '아래 직원', '직급 모름'];
keys.sort((a, b) => order.indexOf(a).compareTo(order.indexOf(b)));
}
return ListView(padding: const EdgeInsets.only(bottom: 24), children: [
for (final k in keys) ...[
SectionHeader('$k ${groups[k]!.length}명'),
for (final p in groups[k]!)
ListTile(
leading: _selecting
? Checkbox(value: _picked.contains(p.person.id), onChanged: (_) => _toggle(p.person.id))
: Avatar(p.person.name, size: 40, color: color),
title: Text(p.person.name),
subtitle: Text([
if (company && p.position != null) p.position!,
if (school && d.split && p.year != null) p.cohort,
if (!d.split && p.subgroup != null) p.subgroup!,
].join(' · ')),
trailing: p.score > 0 ? Text('${p.score}', style: const TextStyle(color: Pal.accent, fontWeight: FontWeight.w700)) : null,
onTap: () => _tap(p),
),
],
]);
}
}

View File

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/core/core.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
import '../widgets/pin_dialog.dart';
import '../widgets/ring_view.dart';
import 'circle_group_screen.dart';
import 'group_detail_screen.dart';
import 'person_screen.dart';
class _Data {
const _Data(this.hub, this.me);
final CircleHub hub;
final String me;
}
/// 학교·회사 원형 보기: 나를 가운데 두고 둘레에 초·중·고·대(또는 회사들)를 놓고,
/// 그 옆에 대화·통화가 많은 가까운 사람을 보여 준다. 칸을 누르면 그 칸 사람들이 원 모양으로 펼쳐진다.
class CircleHubScreen extends StatelessWidget {
const CircleHubScreen({super.key, required this.rootId});
final String rootId;
@override
Widget build(BuildContext context) {
return AsyncView<_Data>(
load: (a) async => _Data(await a.circles.hub(rootId), (await a.profile.me())?.name ?? '나'),
builder: (context, d) {
final root = d.hub.root;
if (root == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('지워진 그룹이에요.'));
final kind = root.group.kind;
final color = Pal.ofKind(kind);
if (!root.open) {
return Scaffold(
appBar: AppBar(title: Text(root.group.name)),
body: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.lock, size: 48, color: Pal.muted),
const SizedBox(height: 12),
FilledButton(onPressed: () => openGroup(context, root), child: const Text('비밀번호로 열기')),
]),
),
);
}
final levels = d.hub.levels;
return Scaffold(
appBar: AppBar(
title: Text(root.group.name),
actions: [IconButton(tooltip: '목록으로 보기', onPressed: () => pushPage(context, GroupDetailScreen(groupId: rootId)), icon: const Icon(Icons.list))],
),
body: ListView(padding: const EdgeInsets.only(bottom: 32), children: [
if (levels.isEmpty)
const EmptyNote('아래에 칸이 없어요.\n목록으로 보기에서 하위 그룹을 만들어 보세요.')
else
RingView(
center: MeDot(d.me),
rings: [
[
for (final l in levels)
RingDot(
label: l.view.group.name,
sub: '${l.count}명',
color: l.count == 0 ? Pal.muted : color,
faded: l.count == 0,
onTap: () => pushPage(context, CircleGroupScreen(groupId: l.view.group.id)),
),
],
],
),
const SectionHeader('가까운 사람'),
if (d.hub.closest.isEmpty) const EmptyNote('대화나 통화가 쌓이면 가까운 사람이 여기 보여요.'),
for (final c in d.hub.closest)
ListTile(
leading: Avatar(c.person.name, size: 40, color: color),
title: Text(c.person.name),
subtitle: Text([
if (c.subgroup != null) c.subgroup!,
if (kind == CardKinds.school && c.cohortDiff != null) c.cohort,
if (kind == CardKinds.company && c.position != null) c.position!,
].join(' · ')),
trailing: c.score > 0 ? Text('${c.score}', style: const TextStyle(color: Pal.accent, fontWeight: FontWeight.w700)) : null,
onTap: () => pushPage(context, PersonScreen(personId: c.person.id)),
),
]),
);
},
);
}
}

View File

@ -11,6 +11,8 @@ import '../widgets/group_dialogs.dart';
import '../widgets/group_menu.dart';
import '../widgets/pin_dialog.dart';
import 'chat_room_screen.dart';
import 'circle_group_screen.dart';
import 'circle_hub_screen.dart';
import 'contact_import_screen.dart';
import 'family_tree_screen.dart';
import 'person_screen.dart';
@ -117,6 +119,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.school || g.kind == CardKinds.company)
Panel(
onTap: () => pushPage(context, v.depth == 1 ? CircleHubScreen(rootId: g.id) : CircleGroupScreen(groupId: g.id)),
child: const Row(children: [
Icon(Icons.radar, color: Pal.accent),
SizedBox(width: 12),
Expanded(child: Text('원형으로 보기 — 가까운 사람·선후배·단체 메시지', style: TextStyle(fontWeight: FontWeight.w600))),
Icon(Icons.chevron_right, color: Pal.muted),
]),
),
if (g.kind == CardKinds.family)
Panel(
onTap: () => pushPage(context, const FamilyTreeScreen()),

View File

@ -8,7 +8,9 @@ import '../widgets/async_view.dart';
import '../widgets/common.dart';
import '../widgets/group_dialogs.dart';
import '../widgets/group_menu.dart';
import 'package:friendly_map/modules/core/core.dart';
import 'add_friend_screen.dart';
import 'circle_hub_screen.dart';
import 'contact_import_screen.dart';
import 'family_tree_screen.dart';
import 'group_detail_screen.dart';
@ -110,7 +112,7 @@ class _RelationsScreenState extends State<RelationsScreen> {
]),
),
SectionHeader('그룹', trailing: TextButton.icon(onPressed: _addGroup, icon: const Icon(Icons.add, size: 18), label: const Text('새 그룹'))),
for (final v in roots) GroupTile(v, children: kids(v), onOpen: () => pushPage(context, GroupDetailScreen(groupId: v.group.id))),
for (final v in roots) GroupTile(v, children: kids(v), onOpen: () => pushPage(context, _opens(v))),
if (d.hidden > 0)
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 0),
@ -138,6 +140,10 @@ class _RelationsScreenState extends State<RelationsScreen> {
}
}
/// 학교·회사는 원형 보기로, 나머지는 목록 화면으로.
Widget _opens(GroupView v) =>
(v.group.kind == CardKinds.school || v.group.kind == CardKinds.company) ? CircleHubScreen(rootId: v.group.id) : GroupDetailScreen(groupId: v.group.id);
class _Quick extends StatelessWidget {
const _Quick(this.icon, this.title, this.sub, this.onTap);
final IconData icon;

View File

@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/circles/circles.dart';
import '../theme.dart';
import 'common.dart';
/// 원 위의 한 점: 이름표와 색, 눌렀을 때 할 일.
class RingDot {
const RingDot({required this.label, required this.color, this.sub, this.selected = false, this.faded = false, this.onTap});
final String label;
final String? sub;
final Color color;
final bool selected;
final bool faded;
final VoidCallback? onTap;
}
/// 가운데에 [center] 를 두고, [rings] 의 고리마다 안쪽에서 바깥쪽 원 위에 점을 고르게 놓는다.
/// 고리 하나가 6명을 넘으면 바깥 고리가 하나 더 생긴다 (rings() 로 미리 나눠서 넘긴다).
class RingView extends StatelessWidget {
const RingView({super.key, required this.center, required this.rings, this.centerLabel = '나', this.dotSize = 44});
final Widget center;
final List<List<RingDot>> rings;
final String centerLabel;
final double dotSize;
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, box) {
final w = box.maxWidth;
// 첫 고리는 가운데 "나"와 이름표가 겹치지 않을 만큼 떨어뜨리고, 고리 사이는 점 + 이름표 두 줄 높이만큼 둔다.
final first = dotSize + 52;
final gap = dotSize + 52;
final radius = [for (var i = 0; i < rings.length; i++) first + gap * i];
final reach = (radius.isEmpty ? first : radius.last) + 46;
final canvasW = reach * 2 > w ? reach * 2 : w;
final h = reach * 2 + 16;
// 사람이 많아 원이 화면보다 커지면 통째로 줄여서 화면 안에 맞춘다.
final scale = canvasW > w ? w / canvasW : 1.0;
final cx = canvasW / 2, cy = h / 2;
return SizedBox(
width: w,
height: h * scale,
child: FittedBox(
fit: BoxFit.scaleDown,
child: SizedBox(
width: canvasW,
height: h,
child: Stack(children: [
for (var i = 0; i < rings.length; i++)
Positioned(
left: cx - radius[i],
top: cy - radius[i],
child: Container(width: radius[i] * 2, height: radius[i] * 2, decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: Pal.line))),
),
Positioned(left: cx - 30, top: cy - 30, child: SizedBox(width: 60, height: 60, child: center)),
for (var i = 0; i < rings.length; i++)
for (final e in _spots(rings[i], radius[i]).indexed)
Positioned(
left: cx + e.$2.$1 - 42,
top: cy + e.$2.$2 - dotSize / 2,
child: _Dot(rings[i][e.$1], dotSize),
),
]),
),
),
);
});
}
List<(double, double)> _spots(List<RingDot> ring, double r) => ringPositions(ring.length, r);
}
class _Dot extends StatelessWidget {
const _Dot(this.d, this.size);
final RingDot d;
final double size;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: d.onTap,
child: SizedBox(
width: 84,
child: Column(mainAxisSize: MainAxisSize.min, children: [
Opacity(
opacity: d.faded ? 0.4 : 1,
child: Stack(clipBehavior: Clip.none, children: [
Avatar(d.label, size: size, color: d.color),
if (d.selected)
Positioned(
right: -4,
top: -4,
child: Container(
decoration: const BoxDecoration(color: Pal.accent, shape: BoxShape.circle),
padding: const EdgeInsets.all(2),
child: const Icon(Icons.check, size: 14, color: Colors.black),
),
),
]),
),
const SizedBox(height: 3),
Text(d.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
if (d.sub != null) Text(d.sub!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 10, color: Pal.muted)),
]),
),
);
}
}
/// 가운데 "나" 동그라미.
class MeDot extends StatelessWidget {
const MeDot(this.name, {super.key});
final String name;
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(shape: BoxShape.circle, color: Pal.accent.withValues(alpha: 0.2), border: Border.all(color: Pal.accent, width: 2)),
alignment: Alignment.center,
child: Text(name.isEmpty ? '나' : name.characters.first, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800)),
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -13,6 +13,8 @@ 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/circle_group_screen.dart';
import 'package:friendly_map/modules/ui/src/screens/circle_hub_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';
@ -91,6 +93,35 @@ void main() {
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 mineCards = await app.cards.mine();
await app.cards.edit(mineCards.firstWhere((c) => c.kind == CardKinds.school).id, fields: {'name': '이미영', 'school': '가람고', 'year': '2006'});
final cls = (await app.groups.add('27회 3반', CardKinds.school, parent: 'school.high'))!;
var seq = 0;
for (final (nm, yr) in [('정수빈', 2004), ('한지민', 2005), ('오세훈', 2006), ('윤아름', 2006), ('강동원', 2007), ('송혜교', 2007), ('유재석', 2008), ('조인성', 2003)]) {
final p = (await app.friends.add('+82107000${(1000 + seq++)}', nm))!;
await app.deps.storage.receivedCards.put(ReceivedCard(id: 'sc$seq', personId: p.id, kind: CardKinds.school, fieldsJson: encodeFields({'year': '$yr'}), receivedAt: 1, updatedAt: 1));
await app.groups.addMembers('school.high', [p.id]);
if (yr == 2006) await app.groups.addMembers(cls.id, [p.id]);
final room = (await app.chats.openDirect(p.id, CardKinds.personal))!;
for (var i = 0; i < (seq * 2) % 7; i++) {
await app.chats.send(room.id, '안녕 $i');
}
}
// 회사: 한빛전자 직원들 (직책·대화)
await app.cards.edit(mineCards.firstWhere((c) => c.kind == CardKinds.company).id, fields: {'name': '이미영', 'company': '한빛전자', 'position': '과장'});
final all = await app.groups.all();
final hb2 = all.firstWhere((v) => v.group.name == '한빛전자').group;
final pay = all.firstWhere((v) => v.group.name == '결제팀').group;
for (final (nm, pos, g2) in [('김부장', '부장', hb2.id), ('박차장', '차장', pay.id), ('최과장', '과장', pay.id), ('정대리', '대리', pay.id), ('한사원', '사원', pay.id), ('오사원', '사원', hb2.id)]) {
final p = (await app.friends.add('+82108000${(1000 + seq++)}', nm))!;
await app.deps.storage.receivedCards.put(ReceivedCard(id: 'cc$seq', personId: p.id, kind: CardKinds.company, fieldsJson: encodeFields({'position': pos}), receivedAt: 1, updatedAt: 1));
await app.groups.addMembers(g2, [p.id]);
final room = (await app.chats.openDirect(p.id, CardKinds.personal))!;
for (var i = 0; i < (seq * 3) % 6; i++) {
await app.chats.send(room.id, '안녕 $i');
}
}
// 김민수가 새 폰으로 다시 연결을 청함
final again = await AppDevice.create(hub, '010-3333-4444', '+821033334444', '김민수 새폰');
await again.app.friends.add(me.phone, '이미영');
@ -158,6 +189,24 @@ void main() {
await shot(tester, '13_settings');
await push(tester, const FamilyTreeScreen());
await shot(tester, '14_family_tree');
await push(tester, const CircleHubScreen(rootId: CardKinds.school));
await shot(tester, '15_school_hub');
await push(tester, const CircleGroupScreen(groupId: 'school.high'));
await shot(tester, '16_school_ring');
await tester.tap(find.text('목록'));
await tester.pumpAndSettle();
await shot(tester, '17_school_list');
await tester.tap(find.text('고르기'));
await tester.pumpAndSettle();
await tester.tap(find.textContaining('모두'));
await tester.pumpAndSettle();
await shot(tester, '18_school_pick');
await push(tester, const CircleHubScreen(rootId: CardKinds.company));
await shot(tester, '19_company_hub');
await push(tester, const CircleGroupScreen(groupId: 'g1'));
await tester.tap(find.text('목록'));
await tester.pumpAndSettle();
await shot(tester, '19b_company_list');
});
testWidgets('주소록 · 사람 · 대화', (tester) async {

View File

@ -1,6 +1,7 @@
# 최우선 규칙 (모든 규칙보다 위)
- **사용 모델: Claude Sonnet 5.5(`claude-sonnet-5-5`)만 쓴다.** 사용자가 따로 지시하지 않는 한 다른 모델(Opus·Haiku 등)로 바꾸거나 하위 작업에 쓰지 않는다. (2026-10-26 사용자 지시)
지시한 모델이 다른경 우 사용자에게 모델 변경 팝업/메시지가 나타나고 이후 선택된 모델로 진행
- **0순위: 모든 가이드·문서·결정·진행 기록·화면 점검은 이 wiki 보관함에 저장·관리한다.** 세션 시작 때 홈과 이 문서부터 읽고, 일할 때마다 진행 로그·결정 기록을 갱신한다.
- **질문은 설치 가능한 APK 를 먼저 건넨 뒤에만.** 사용자가 폰에 설치해 써 보며 답할 수 있게. 캡처도 함께 보여 주고 "이렇게 하면 / 저렇게 하면" 화면 비교로만 묻는다.

View File

@ -24,3 +24,4 @@
| 2026-10-26 | 질문은 APK·화면을 먼저 보여 준 뒤에만 | 사용자 최우선 지시 |
| 2026-10-26 | 가계도: 세대 줄 방식(위 조상·옆 배우자·아래 자식), 친가 왼쪽·외가/처가 오른쪽, 부부는 남자 왼쪽, 처가 족보 = 배우자 기준으로 다시 보기 | 사용자 요구(01 제품 개요) |
| 2026-10-26 | 사용 모델은 Sonnet 5.5 만(특별한 지시가 없는 한) | 사용자 지시 |
| 2026-10-26 | 가까움 점수 = 대화 수 + 통화 수 × 3. 선배·후배 = 학교 명함 연도 차이, 윗사람·아래 직원 = 직책 글자의 직급 순서(사원<대리<과장<차장<부장<…). 단체 메시지는 각자에게 1:1 | 사용자 요구(01 제품 개요), 새 통신 형식 없이 기존 대화로 |

View File

@ -12,8 +12,8 @@
## 2026-10-26 사용자 추가 요구 ([[01 제품 개요]])
- [x] 5. 가계도 화면: 나 가운데, 위 윗세대(촌수), 옆 배우자, 아래 자식, 생일·알림, 친가/처가 족보, 자식 누르면 그 자식 기준으로 촌수 일괄 변경
- [ ] 6. 학교 원형 화면: 나 가운데, 초·중·고·대 둘레, 가까운 사람(대화·통화 많은 순), 선배·후배(연차/연도), 반별/합쳐 보기 설정, 동문·동창 일괄 선택 → 긴급 단체 메시지
- [ ] 7. 회사 원형 화면: 학교와 같은 방식(설정 가능), 프로젝트 전체·아래 직원 전체·윗사람 전체 메시지(직급 순서)
- [x] 6. 학교 원형 화면: 나 가운데, 초·중·고·대 둘레, 가까운 사람(대화·통화 많은 순), 선배·후배(연차/연도), 반별/합쳐 보기 설정, 동문·동창 일괄 선택 → 긴급 단체 메시지
- [x] 7. 회사 원형 화면: 학교와 같은 방식(설정 가능), 프로젝트 전체·아래 직원 전체·윗사람 전체 메시지(직급 순서)
- [ ] 8. 이직 이력: 회사별 근무 기간(몇 년~몇 년), 그 사람을 누르면 내 기록 속 주변 사람
## 현재 상태 (2026-10-26)

View File

@ -25,6 +25,7 @@
| events | 생일·기일 일정, 알림 날짜 계획, 통화 기록 요약(순수) | 없음 |
| groups | 그룹 정의(이름·종류·순서·구성원·하위 그룹), 비밀번호 잠금, 여러 번 틀리면 기다리기(순수) | core |
| work | 대화 속 업무 표시(📌 업무·🚨 긴급 호출) 읽고 쓰기, 맡은 업무 목록(순수) | 없음 |
| circles | 원형 보기 계산: 가까움 순위·고리 나누기, 선배·후배(연도), 직급 순서, 원 위의 자리(순수) | 없음 |
| app | 서비스 묶음(`FriendlyApp`). 화면이 쓰는 유일한 내부 입구 | 위 모듈들(키·전송 구현 제외) |
| ui | 화면. `app` 의 서비스만 쓴다 | app, core, platform, storage, discovery, inference, layout, visibility, kinship, events, groups, work |

View File

@ -35,3 +35,12 @@
- 위쪽 "OOO 쪽 족보"(배우자 기준 = 처가·시가 족보), "OOO 기준"(자녀 기준)으로 누르면 촌수·호칭이 한꺼번에 바뀜
- 사람을 누르면: 그 사람 기준으로 보기 / 프로필·생일 적기 / 그 사람의 부모·배우자·자녀·형제자매 더하기
- 생일이 있으면 "생일 11월 3일 · D-8"
## 2026-10-26 학교·회사 원형 보기 (로드맵 6·7단계)
![[화면 점검/2026-10-26 원형 보기/15_school_hub.png|220]] ![[화면 점검/2026-10-26 원형 보기/16_school_ring.png|220]] ![[화면 점검/2026-10-26 원형 보기/17_school_list.png|220]] ![[화면 점검/2026-10-26 원형 보기/18_school_pick.png|220]] ![[화면 점검/2026-10-26 원형 보기/19_company_hub.png|220]] ![[화면 점검/2026-10-26 원형 보기/19b_company_list.png|220]]
- 학교·회사 가운데: 나를 가운데 두고 둘레에 초·중·고·대·대학원(회사는 회사들), 칸마다 사람 수. 아래에 대화·통화가 많은 가까운 사람
- 칸을 누르면: 나 가운데, 가까운 순으로 안쪽 고리부터 원 모양으로 펼침 (6명, 12명, 18명…). 사람이 많아 원이 커지면 화면에 맞게 줄임
- 보기: 원형 / 목록. 거르기: 학교 = 선배·동기·후배, 회사 = 윗사람·같은 급·아래 직원. 나눠 보기(반별·팀별)는 설정으로 켜고 끔
- 목록은 연차별(3년 선배 … 동기 … 후배) 또는 직급별로 묶음
- 고르기: 보이는 사람 모두 / 윗사람 전체 / 아래 직원 전체 → 메시지 보내기 · 긴급 호출 (각자에게 1:1로 가며 긴급은 받는 쪽에 즉시 알림)
- 관계 탭의 학교·회사 그룹은 이 원형 화면으로 열림. 목록 관리는 오른쪽 위 아이콘

View File

@ -18,3 +18,4 @@
- 5단계 가계도 화면: kinship/tree.dart(세대 줄 계산), FamilyTreeService, FamilyTreeScreen. 시험 317개 통과 → [[06 화면 점검]]
- 21:20 사용 모델을 Sonnet 5.5 로 고정하는 규칙 추가. 6단계는 circles 모듈·CircleService·시험까지 됨 → 다음은 학교 원형 화면(ui)부터
- 21:2x .claude/settings.local.json 에 "model": "sonnet" 설정 (사용자 지시로 직접 수정)
- 21:46 6·7단계 학교·회사 원형 보기: circles 모듈, CircleService(가까움·선후배·직급·단체 메시지), CircleHubScreen·CircleGroupScreen·RingView. 시험: circles 5 + circle_service 5 + widget 4 통과 → [[06 화면 점검]]

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB