이직 이력과 주변 사람 (8단계)

- history 모듈(순수): 회사별 근무 기간, 이직하면 이전 회사 닫기, 기간 겹침, 기간 직접 고치기
- CareerService: 회사 명함이 바뀔 때 이력 자동 기록, 같은 회사에 있던 내 인맥, 같은 그룹·같은 회사 주변 사람
- 화면: 프로필의 이직 이력 한 줄 → 이력 화면(기간·같이 있던 인맥·주변 사람, 사람을 누르면 그 사람 이력)
- 시험 12개 추가 (전체 336개)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
main
diadmin 2026-10-26 21:57:29 +09:00
parent 1dcf1dcdb7
commit 906ae697e0
25 changed files with 563 additions and 10 deletions

View File

@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:friendly_map/modules/core/core.dart';
import 'package:friendly_map/modules/history/history.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import 'change_bus.dart';
@ -34,6 +35,22 @@ class CareerNotice {
String currentCompany(Map<String, String> fields) =>
fields[CardKeys.former] == 'true' ? '' : (fields[CardKeys.company] ?? '').trim();
/// 이력 화면의 한 줄: 회사 기간과, 내 기록에서 그 회사에 있었던 다른 사람 수.
class StintView {
const StintView(this.index, this.stint, this.colleagues);
final int index; // 최근 것부터 센 번호
final Stint stint;
final List<Person> colleagues;
}
/// 주변 사람 한 명과 왜 주변인지.
class AroundPerson {
const AroundPerson(this.person, this.reasons);
final Person person;
final List<String> reasons;
int get weight => reasons.length;
}
/// 받침에 맞는 조사 ("누리소프트" + 로, "회사" + 를, "한빛" + 으로). 한글이 아니면 "(으)로"·"을(를)".
String withJosa(String word, String withFinal, String withoutFinal) {
if (word.isEmpty) return word;
@ -52,6 +69,7 @@ class CareerService {
CareerService(this._storage, this._clock, this._groups, this._relate, this._network, this._bus);
static const _knownKey = 'career.known';
static const _historyKey = 'career.history';
static const _noticesKey = 'career.notices';
final Storage _storage;
@ -85,16 +103,95 @@ class CareerService {
_bus.notify();
}
/// 이미 받아 둔 회사 명함으로 "알던 회사"를 채운다 (앱 시작 때). 알림은 만들지 않는다.
Future<Map<String, Career>> _histories() async {
try {
final m = (jsonDecode(await _storage.settings.getText(_historyKey) ?? '{}') as Map).cast<String, String>();
return {for (final e in m.entries) e.key: Career.fromJson(e.value)};
} catch (_) {
return {};
}
}
Future<void> _saveHistories(Map<String, Career> m) =>
_storage.settings.putText(_historyKey, jsonEncode({for (final e in m.entries) e.key: e.value.toJson()}), _clock.now());
int get _year => DateTime.fromMillisecondsSinceEpoch(_clock.now() * 10).year;
/// 이미 받아 둔 회사 명함으로 "알던 회사"와 이력을 채운다 (앱 시작 때). 알림은 만들지 않는다. 시작 연도는 모른다.
Future<void> seed() async {
final known = await _known();
var changed = false;
final hist = await _histories();
var changed = false, histChanged = false;
for (final c in await _storage.receivedCards.all()) {
if (c.kind != CardKinds.company || known.containsKey(c.personId)) continue;
known[c.personId] = currentCompany(decodeFields(c.fieldsJson));
if (c.kind != CardKinds.company) continue;
final now = currentCompany(decodeFields(c.fieldsJson));
if (!known.containsKey(c.personId)) {
known[c.personId] = now;
changed = true;
}
if (!hist.containsKey(c.personId) && now.isNotEmpty) {
hist[c.personId] = Career()..begin(now);
histChanged = true;
}
}
if (changed) await _saveKnown(known);
if (histChanged) await _saveHistories(hist);
}
/// 이 사람의 회사 이력과, 각 회사에 같이 있었던 내 인맥 (기간이 겹치는 사람).
Future<List<StintView>> history(String personId) async {
final hist = await _histories();
final mine = hist[personId];
if (mine == null) return const [];
final people = {for (final p in await _storage.people.all()) p.id: p};
final out = <StintView>[];
final list = mine.newestFirst;
for (var i = 0; i < list.length; i++) {
final s = list[i];
final mates = <Person>[];
for (final e in hist.entries) {
if (e.key == personId) continue;
if (e.value.stints.any((o) => o.overlaps(s, _year)) && people[e.key] != null) mates.add(people[e.key]!);
}
mates.sort((a, b) => a.name.compareTo(b.name));
out.add(StintView(i, s, mates));
}
return out;
}
/// 사용자가 기간을 고친다 ([index] 는 최근 것부터). 모르면 null.
Future<bool> setYears(String personId, int index, {int? from, int? to, bool? current}) async {
final hist = await _histories();
final c = hist[personId];
if (c == null || !c.setYears(index, from: from, to: to, current: current)) return false;
await _saveHistories(hist);
_bus.notify();
return true;
}
/// 내 기록 속 주변 사람: 같은 그룹에 있거나 같은 회사에 같이 있었던 사람 (겹치는 이유가 많은 순).
/// 잠긴 그룹이 닫혀 있으면 그 그룹은 보지 않는다.
Future<List<AroundPerson>> around(String personId) async {
final reasons = <String, List<String>>{};
for (final g in await _groups.groupsOf(personId)) {
final v = await _groups.view(g.id);
for (final p in await _groups.members(g.id) ?? const <Person>[]) {
if (p.id != personId) (reasons[p.id] ??= []).add('같은 그룹 · ${v?.path ?? g.name}');
}
}
for (final s in await history(personId)) {
for (final p in s.colleagues) {
(reasons[p.id] ??= []).add('같은 회사 · ${s.stint.company}');
}
}
final hidden = await _groups.hiddenPeople();
final out = <AroundPerson>[];
for (final e in reasons.entries) {
final p = await _storage.people.get(e.key);
if (p != null && !hidden.contains(p.id)) out.add(AroundPerson(p, e.value.toSet().toList()));
}
out.sort((a, b) => b.weight != a.weight ? b.weight.compareTo(a.weight) : a.person.name.compareTo(b.person.name));
return out;
}
/// 받은 명함을 살핀다 (동기화가 명함을 반영한 뒤 부른다). 회사가 바뀌었으면 알림을 만든다.
@ -106,6 +203,15 @@ class CareerService {
final before = known[c.personId];
known[c.personId] = now;
await _saveKnown(known);
// 이력: 회사가 바뀌었거나 처음 알게 되면 기록 (바뀐 해는 지금 해).
final hist = await _histories();
final career = hist.putIfAbsent(c.personId, Career.new);
if (before != null && before != now && now.isEmpty) {
career.leave(year: _year);
} else if (now.isNotEmpty) {
career.begin(now, year: before == null || before == now ? null : _year);
}
await _saveHistories(hist);
if (before == null || before == now) return null;
final list = (await _all())..removeWhere((n) => n.personId == c.personId);
final n = CareerNotice(newId(), c.personId, before, now, _clock.now());

View File

@ -0,0 +1,7 @@
/// 이력: 사람마다 회사별 근무 기간(몇 년부터 몇 년까지)과 겹치는 기간 계산
///
/// 순수 계산만 한다(저장·화면 없음). 어떤 모듈도 쓰지 않는다.
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
library;
export 'src/career.dart';

View File

@ -0,0 +1,98 @@
import 'dart:convert';
/// 한 회사에서 일한 기간. 연도를 모르면 null, [to] 가 null 이고 [current] 면 지금도 다님.
class Stint {
const Stint(this.company, {this.from, this.to, this.current = false});
final String company;
final int? from;
final int? to;
final bool current;
/// "2019 ~ 2024", "2024 ~ 현재", "~ 2024", "기간 모름"
String get period {
if (from == null && to == null && !current) return '기간 모름';
final a = from?.toString() ?? '';
final b = current ? '현재' : (to?.toString() ?? '');
return '$a ~ $b'.trim();
}
/// 몇 년 일했는지 (모르면 null).
int? years(int thisYear) {
final a = from;
if (a == null) return null;
final b = current ? thisYear : to;
return b == null ? null : (b - a < 0 ? 0 : b - a);
}
Stint copyWith({int? from, int? to, bool? current, bool clearFrom = false, bool clearTo = false}) => Stint(company,
from: clearFrom ? null : (from ?? this.from), to: clearTo ? null : (to ?? this.to), current: current ?? this.current);
Map<String, Object?> toJson() => {'c': company, if (from != null) 'f': from, if (to != null) 't': to, if (current) 'now': true};
static Stint? fromJson(Object? j) {
if (j is! Map || j['c'] is! String) return null;
return Stint(j['c'] as String, from: j['f'] is int ? j['f'] as int : null, to: j['t'] is int ? j['t'] as int : null, current: j['now'] == true);
}
/// 두 사람이 같은 회사에서 겹쳐 일했을 가능성. 연도를 모르면 겹친다고 본다.
bool overlaps(Stint other, int thisYear) {
if (company != other.company) return false;
final aFrom = from, bFrom = other.from;
final aTo = current ? thisYear : to, bTo = other.current ? thisYear : other.to;
if (aFrom == null || bFrom == null || aTo == null || bTo == null) return true;
return aFrom <= bTo && bFrom <= aTo;
}
}
/// 한 사람의 회사 이력 (오래된 것부터 저장, 보여 줄 때는 최근 것부터).
class Career {
Career([List<Stint>? stints]) : _stints = [...?stints];
final List<Stint> _stints;
List<Stint> get stints => List.unmodifiable(_stints);
/// 최근 것부터.
List<Stint> get newestFirst => _stints.reversed.toList();
Stint? get now => _stints.isNotEmpty && _stints.last.current ? _stints.last : null;
/// [company] 에서 일하기 시작함. 지금 다니는 다른 회사가 있으면 [year] 에 끝낸 것으로 닫는다. 같은 회사면 그대로.
/// 시작 연도를 모르면 [year] 를 null 로 둔다.
void begin(String company, {int? year}) {
final c = company.trim();
if (c.isEmpty) return;
final cur = now;
if (cur != null && cur.company == c) return;
if (cur != null) _stints[_stints.length - 1] = cur.copyWith(to: year, current: false);
_stints.add(Stint(c, from: year, current: true));
}
/// 지금 다니던 회사를 [year] 에 그만둠.
void leave({int? year}) {
final cur = now;
if (cur != null) _stints[_stints.length - 1] = cur.copyWith(to: year, current: false);
}
/// [index](최근 것부터 센 번호)의 기간을 사용자가 고친다. null 이면 "모름".
bool setYears(int index, {int? from, int? to, bool? current}) {
final i = _stints.length - 1 - index;
if (i < 0 || i >= _stints.length) return false;
final s = _stints[i];
_stints[i] = Stint(s.company, from: from, to: to, current: current ?? s.current);
return true;
}
String toJson() => jsonEncode([for (final s in _stints) s.toJson()]);
factory Career.fromJson(String? text) {
if (text == null || text.isEmpty) return Career();
try {
final j = jsonDecode(text);
return Career([if (j is List) for (final x in j) if (Stint.fromJson(x) case final s?) s]);
} on FormatException {
return Career();
}
}
}

View File

@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/common.dart';
import 'person_screen.dart';
class _Data {
const _Data(this.person, this.history, this.around);
final Person? person;
final List<StintView> history;
final List<AroundPerson> around;
}
/// 이직 이력: 회사마다 몇 년부터 몇 년까지 다녔는지, 그 회사에 같이 있던 내 인맥,
/// 그리고 이 사람의 주변 사람(같은 그룹·같은 회사, 내 기록 기준). 사람을 누르면 그 사람의 이력으로 넘어간다.
class CareerHistoryScreen extends StatelessWidget {
const CareerHistoryScreen({super.key, required this.personId});
final String personId;
Future<_Data> _load(FriendlyApp a) async => _Data(await a.friends.person(personId), await a.career.history(personId), await a.career.around(personId));
Future<void> _edit(BuildContext context, StintView s) async {
final app = AppScope.of(context).app;
final from = TextEditingController(text: s.stint.from?.toString() ?? '');
final to = TextEditingController(text: s.stint.to?.toString() ?? '');
var current = s.stint.current;
final ok = await showDialog<bool>(
context: context,
builder: (c) => StatefulBuilder(
builder: (c, set) => AlertDialog(
title: Text('${s.stint.company} 근무 기간'),
content: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(controller: from, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '들어간 해 (모르면 비워 두기)', hintText: '예: 2019')),
const SizedBox(height: 10),
TextField(controller: to, enabled: !current, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '나온 해 (모르면 비워 두기)', hintText: '예: 2024')),
SwitchListTile(contentPadding: EdgeInsets.zero, title: const Text('지금도 다니는 중'), value: current, onChanged: (v) => set(() => current = v)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('취소')),
TextButton(onPressed: () => Navigator.pop(c, true), child: const Text('저장')),
],
),
),
);
final f = int.tryParse(from.text.trim()), t = current ? null : int.tryParse(to.text.trim());
from.dispose();
to.dispose();
if (ok == true) await app.career.setYears(personId, s.index, from: f, to: t, current: current);
}
void _mates(BuildContext context, StintView s) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (c) => SafeArea(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.6,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Text('${s.stint.company}에 같이 있던 사람', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
),
Padding(padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), child: Text('${s.stint.period} · 내 기록 기준', style: const TextStyle(fontSize: 12, color: Pal.muted))),
Expanded(
child: ListView(children: [
for (final p in s.colleagues)
ListTile(
leading: Avatar(p.name, size: 40, color: Pal.company),
title: Text(p.name),
subtitle: const Text('누르면 이 사람의 이력과 주변 사람'),
onTap: () {
Navigator.pop(c);
pushPage(context, CareerHistoryScreen(personId: p.id));
},
),
]),
),
]),
),
),
);
}
@override
Widget build(BuildContext context) {
return AsyncView<_Data>(
load: _load,
builder: (context, d) {
final p = d.person;
if (p == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('찾을 수 없는 사람이에요.'));
final now = d.history.where((h) => h.stint.current).firstOrNull;
return Scaffold(
appBar: AppBar(title: const Text('이직 이력'), actions: [
IconButton(tooltip: '프로필', onPressed: () => pushPage(context, PersonScreen(personId: p.id)), icon: const Icon(Icons.person_outline)),
]),
body: ListView(padding: const EdgeInsets.only(bottom: 32), children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Row(children: [
Avatar(p.name, size: 56, color: Pal.company),
const SizedBox(width: 14),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(p.name, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800)),
Text(now == null ? (d.history.isEmpty ? '이력이 없어요' : '지금은 다니는 곳이 없어요') : '지금 ${now.stint.company}', style: const TextStyle(color: Pal.muted)),
]),
),
]),
),
const SectionHeader('다닌 회사'),
if (d.history.isEmpty) const EmptyNote('회사 명함을 받으면 이력이 쌓여요.'),
for (final s in d.history)
Panel(
onTap: s.colleagues.isEmpty ? null : () => _mates(context, s),
child: Row(children: [
Container(
width: 4,
height: 56,
decoration: BoxDecoration(color: s.stint.current ? Pal.accent : Pal.line, borderRadius: BorderRadius.circular(2)),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Flexible(child: Text(s.stint.company, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: s.stint.current ? Pal.text : Pal.muted))),
if (s.stint.current)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: Pal.accent.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(10)),
child: const Text('현재', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Pal.accent)),
),
]),
const SizedBox(height: 2),
Text([s.stint.period, if (s.stint.years(DateTime.now().year) case final y?) '$y년'].join(' · '), style: const TextStyle(fontSize: 13, color: Pal.muted)),
const SizedBox(height: 2),
Text(s.colleagues.isEmpty ? '같이 있던 내 인맥 없음' : '같이 있던 내 인맥 ${s.colleagues.length}명 ›', style: TextStyle(fontSize: 12, color: s.colleagues.isEmpty ? Pal.muted : Pal.company)),
]),
),
IconButton(tooltip: '기간 고치기', onPressed: () => _edit(context, s), icon: const Icon(Icons.edit_outlined, size: 20, color: Pal.muted)),
]),
),
const SectionHeader('주변 사람'),
const Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, 6),
child: Text('내 기록에서 같은 그룹이거나 같은 회사에 있던 사람이에요.', style: TextStyle(fontSize: 12, color: Pal.muted)),
),
if (d.around.isEmpty) const EmptyNote('아직 주변 사람이 없어요.'),
for (final a in d.around)
ListTile(
leading: Avatar(a.person.name, size: 40),
title: Text(a.person.name),
subtitle: Text(a.reasons.join('\n')),
isThreeLine: a.reasons.length > 1,
onTap: () => pushPage(context, CareerHistoryScreen(personId: a.person.id)),
),
]),
);
},
);
}
}

View File

@ -98,6 +98,7 @@ class PersonScreen extends StatelessWidget {
),
const SizedBox(height: 16),
PlacesPanel(personId: p.id, name: p.name),
CareerHistoryTile(personId: p.id),
if (d.info.relations.any((v) => v.relation.related))
RelationPanel(d.info.relations, onEdit: () => pushPage(context, PersonFactsScreen(personId: p.id))),
if (d.info.reachable && d.info.canReadLog) CallPanel(d.info),

View File

@ -5,6 +5,40 @@ import '../app_scope.dart';
import '../theme.dart';
import 'async_view.dart';
import 'common.dart';
import '../screens/career_history_screen.dart';
/// 프로필의 "이직 이력" 한 줄: 지금 회사와 다닌 회사 수. 이력이 없으면 아무것도 그리지 않는다.
class CareerHistoryTile extends StatelessWidget {
const CareerHistoryTile({super.key, required this.personId});
final String personId;
@override
Widget build(BuildContext context) {
return AsyncView<List<StintView>>(
load: (a) => a.career.history(personId),
builder: (context, h) {
if (h.isEmpty) return const SizedBox.shrink();
final now = h.where((s) => s.stint.current).firstOrNull;
return Panel(
onTap: () => pushPage(context, CareerHistoryScreen(personId: personId)),
child: Row(children: [
const Icon(Icons.timeline, color: Pal.company),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('이직 이력', style: TextStyle(fontSize: 13, color: Pal.muted)),
Text(now == null ? '지금은 다니는 곳 없음' : '${now.stint.company} · ${now.stint.period}', style: const TextStyle(fontWeight: FontWeight.w600)),
if (h.length > 1) Text('이전 회사 ${h.length - 1}곳', style: const TextStyle(fontSize: 12, color: Pal.muted)),
]),
),
const Icon(Icons.chevron_right, color: Pal.muted),
]),
);
},
);
}
}
class _Item {
const _Item(this.notice, this.text);

View File

@ -107,4 +107,58 @@ void main() {
expect(withJosa('ABC', '으로', '로'), 'ABC(으)로');
expect(withJosa('ABC', '을', '를'), 'ABC을(를)');
});
group('이직 이력', () {
final year = DateTime.now().year;
Future<Person> mate(String name, String phone, String company) async {
final p = (await b.app.friends.add(phone, name))!;
final id = 'mate-${p.id}';
await b.app.deps.storage.receivedCards.put(ReceivedCard(
id: id, personId: p.id, kind: CardKinds.company, fieldsJson: encodeFields({'company': company}), receivedAt: 1, updatedAt: 1));
await b.app.career.onCard(id);
return p;
}
test('처음엔 지금 회사만 "~ 현재", 이직하면 이전 회사가 올해로 닫히고 새 회사가 올해부터', () async {
var h = await b.app.career.history(aOfB.id);
expect(h.map((s) => '${s.stint.company} ${s.stint.period}'), ['한빛전자 ~ 현재']);
await moveTo('누리소프트');
h = await b.app.career.history(aOfB.id);
expect(h.map((s) => '${s.stint.company} ${s.stint.period}'), ['누리소프트 $year ~ 현재', '한빛전자 ~ $year']);
});
test('기간을 직접 고치면 반영되고, 같은 회사에 겹쳐 있던 내 인맥이 나온다', () async {
final old = await mate('옛동료', '+821066667777', '한빛전자');
await moveTo('누리소프트');
expect(await b.app.career.setYears(aOfB.id, 1, from: 2015, to: 2020), isTrue);
expect(await b.app.career.setYears(aOfB.id, 9, from: 1), isFalse);
var h = await b.app.career.history(aOfB.id);
expect(h.last.stint.period, '2015 ~ 2020');
expect(h.last.colleagues.map((p) => p.name), ['옛동료'], reason: '옛동료는 기간을 몰라 겹친 것으로 본다');
await b.app.career.setYears(old.id, 0, from: 2022, current: true);
h = await b.app.career.history(aOfB.id);
expect(h.last.colleagues, isEmpty, reason: '2022년부터 다닌 사람은 2015~2020 과 겹치지 않는다');
expect(h.first.colleagues, isEmpty);
});
test('주변 사람: 같은 그룹, 같은 회사에 같이 있었던 사람 (이유가 많은 순)', () async {
final x = await mate('같은팀', '+821055550001', '한빛전자');
final y = await mate('그냥동료', '+821055550002', '한빛전자');
final hb = (await b.app.groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!;
await b.app.groups.addMembers(hb.id, [aOfB.id, x.id]);
final around = await b.app.career.around(aOfB.id);
expect(around.map((e) => e.person.name), ['같은팀', '그냥동료']);
expect(around.first.reasons, contains('같은 그룹 · 회사 › 한빛전자'));
expect(around.first.reasons, contains('같은 회사 · 한빛전자'));
expect(around.last.reasons, ['같은 회사 · 한빛전자']);
expect(y.name, '그냥동료');
});
test('퇴사(지금은 다니지 않음)하면 기간이 올해로 닫히고 지금 다니는 곳이 없다', () async {
await moveTo('한빛전자', former: true);
final h = await b.app.career.history(aOfB.id);
expect(h.single.stint.period, '~ $year');
});
});
}

54
test/history_test.dart Normal file
View File

@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:friendly_map/modules/history/history.dart';
void main() {
test('이직하면 이전 회사는 끝난 해로 닫히고 새 회사가 지금 다니는 곳이 된다', () {
final c = Career()
..begin('한빛전자', year: 2019)
..begin('누리소프트', year: 2024);
expect(c.newestFirst.map((s) => s.company), ['누리소프트', '한빛전자']);
expect(c.newestFirst.last.period, '2019 ~ 2024');
expect(c.now!.period, '2024 ~ 현재');
expect(c.now!.years(2026), 2);
});
test('같은 회사를 다시 알려도 그대로, 빈 회사 이름은 무시, 그만두면 닫힘', () {
final c = Career()..begin('한빛', year: 2020);
c.begin('한빛', year: 2025);
c.begin(' ');
expect(c.stints.length, 1);
expect(c.stints.single.from, 2020);
c.leave(year: 2026);
expect(c.now, isNull);
expect(c.stints.single.period, '2020 ~ 2026');
});
test('연도를 모르면 "기간 모름" · "~ 현재", 사용자가 기간을 고칠 수 있다', () {
final c = Career()..begin('한빛');
expect(c.now!.period, '~ 현재');
expect(Career().stints, isEmpty);
expect(const Stint('x').period, '기간 모름');
expect(c.setYears(0, from: 2018, current: true), isTrue);
expect(c.now!.period, '2018 ~ 현재');
expect(c.setYears(5, from: 1), isFalse);
});
test('같은 회사에서 기간이 겹치는지 (연도를 모르면 겹침으로)', () {
const a = Stint('한빛', from: 2015, to: 2020);
expect(a.overlaps(const Stint('한빛', from: 2019, to: 2023), 2026), isTrue);
expect(a.overlaps(const Stint('한빛', from: 2021, to: 2023), 2026), isFalse);
expect(a.overlaps(const Stint('한빛', from: 2021, current: true), 2026), isFalse);
expect(a.overlaps(const Stint('한빛'), 2026), isTrue);
expect(a.overlaps(const Stint('누리', from: 2015, to: 2020), 2026), isFalse);
});
test('글자로 저장했다 되살리기, 깨진 글자는 빈 이력', () {
final c = Career()
..begin('한빛', year: 2019)
..begin('누리', year: 2024);
final d = Career.fromJson(c.toJson());
expect(d.newestFirst.map((s) => s.period), ['2024 ~ 현재', '2019 ~ 2024']);
expect(Career.fromJson('{').stints, isEmpty);
expect(Career.fromJson('[{"x":1}]').stints, isEmpty);
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 79 KiB

View File

@ -13,6 +13,7 @@ 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/career_history_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';
@ -115,7 +116,7 @@ void main() {
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.deps.storage.receivedCards.put(ReceivedCard(id: 'cc$seq', personId: p.id, kind: CardKinds.company, fieldsJson: encodeFields({'company': '한빛전자', '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++) {
@ -203,6 +204,23 @@ void main() {
await shot(tester, '18_school_pick');
await push(tester, const CircleHubScreen(rootId: CardKinds.company));
await shot(tester, '19_company_hub');
final minsuId = (await tester.runAsync(() async => (await app.friends.people()).firstWhere((p) => p.name == '김민수').id))!;
await tester.runAsync(() async {
await app.career.seed(); // 위에서 넣은 직원들의 이력
// 김민수는 한빛전자(2019~2024) → 누리소프트로 옮김
Future<void> card(String company) async {
await app.deps.storage.receivedCards.put(ReceivedCard(
id: 'mc', personId: minsuId, kind: CardKinds.company, fieldsJson: encodeFields({'company': company, 'position': '팀장'}), receivedAt: 1, updatedAt: 9));
await app.career.onCard('mc');
}
await card('한빛전자');
await card('누리소프트');
await app.career.setYears(minsuId, 0, from: 2024, current: true);
await app.career.setYears(minsuId, 1, from: 2019, to: 2024);
});
await push(tester, CareerHistoryScreen(personId: minsuId));
await shot(tester, '19c_career_history');
await push(tester, const CircleGroupScreen(groupId: 'g1'));
await tester.tap(find.text('목록'));
await tester.pumpAndSettle();

View File

@ -21,9 +21,10 @@ ALLOW = {
"groups": {"core"},
"work": set(),
"circles": set(),
"history": set(),
"events": set(),
"app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work", "circles"},
"ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work", "circles"},
"app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work", "circles", "history"},
"ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work", "circles", "history"},
}
# 모듈 입구가 둘 이상인 경우: 입구 파일이 모듈 이름.dart 외에 허용되는 것들.
EXTRA_BARRELS = {"platform": {"platform_ui.dart"}, "storage": {"storage_sqlite.dart"}}

View File

@ -10,6 +10,7 @@ declare -A T=(
[groups]="groups_test.dart"
[work]="work_test.dart"
[circles]="circles_test.dart"
[history]="history_test.dart"
[events]="events_test.dart lunar_test.dart"
[storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart"
[transport]="transport_test.dart"
@ -19,7 +20,7 @@ declare -A T=(
[inference]="inference_test.dart"
[layout]="layout_test.dart"
[sharing]="sharing_service_test.dart sharing_status_test.dart"
[app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart group_service_test.dart contact_import_test.dart relate_service_test.dart work_service_test.dart career_service_test.dart reconnect_display_test.dart circle_service_test.dart"
[app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart group_service_test.dart contact_import_test.dart relate_service_test.dart work_service_test.dart career_service_test.dart history_test.dart reconnect_display_test.dart circle_service_test.dart"
[platform_android]="df_parser_test.dart"
[ui]="widget_test.dart"
)

View File

@ -25,3 +25,4 @@
| 2026-10-26 | 가계도: 세대 줄 방식(위 조상·옆 배우자·아래 자식), 친가 왼쪽·외가/처가 오른쪽, 부부는 남자 왼쪽, 처가 족보 = 배우자 기준으로 다시 보기 | 사용자 요구(01 제품 개요) |
| 2026-10-26 | 사용 모델은 Sonnet 5.5 만(특별한 지시가 없는 한) | 사용자 지시 |
| 2026-10-26 | 가까움 점수 = 대화 수 + 통화 수 × 3. 선배·후배 = 학교 명함 연도 차이, 윗사람·아래 직원 = 직책 글자의 직급 순서(사원<대리<과장<차장<부장<…). 단체 메시지는 각자에게 1:1 | 사용자 요구(01 제품 개요), 새 통신 형식 없이 기존 대화로 |
| 2026-10-26 | 이직 이력은 회사 명함이 바뀔 때 자동 기록(바뀐 해=받은 해), 처음 안 회사의 들어간 해는 모름, 기간은 사용자가 고칠 수 있음. 주변 사람 = 내 기록의 같은 그룹 + 같은 회사 겹침(연도를 모르면 겹침으로 봄) | 사용자 요구(01 제품 개요) |

View File

@ -7,6 +7,7 @@
- [x] 4단계 이직 알림
- [x] 복구 폐지 → 다시 연결 승인·제명
- [x] UX 전면 개편 1차 (캡처 [[06 화면 점검]])
- [x] 5~8단계 끝 (시험 336개)
- [ ] APK 전달 → 사용자 설치·시험 → 화면 보며 피드백 반영
- [ ] 실제 기기 점검(주소록, 알림, 위치, QR, 같은 네트워크)
@ -14,7 +15,7 @@
- [x] 5. 가계도 화면: 나 가운데, 위 윗세대(촌수), 옆 배우자, 아래 자식, 생일·알림, 친가/처가 족보, 자식 누르면 그 자식 기준으로 촌수 일괄 변경
- [x] 6. 학교 원형 화면: 나 가운데, 초·중·고·대 둘레, 가까운 사람(대화·통화 많은 순), 선배·후배(연차/연도), 반별/합쳐 보기 설정, 동문·동창 일괄 선택 → 긴급 단체 메시지
- [x] 7. 회사 원형 화면: 학교와 같은 방식(설정 가능), 프로젝트 전체·아래 직원 전체·윗사람 전체 메시지(직급 순서)
- [ ] 8. 이직 이력: 회사별 근무 기간(몇 년~몇 년), 그 사람을 누르면 내 기록 속 주변 사람
- [x] 8. 이직 이력: 회사별 근무 기간(몇 년~몇 년), 그 사람을 누르면 내 기록 속 주변 사람
## 현재 상태 (2026-10-26)
- podman Flutter(stable)에서 `flutter analyze` 0건, `flutter test` 313개 통과, 서버(Python) 18개 통과, check_modules.py 통과.

View File

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

View File

@ -44,3 +44,11 @@
- 목록은 연차별(3년 선배 … 동기 … 후배) 또는 직급별로 묶음
- 고르기: 보이는 사람 모두 / 윗사람 전체 / 아래 직원 전체 → 메시지 보내기 · 긴급 호출 (각자에게 1:1로 가며 긴급은 받는 쪽에 즉시 알림)
- 관계 탭의 학교·회사 그룹은 이 원형 화면으로 열림. 목록 관리는 오른쪽 위 아이콘
## 2026-10-26 이직 이력 (로드맵 8단계)
![[화면 점검/2026-10-26 이직 이력/19c_career_history.png|300]]
- 프로필에 "이직 이력" 한 줄(지금 회사 · 기간, 이전 회사 N곳) → 누르면 이력 화면
- 이력 화면: 회사마다 "2019 ~ 2024 · 5년", 지금 다니는 곳은 "현재" 표시. 연필로 들어간 해·나온 해·지금도 다니는지를 직접 고칠 수 있음(모르면 비움)
- 각 회사에 "같이 있던 내 인맥 N명 ›": 누르면 그 사람들 목록, 사람을 누르면 그 사람의 이력으로 이동
- 주변 사람: 내 기록에서 같은 그룹이거나 같은 회사에 있던 사람, 이유가 많은 순("같은 그룹 · 회사 › 한빛전자 › 결제팀" + "같은 회사 · 한빛전자"). 사람을 누르면 그 사람 기준으로 다시 봄
- 이력은 회사 명함이 바뀌어 올 때 자동으로 쌓임(바뀐 해 = 받은 해). 처음 알게 된 회사의 들어간 해는 모름이라 "~ 현재"

View File

@ -19,3 +19,4 @@
- 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 화면 점검]]
- 21:57 8단계 이직 이력: history 모듈(Career·Stint), CareerService(이력 기록·같은 회사 인맥·주변 사람·기간 고치기), CareerHistoryScreen. 전체 시험 336개·analyze 0건 통과 → APK 빌드 시작

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB