diff --git a/lib/modules/app/src/career_service.dart b/lib/modules/app/src/career_service.dart index 0392cd2..2d2b37b 100644 --- a/lib/modules/app/src/career_service.dart +++ b/lib/modules/app/src/career_service.dart @@ -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 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 colleagues; +} + +/// 주변 사람 한 명과 왜 주변인지. +class AroundPerson { + const AroundPerson(this.person, this.reasons); + final Person person; + final List 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> _histories() async { + try { + final m = (jsonDecode(await _storage.settings.getText(_historyKey) ?? '{}') as Map).cast(); + return {for (final e in m.entries) e.key: Career.fromJson(e.value)}; + } catch (_) { + return {}; + } + } + + Future _saveHistories(Map 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 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)); - changed = true; + 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> 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 = []; + final list = mine.newestFirst; + for (var i = 0; i < list.length; i++) { + final s = list[i]; + final mates = []; + 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 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> around(String personId) async { + final reasons = >{}; + 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 []) { + 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 = []; + 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()); diff --git a/lib/modules/history/history.dart b/lib/modules/history/history.dart new file mode 100644 index 0000000..632890f --- /dev/null +++ b/lib/modules/history/history.dart @@ -0,0 +1,7 @@ +/// 이력: 사람마다 회사별 근무 기간(몇 년부터 몇 년까지)과 겹치는 기간 계산 +/// +/// 순수 계산만 한다(저장·화면 없음). 어떤 모듈도 쓰지 않는다. +/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다. +library; + +export 'src/career.dart'; diff --git a/lib/modules/history/src/career.dart b/lib/modules/history/src/career.dart new file mode 100644 index 0000000..d2d6965 --- /dev/null +++ b/lib/modules/history/src/career.dart @@ -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 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? stints]) : _stints = [...?stints]; + + final List _stints; + + List get stints => List.unmodifiable(_stints); + + /// 최근 것부터. + List 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(); + } + } +} diff --git a/lib/modules/ui/src/screens/career_history_screen.dart b/lib/modules/ui/src/screens/career_history_screen.dart new file mode 100644 index 0000000..10b5596 --- /dev/null +++ b/lib/modules/ui/src/screens/career_history_screen.dart @@ -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 history; + final List 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 _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( + 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( + 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)), + ), + ]), + ); + }, + ); + } +} diff --git a/lib/modules/ui/src/screens/person_screen.dart b/lib/modules/ui/src/screens/person_screen.dart index 1316f67..ff25f68 100644 --- a/lib/modules/ui/src/screens/person_screen.dart +++ b/lib/modules/ui/src/screens/person_screen.dart @@ -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), diff --git a/lib/modules/ui/src/widgets/career_panel.dart b/lib/modules/ui/src/widgets/career_panel.dart index 05a388f..07cbdc1 100644 --- a/lib/modules/ui/src/widgets/career_panel.dart +++ b/lib/modules/ui/src/widgets/career_panel.dart @@ -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>( + 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); diff --git a/test/career_service_test.dart b/test/career_service_test.dart index 7c44eab..03a305c 100644 --- a/test/career_service_test.dart +++ b/test/career_service_test.dart @@ -107,4 +107,58 @@ void main() { expect(withJosa('ABC', '으로', '로'), 'ABC(으)로'); expect(withJosa('ABC', '을', '를'), 'ABC을(를)'); }); + + group('이직 이력', () { + final year = DateTime.now().year; + + Future 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'); + }); + }); } diff --git a/test/history_test.dart b/test/history_test.dart new file mode 100644 index 0000000..a89fe7d --- /dev/null +++ b/test/history_test.dart @@ -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); + }); +} diff --git a/test_ux/shots/01_chats.png b/test_ux/shots/01_chats.png index 3f5bf3e..fcf12d8 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 24527b0..733941d 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/16_school_ring.png b/test_ux/shots/16_school_ring.png index aaac036..ae84657 100644 Binary files a/test_ux/shots/16_school_ring.png and b/test_ux/shots/16_school_ring.png differ diff --git a/test_ux/shots/19b_company_list.png b/test_ux/shots/19b_company_list.png index 051fc3e..2a43e6a 100644 Binary files a/test_ux/shots/19b_company_list.png and b/test_ux/shots/19b_company_list.png differ diff --git a/test_ux/shots/19c_career_history.png b/test_ux/shots/19c_career_history.png new file mode 100644 index 0000000..0dad2e1 Binary files /dev/null and b/test_ux/shots/19c_career_history.png differ diff --git a/test_ux/shots/21_person.png b/test_ux/shots/21_person.png index b74d9a2..1a85def 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 4582499..1b93009 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 263acfe..03a3678 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 8380d6c..da4e03a 100644 --- a/test_ux/shots_test.dart +++ b/test_ux/shots_test.dart @@ -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 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(); diff --git a/tool/check_modules.py b/tool/check_modules.py index cf66de8..51dedd8 100644 --- a/tool/check_modules.py +++ b/tool/check_modules.py @@ -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"}} diff --git a/tool/test_module.sh b/tool/test_module.sh index 1b1321d..336ffa3 100644 --- a/tool/test_module.sh +++ b/tool/test_module.sh @@ -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" ) diff --git a/wiki/02 결정 기록.md b/wiki/02 결정 기록.md index e0dccdb..63a13a2 100644 --- a/wiki/02 결정 기록.md +++ b/wiki/02 결정 기록.md @@ -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 제품 개요) | diff --git a/wiki/03 로드맵.md b/wiki/03 로드맵.md index 12b0481..7f4b149 100644 --- a/wiki/03 로드맵.md +++ b/wiki/03 로드맵.md @@ -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 통과. diff --git a/wiki/04 구조와 모듈.md b/wiki/04 구조와 모듈.md index f1e2ea7..e0f9e2d 100644 --- a/wiki/04 구조와 모듈.md +++ b/wiki/04 구조와 모듈.md @@ -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 | diff --git a/wiki/06 화면 점검.md b/wiki/06 화면 점검.md index b8377df..76ce647 100644 --- a/wiki/06 화면 점검.md +++ b/wiki/06 화면 점검.md @@ -44,3 +44,11 @@ - 목록은 연차별(3년 선배 … 동기 … 후배) 또는 직급별로 묶음 - 고르기: 보이는 사람 모두 / 윗사람 전체 / 아래 직원 전체 → 메시지 보내기 · 긴급 호출 (각자에게 1:1로 가며 긴급은 받는 쪽에 즉시 알림) - 관계 탭의 학교·회사 그룹은 이 원형 화면으로 열림. 목록 관리는 오른쪽 위 아이콘 + +## 2026-10-26 이직 이력 (로드맵 8단계) +![[화면 점검/2026-10-26 이직 이력/19c_career_history.png|300]] +- 프로필에 "이직 이력" 한 줄(지금 회사 · 기간, 이전 회사 N곳) → 누르면 이력 화면 +- 이력 화면: 회사마다 "2019 ~ 2024 · 5년", 지금 다니는 곳은 "현재" 표시. 연필로 들어간 해·나온 해·지금도 다니는지를 직접 고칠 수 있음(모르면 비움) +- 각 회사에 "같이 있던 내 인맥 N명 ›": 누르면 그 사람들 목록, 사람을 누르면 그 사람의 이력으로 이동 +- 주변 사람: 내 기록에서 같은 그룹이거나 같은 회사에 있던 사람, 이유가 많은 순("같은 그룹 · 회사 › 한빛전자 › 결제팀" + "같은 회사 · 한빛전자"). 사람을 누르면 그 사람 기준으로 다시 봄 +- 이력은 회사 명함이 바뀌어 올 때 자동으로 쌓임(바뀐 해 = 받은 해). 처음 알게 된 회사의 들어간 해는 모름이라 "~ 현재" diff --git a/wiki/진행 로그/2026-10-26.md b/wiki/진행 로그/2026-10-26.md index 2a20e23..9f72299 100644 --- a/wiki/진행 로그/2026-10-26.md +++ b/wiki/진행 로그/2026-10-26.md @@ -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 빌드 시작 diff --git a/wiki/화면 점검/2026-10-26 이직 이력/19c_career_history.png b/wiki/화면 점검/2026-10-26 이직 이력/19c_career_history.png new file mode 100644 index 0000000..0dad2e1 Binary files /dev/null and b/wiki/화면 점검/2026-10-26 이직 이력/19c_career_history.png differ