diff --git a/lib/modules/circles/src/cohort.dart b/lib/modules/circles/src/cohort.dart index 7951b28..0b71cf4 100644 --- a/lib/modules/circles/src/cohort.dart +++ b/lib/modules/circles/src/cohort.dart @@ -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) { diff --git a/lib/modules/ui/src/screens/circle_group_screen.dart b/lib/modules/ui/src/screens/circle_group_screen.dart new file mode 100644 index 0000000..2ac1b06 --- /dev/null +++ b/lib/modules/ui/src/screens/circle_group_screen.dart @@ -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 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 createState() => _CircleGroupScreenState(); +} + +class _CircleGroupScreenState extends State { + _Mode _mode = _Mode.ring; + String _filter = 'all'; // all | senior·same·junior (학교) | above·same·below (회사) + bool _selecting = false; + final Set _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 _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 _askText() async { + final c = TextEditingController(); + final r = await showDialog( + 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 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 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 = >{}; + 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), + ), + ], + ]); + } +} diff --git a/lib/modules/ui/src/screens/circle_hub_screen.dart b/lib/modules/ui/src/screens/circle_hub_screen.dart new file mode 100644 index 0000000..5f98216 --- /dev/null +++ b/lib/modules/ui/src/screens/circle_hub_screen.dart @@ -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)), + ), + ]), + ); + }, + ); + } +} diff --git a/lib/modules/ui/src/screens/group_detail_screen.dart b/lib/modules/ui/src/screens/group_detail_screen.dart index d5f2e13..ae74783 100644 --- a/lib/modules/ui/src/screens/group_detail_screen.dart +++ b/lib/modules/ui/src/screens/group_detail_screen.dart @@ -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()), diff --git a/lib/modules/ui/src/screens/relations_screen.dart b/lib/modules/ui/src/screens/relations_screen.dart index f57631b..a7c0625 100644 --- a/lib/modules/ui/src/screens/relations_screen.dart +++ b/lib/modules/ui/src/screens/relations_screen.dart @@ -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 { ]), ), 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 { } } +/// 학교·회사는 원형 보기로, 나머지는 목록 화면으로. +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; diff --git a/lib/modules/ui/src/widgets/ring_view.dart b/lib/modules/ui/src/widgets/ring_view.dart new file mode 100644 index 0000000..0296226 --- /dev/null +++ b/lib/modules/ui/src/widgets/ring_view.dart @@ -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> 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 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)), + ); +} diff --git a/test_ux/shots/01_chats.png b/test_ux/shots/01_chats.png index f92738e..3f5bf3e 100644 Binary files a/test_ux/shots/01_chats.png and b/test_ux/shots/01_chats.png differ diff --git a/test_ux/shots/02_relations.png b/test_ux/shots/02_relations.png index 949a95a..24527b0 100644 Binary files a/test_ux/shots/02_relations.png and b/test_ux/shots/02_relations.png differ diff --git a/test_ux/shots/04_cards.png b/test_ux/shots/04_cards.png index 8264490..afbed46 100644 Binary files a/test_ux/shots/04_cards.png and b/test_ux/shots/04_cards.png differ diff --git a/test_ux/shots/11_group_school.png b/test_ux/shots/11_group_school.png index 1218271..a0d6ffa 100644 Binary files a/test_ux/shots/11_group_school.png and b/test_ux/shots/11_group_school.png differ diff --git a/test_ux/shots/12_group_company.png b/test_ux/shots/12_group_company.png index 23b54c1..8baf36d 100644 Binary files a/test_ux/shots/12_group_company.png and b/test_ux/shots/12_group_company.png differ diff --git a/test_ux/shots/15_school_hub.png b/test_ux/shots/15_school_hub.png new file mode 100644 index 0000000..96fdde6 Binary files /dev/null and b/test_ux/shots/15_school_hub.png differ diff --git a/test_ux/shots/16_school_ring.png b/test_ux/shots/16_school_ring.png new file mode 100644 index 0000000..aaac036 Binary files /dev/null and b/test_ux/shots/16_school_ring.png differ diff --git a/test_ux/shots/17_school_list.png b/test_ux/shots/17_school_list.png new file mode 100644 index 0000000..b3b93d1 Binary files /dev/null and b/test_ux/shots/17_school_list.png differ diff --git a/test_ux/shots/18_school_pick.png b/test_ux/shots/18_school_pick.png new file mode 100644 index 0000000..dfb5b81 Binary files /dev/null and b/test_ux/shots/18_school_pick.png differ diff --git a/test_ux/shots/19_company_hub.png b/test_ux/shots/19_company_hub.png new file mode 100644 index 0000000..e62aa3c Binary files /dev/null and b/test_ux/shots/19_company_hub.png differ diff --git a/test_ux/shots/19b_company_list.png b/test_ux/shots/19b_company_list.png new file mode 100644 index 0000000..051fc3e Binary files /dev/null and b/test_ux/shots/19b_company_list.png differ diff --git a/test_ux/shots/21_person.png b/test_ux/shots/21_person.png index a038afe..b74d9a2 100644 Binary files a/test_ux/shots/21_person.png and b/test_ux/shots/21_person.png differ diff --git a/test_ux/shots/22_relate.png b/test_ux/shots/22_relate.png index 0817222..4582499 100644 Binary files a/test_ux/shots/22_relate.png and b/test_ux/shots/22_relate.png differ diff --git a/test_ux/shots/23_chat_room.png b/test_ux/shots/23_chat_room.png index 7c0e002..263acfe 100644 Binary files a/test_ux/shots/23_chat_room.png and b/test_ux/shots/23_chat_room.png differ diff --git a/test_ux/shots_test.dart b/test_ux/shots_test.dart index 883b6a7..8380d6c 100644 --- a/test_ux/shots_test.dart +++ b/test_ux/shots_test.dart @@ -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 { diff --git a/wiki/00 최우선 규칙.md b/wiki/00 최우선 규칙.md index fa5692f..1ded869 100644 --- a/wiki/00 최우선 규칙.md +++ b/wiki/00 최우선 규칙.md @@ -1,6 +1,7 @@ # 최우선 규칙 (모든 규칙보다 위) - **사용 모델: Claude Sonnet 5.5(`claude-sonnet-5-5`)만 쓴다.** 사용자가 따로 지시하지 않는 한 다른 모델(Opus·Haiku 등)로 바꾸거나 하위 작업에 쓰지 않는다. (2026-10-26 사용자 지시) +지시한 모델이 다른경 우 사용자에게 모델 변경 팝업/메시지가 나타나고 이후 선택된 모델로 진행 - **0순위: 모든 가이드·문서·결정·진행 기록·화면 점검은 이 wiki 보관함에 저장·관리한다.** 세션 시작 때 홈과 이 문서부터 읽고, 일할 때마다 진행 로그·결정 기록을 갱신한다. - **질문은 설치 가능한 APK 를 먼저 건넨 뒤에만.** 사용자가 폰에 설치해 써 보며 답할 수 있게. 캡처도 함께 보여 주고 "이렇게 하면 / 저렇게 하면" 화면 비교로만 묻는다. diff --git a/wiki/02 결정 기록.md b/wiki/02 결정 기록.md index 299477b..e0dccdb 100644 --- a/wiki/02 결정 기록.md +++ b/wiki/02 결정 기록.md @@ -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 제품 개요), 새 통신 형식 없이 기존 대화로 | diff --git a/wiki/03 로드맵.md b/wiki/03 로드맵.md index ec3c325..12b0481 100644 --- a/wiki/03 로드맵.md +++ b/wiki/03 로드맵.md @@ -12,8 +12,8 @@ ## 2026-10-26 사용자 추가 요구 ([[01 제품 개요]]) - [x] 5. 가계도 화면: 나 가운데, 위 윗세대(촌수), 옆 배우자, 아래 자식, 생일·알림, 친가/처가 족보, 자식 누르면 그 자식 기준으로 촌수 일괄 변경 -- [ ] 6. 학교 원형 화면: 나 가운데, 초·중·고·대 둘레, 가까운 사람(대화·통화 많은 순), 선배·후배(연차/연도), 반별/합쳐 보기 설정, 동문·동창 일괄 선택 → 긴급 단체 메시지 -- [ ] 7. 회사 원형 화면: 학교와 같은 방식(설정 가능), 프로젝트 전체·아래 직원 전체·윗사람 전체 메시지(직급 순서) +- [x] 6. 학교 원형 화면: 나 가운데, 초·중·고·대 둘레, 가까운 사람(대화·통화 많은 순), 선배·후배(연차/연도), 반별/합쳐 보기 설정, 동문·동창 일괄 선택 → 긴급 단체 메시지 +- [x] 7. 회사 원형 화면: 학교와 같은 방식(설정 가능), 프로젝트 전체·아래 직원 전체·윗사람 전체 메시지(직급 순서) - [ ] 8. 이직 이력: 회사별 근무 기간(몇 년~몇 년), 그 사람을 누르면 내 기록 속 주변 사람 ## 현재 상태 (2026-10-26) diff --git a/wiki/04 구조와 모듈.md b/wiki/04 구조와 모듈.md index b33d57f..f1e2ea7 100644 --- a/wiki/04 구조와 모듈.md +++ b/wiki/04 구조와 모듈.md @@ -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 | diff --git a/wiki/06 화면 점검.md b/wiki/06 화면 점검.md index 69d4f7f..b8377df 100644 --- a/wiki/06 화면 점검.md +++ b/wiki/06 화면 점검.md @@ -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로 가며 긴급은 받는 쪽에 즉시 알림) +- 관계 탭의 학교·회사 그룹은 이 원형 화면으로 열림. 목록 관리는 오른쪽 위 아이콘 diff --git a/wiki/진행 로그/2026-10-26.md b/wiki/진행 로그/2026-10-26.md index b65532c..2a20e23 100644 --- a/wiki/진행 로그/2026-10-26.md +++ b/wiki/진행 로그/2026-10-26.md @@ -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 화면 점검]] diff --git a/wiki/화면 점검/2026-10-26 원형 보기/15_school_hub.png b/wiki/화면 점검/2026-10-26 원형 보기/15_school_hub.png new file mode 100644 index 0000000..96fdde6 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 원형 보기/15_school_hub.png differ diff --git a/wiki/화면 점검/2026-10-26 원형 보기/16_school_ring.png b/wiki/화면 점검/2026-10-26 원형 보기/16_school_ring.png new file mode 100644 index 0000000..aaac036 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 원형 보기/16_school_ring.png differ diff --git a/wiki/화면 점검/2026-10-26 원형 보기/17_school_list.png b/wiki/화면 점검/2026-10-26 원형 보기/17_school_list.png new file mode 100644 index 0000000..b3b93d1 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 원형 보기/17_school_list.png differ diff --git a/wiki/화면 점검/2026-10-26 원형 보기/18_school_pick.png b/wiki/화면 점검/2026-10-26 원형 보기/18_school_pick.png new file mode 100644 index 0000000..dfb5b81 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 원형 보기/18_school_pick.png differ diff --git a/wiki/화면 점검/2026-10-26 원형 보기/19_company_hub.png b/wiki/화면 점검/2026-10-26 원형 보기/19_company_hub.png new file mode 100644 index 0000000..e62aa3c Binary files /dev/null and b/wiki/화면 점검/2026-10-26 원형 보기/19_company_hub.png differ diff --git a/wiki/화면 점검/2026-10-26 원형 보기/19b_company_list.png b/wiki/화면 점검/2026-10-26 원형 보기/19b_company_list.png new file mode 100644 index 0000000..051fc3e Binary files /dev/null and b/wiki/화면 점검/2026-10-26 원형 보기/19b_company_list.png differ