이직 알림: 받은 회사 명함 비교, 이전 회사 흐리게·새 회사로 옮기기

- groups: 이전 구성원(formers) — 흐리게 남김, 다시 넣으면 구성원으로, 잠기면 함께 숨김
- app: CareerService — 사람마다 알던 회사 기억(시작 때 받은 명함으로 채움), 회사가 바뀌거나 떠나면 알림, 확인하면 이전 회사 그룹은 이전 구성원·새 회사 그룹으로 이동·연결 고리 갱신, 지우기·무시, 받침에 맞는 조사
- ui: 이직 알림 칸(채팅 목록·사람 화면·그룹 관리), 그룹 안 이전 구성원 흐리게 표시
- 시험 6개 추가 (전체 307개)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main v0.1.0-debug
diadmin 2026-10-26 14:52:28 +09:00
parent 3ceaf11fcc
commit 3a31b40e54
16 changed files with 475 additions and 20 deletions

View File

@ -31,8 +31,8 @@ Android 다크모드 인맥 앱(Flutter/Dart). 옛 "Family MAP" + 카카오톡
- 화면은 `AppScope.of(context).app`(FriendlyApp)의 서비스만 쓴다.
## 상태 (2026-10-26 기준)
- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 301개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과.
- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 관계망 로드맵 1단계(그룹 계층: 학교 › 초등~대학원, 가족 › 친가·외가·처가·시가·촌수 모름, 회사 › 회사 › 팀) 끝. 2단계(채팅·사람 화면의 "관계 정하기": RelateService, 촌수 맞춰 친가·외가·처가, 촌수 모름 모음, 연결 고리) 끝. 3단계(work 모듈, WorkService: 팀 대화방, 📌 업무 맡기기·정리, 🚨 긴급 호출 즉시 알림) 끝. 다음은 4단계(받은 회사 명함 비교로 이직 알림 → 이전 회사 흐리게, 새 회사로 이동·삭제).
- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 307개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과.
- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 관계망 로드맵 1단계(그룹 계층: 학교 › 초등~대학원, 가족 › 친가·외가·처가·시가·촌수 모름, 회사 › 회사 › 팀) 끝. 2단계(채팅·사람 화면의 "관계 정하기": RelateService, 촌수 맞춰 친가·외가·처가, 촌수 모름 모음, 연결 고리) 끝. 3단계(work 모듈, WorkService: 팀 대화방, 📌 업무 맡기기·정리, 🚨 긴급 호출 즉시 알림) 끝. 4단계(CareerService: 받은 회사 명함 비교로 이직 알림, 확인하면 이전 회사 그룹에 이전 구성원으로 흐리게·새 회사 그룹으로 이동, 지우기·무시) 끝. 로드맵 1~4단계 완료.
- `flutter build apk --debug` 성공(app-debug.apk 약 190MB). 컨테이너마다 SDK 34·35, CMake 를 다시 내려받아 15분 넘게 걸린다. 빌드할 때는 시험을 같이 돌리지 않는다(podman 머신 8GB 에서 Gradle 데몬이 메모리 부족으로 꺼진 적 있음).
- 빌드 경고(아직 빌드를 막지는 않음): `nsd_android`, `flutter_contacts` 가 Kotlin Gradle Plugin 을 직접 적용한다. 앞으로 Flutter 판에서 빌드가 실패할 수 있으니, 지원 판이 나오면 nsd 버전을 올린다. geolocator_android 는 deprecated API 를 쓴다는 Note 만 나온다.
- 확인 안 된 곳: 실제 기기에서 화면 실행과 플러그인 동작(geolocator, call_log, url_launcher, flutter_local_notifications, timezone, sqflite_sqlcipher, nsd, mobile_scanner, sensors_plus, flutter_secure_storage).

View File

@ -125,3 +125,9 @@ flutter run
- 업무와 긴급 호출은 보통 대화로 간다(`📌 업무 [김민수] 결제 API 정리 (마감 10/30)`, `🚨 긴급 호출: …`). 받는 쪽 앱이 읽어서 업무 목록에 넣고(담당 이름이 내 이름이면 "내 업무"), 긴급 호출은 바로 알림을 띄운다. 새 봉투 형식은 없다.
- 지난 대화를 길게 누르면 "업무로 정리"(내 목록에만). 업무 목록은 이 기기에만 저장하고, 끝냄 표시·밀어서 지우기가 된다.
- 안드로이드: 긴급 호출 알림은 `flutter_local_notifications` 의 높은 중요도 채널(urgent)을 쓴다.
## 이직 알림
- 상대가 회사 명함을 고쳐 "다시 보내기" 하면, 이 기기가 알던 회사와 비교해 바뀌었으면 알림이 생긴다(채팅 목록 맨 위, 사람 화면, 그룹 관리). 예: "김민수님이 한빛전자에서 누리소프트로 옮겼어요", "지금은 다니지 않음"이면 "…를 떠났어요".
- "확인 · 옮기기": 이전 회사 이름이 경로에 든 회사 그룹(예: 회사 › 한빛전자 › 결제팀)에서는 이전 구성원으로 흐리게 남기고, "회사 › 새 회사" 그룹(없으면 만든다)에 넣는다. 연결 고리는 '직장 동료'가 새 회사로 바뀌고 '전 직장 동료'(이전 경로)가 남는다.
- "이전 회사에서 지우기": 흐리게 남기지 않고 뺀다. "무시": 알림만 지운다. 이전 구성원은 그룹 안에서 ✕ 로 지울 수 있다.
- 처음 받는 회사 명함, 앱을 새로 연 뒤의 첫 비교는 알림을 만들지 않는다(알던 회사로만 기억). 알던 회사·알림은 이 기기에만 저장한다.

View File

@ -7,6 +7,7 @@ library;
export 'src/app_deps.dart';
export 'src/call_service.dart';
export 'src/card_service.dart';
export 'src/career_service.dart';
export 'src/change_bus.dart';
export 'src/chat_service.dart';
export 'src/contact_import_service.dart';

View File

@ -0,0 +1,179 @@
import 'dart:convert';
import 'package:friendly_map/modules/core/core.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import 'change_bus.dart';
import 'fields.dart';
import 'group_service.dart';
import 'network_service.dart';
import 'relate_service.dart';
/// 이직 알림 하나: [personId] 의 회사가 [from] 에서 [to] 로 바뀜. 빈 글자는 "회사 없음/떠남".
class CareerNotice {
const CareerNotice(this.id, this.personId, this.from, this.to, this.at);
final String id;
final String personId;
final String from;
final String to;
final int at;
bool get left => to.isEmpty;
Map<String, Object> toJson() => {'id': id, 'p': personId, 'f': from, 't': to, 'at': at};
static CareerNotice? fromJson(Object? j) {
if (j is! Map) return null;
final id = j['id'], p = j['p'], f = j['f'], t = j['t'], at = j['at'];
if (id is! String || p is! String || f is! String || t is! String || at is! int) return null;
return CareerNotice(id, p, f, t, at);
}
}
/// 받은 회사 명함에서 지금 다니는 회사 (지금은 다니지 않으면 빈 글자).
String currentCompany(Map<String, String> fields) =>
fields[CardKeys.former] == 'true' ? '' : (fields[CardKeys.company] ?? '').trim();
/// 받침에 맞는 조사 ("누리소프트" + 로, "회사" + 를, "한빛" + 으로). 한글이 아니면 "(으)로"·"을(를)".
String withJosa(String word, String withFinal, String withoutFinal) {
if (word.isEmpty) return word;
final r = word.runes.last;
if (r < 0xAC00 || r > 0xD7A3) return '$word$withFinal($withoutFinal)'.replaceFirst('으로(로)', '(으)로');
final fin = (r - 0xAC00) % 28;
final useFinal = fin != 0 && !(withFinal == '으로' && fin == 8); // ㄹ 받침은 "로"
return '$word${useFinal ? withFinal : withoutFinal}';
}
/// 이직 알림: 상대가 회사 명함을 "다시 보내기"로 고쳐 보내면 회사가 바뀌었는지 보고 알린다.
///
/// 확인하면 이전 회사 그룹에서는 흐리게(이전 구성원) 남기거나 지우고, 새 회사 그룹(회사 › 새 회사)으로 옮긴다.
/// 알던 회사와 알림은 이 기기에만 저장한다.
class CareerService {
CareerService(this._storage, this._clock, this._groups, this._relate, this._network, this._bus);
static const _knownKey = 'career.known';
static const _noticesKey = 'career.notices';
final Storage _storage;
final Clock _clock;
final GroupService _groups;
final RelateService _relate;
final NetworkService _network;
final ChangeBus _bus;
Future<Map<String, String>> _known() async {
try {
return (jsonDecode(await _storage.settings.getText(_knownKey) ?? '{}') as Map).cast<String, String>();
} catch (_) {
return {};
}
}
Future<void> _saveKnown(Map<String, String> m) => _storage.settings.putText(_knownKey, jsonEncode(m), _clock.now());
Future<List<CareerNotice>> _all() async {
try {
final j = jsonDecode(await _storage.settings.getText(_noticesKey) ?? '[]');
return [for (final x in j as List) if (CareerNotice.fromJson(x) case final n?) n];
} catch (_) {
return [];
}
}
Future<void> _saveAll(List<CareerNotice> list) async {
await _storage.settings.putText(_noticesKey, jsonEncode([for (final n in list) n.toJson()]), _clock.now());
_bus.notify();
}
/// 이미 받아 둔 회사 명함으로 "알던 회사"를 채운다 (앱 시작 때). 알림은 만들지 않는다.
Future<void> seed() async {
final known = await _known();
var changed = 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 (changed) await _saveKnown(known);
}
/// 받은 명함을 살핀다 (동기화가 명함을 반영한 뒤 부른다). 회사가 바뀌었으면 알림을 만든다.
Future<CareerNotice?> onCard(String cardId) async {
final c = await _storage.receivedCards.get(cardId);
if (c == null || c.kind != CardKinds.company) return null;
final now = currentCompany(decodeFields(c.fieldsJson));
final known = await _known();
final before = known[c.personId];
known[c.personId] = now;
await _saveKnown(known);
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());
list.add(n);
await _saveAll(list);
return n;
}
/// 확인 안 한 이직 알림 (최근 것부터). [personId] 를 주면 그 사람 것만.
Future<List<CareerNotice>> notices({String? personId}) async =>
[for (final n in (await _all()).reversed) if (personId == null || n.personId == personId) n];
/// "김민수님이 한빛전자에서 누리소프트로 옮겼어요"
Future<String> describe(CareerNotice n) async {
final name = (await _storage.people.get(n.personId))?.name ?? '친구';
if (n.from.isEmpty) return '$name님이 ${n.to}에 다니기 시작했어요';
if (n.left) return '$name님이 ${withJosa(n.from, '을', '를')} 떠났어요';
return '$name님이 ${n.from}에서 ${withJosa(n.to, '으로', '로')} 옮겼어요';
}
/// 알림을 받아들인다. 이전 회사 그룹에서는 [keepOld] 면 흐리게 남기고 아니면 뺀다. 새 회사가 있으면 그 그룹에 넣는다.
/// 들어간 새 회사 그룹 경로 (없으면 null).
Future<String?> accept(String noticeId, {bool keepOld = true}) async {
final list = await _all();
final i = list.indexWhere((x) => x.id == noticeId);
if (i < 0) return null;
final n = list.removeAt(i);
final pid = n.personId;
String? oldPath;
if (n.from.isNotEmpty) {
for (final v in await _groups.all()) {
if (v.group.kind != CardKinds.company || !v.open || !v.group.members.contains(pid)) continue;
if (!v.path.split(' › ').contains(n.from)) continue;
oldPath ??= v.path;
keepOld ? await _groups.markFormer(v.group.id, pid) : await _groups.removeMember(v.group.id, pid);
}
}
for (final l in await _network.links(pid)) {
if (l.relation == '직장 동료') await _network.removeLink(l.id);
}
if (keepOld && n.from.isNotEmpty) await _network.addLink(pid, '전 직장 동료', oldPath ?? n.from);
String? placed;
if (n.to.isNotEmpty) {
final target = await _companyGroup(n.to);
if (target != null) placed = (await _relate.into(pid, target))?.groupPath;
}
await _saveAll(list);
return placed;
}
/// 알림을 그냥 지운다.
Future<void> dismiss(String noticeId) async {
final list = await _all();
list.removeWhere((x) => x.id == noticeId);
await _saveAll(list);
}
/// 이름이 [name] 인 회사 그룹 (회사 바로 아래를 먼저). 없으면 회사 아래에 만든다.
Future<String?> _companyGroup(String name) async {
String? found;
for (final v in await _groups.all()) {
if (v.group.kind != CardKinds.company || !v.open || v.group.name != name) continue;
if (v.group.parent == CardKinds.company) return v.group.id;
found ??= v.group.id;
}
if (found != null) return found;
final root = await _groups.view(CardKinds.company);
final g = await _groups.add(name, CardKinds.company, parent: root != null && root.open ? CardKinds.company : null);
return g?.id;
}
}

View File

@ -5,6 +5,7 @@ import 'package:friendly_map/modules/sync/sync.dart';
import 'app_deps.dart';
import 'card_service.dart';
import 'career_service.dart';
import 'change_bus.dart';
import 'call_service.dart';
import 'chat_service.dart';
@ -62,6 +63,7 @@ class FriendlyApp {
contacts = ContactImportService(storage, clock, friends, groups, bus, deps.contacts);
relate = RelateService(groups, kin, network, bus);
work = WorkService(storage, clock, share, chats, groups, bus, deps.instant);
career = CareerService(storage, clock, groups, relate, network, bus);
}
final AppDeps deps;
@ -86,6 +88,7 @@ class FriendlyApp {
late final ContactImportService contacts;
late final RelateService relate;
late final WorkService work;
late final CareerService career;
StreamSubscription<SyncEvent>? _events;
final Set<Future<void>> _tasks = {};
@ -103,14 +106,16 @@ class FriendlyApp {
} else {
// 받은 대화에 업무 표시·긴급 호출이 있으면 업무 쪽이 처리한다.
final id = e.refId;
if (e.type == SyncEventType.message && id != null) {
final f = work.onMessage(id).catchError((Object _) {});
// 받은 회사 명함의 회사가 바뀌었으면 이직 알림을 만든다.
if ((e.type == SyncEventType.message || e.type == SyncEventType.card) && id != null) {
final f = (e.type == SyncEventType.message ? work.onMessage(id) : career.onCard(id)).then((_) {}).catchError((Object _) {});
_tasks.add(f);
unawaited(f.whenComplete(() => _tasks.remove(f)));
}
bus.notify();
}
});
await career.seed();
await sync.start();
unawaited(events.reschedule());
final me = await profile.me();

View File

@ -223,6 +223,26 @@ class GroupService {
return out;
}
/// 이전 구성원 (이름순). 잠겨 있으면 null.
Future<List<Person>?> formers(String id) async {
final b = await _load();
final g = b.byId(id);
if (g == null || !_isOpen(b, g)) return null;
final out = <Person>[];
for (final pid in g.formers) {
final p = await _storage.people.get(pid);
if (p != null) out.add(p);
}
out.sort((a, c) => a.name.compareTo(c.name));
return out;
}
/// 구성원을 이전 구성원으로 돌린다 (흐리게 남김).
Future<bool> markFormer(String id, String person) => _edit(id, (b) => b.markFormer(id, person));
/// 이전 구성원 표시를 지운다.
Future<bool> removeFormer(String id, String person) => _edit(id, (b) => b.removeFormer(id, person));
/// 구성원을 더한다 (잠긴 그룹은 열려 있을 때만). 새로 들어간 수.
Future<int> addMembers(String id, Iterable<String> people) async {
final b = await _load();

View File

@ -5,24 +5,27 @@ import 'pin.dart';
/// 사람을 묶는 그룹 하나. [kind] 는 명함 종류(가족·학교·회사·그룹·개인) 중 하나다.
/// [parent] 가 있으면 그 그룹의 하위 그룹이다 (예: 학교 > 고등학교, 회사 > 한빛전자 > 결제팀).
/// [lock] 이 있으면 비밀번호를 넣어야 구성원과 하위 그룹을 볼 수 있다.
/// [formers] 는 이전 구성원 (예: 이 회사를 떠난 사람). 화면에서 흐리게 보이고, 구성원 수에는 들지 않는다.
class Group {
const Group({required this.id, required this.name, required this.kind, this.parent, this.members = const {}, this.lock});
const Group({required this.id, required this.name, required this.kind, this.parent, this.members = const {}, this.formers = const {}, this.lock});
final String id;
final String name;
final String kind;
final String? parent;
final Set<String> members; // 사람 id
final Set<String> formers; // 이전 구성원 사람 id
final PinHash? lock;
bool get locked => lock != null;
Group copyWith({String? name, String? kind, Set<String>? members}) =>
Group(id: id, name: name ?? this.name, kind: kind ?? this.kind, parent: parent, members: members ?? this.members, lock: lock);
Group copyWith({String? name, String? kind, Set<String>? members, Set<String>? formers}) => Group(
id: id, name: name ?? this.name, kind: kind ?? this.kind, parent: parent,
members: members ?? this.members, formers: formers ?? this.formers, lock: lock);
Group withLock(PinHash? lock) => Group(id: id, name: name, kind: kind, parent: parent, members: members, lock: lock);
Group withLock(PinHash? lock) => Group(id: id, name: name, kind: kind, parent: parent, members: members, formers: formers, lock: lock);
Group withParent(String? parent) => Group(id: id, name: name, kind: kind, parent: parent, members: members, lock: lock);
Group withParent(String? parent) => Group(id: id, name: name, kind: kind, parent: parent, members: members, formers: formers, lock: lock);
Map<String, Object?> toJson() => {
'id': id,
@ -30,12 +33,13 @@ class Group {
'kind': kind,
if (parent != null) 'parent': parent,
'members': members.toList()..sort(),
if (formers.isNotEmpty) 'formers': formers.toList()..sort(),
if (lock != null) 'lock': lock!.toJson(),
};
static Group? fromJson(Object? j) {
if (j is! Map) return null;
final id = j['id'], name = j['name'], kind = j['kind'], m = j['members'], parent = j['parent'];
final id = j['id'], name = j['name'], kind = j['kind'], m = j['members'], parent = j['parent'], f = j['formers'];
if (id is! String || name is! String) return null;
return Group(
id: id,
@ -43,6 +47,7 @@ class Group {
kind: kind is String && CardKinds.all.contains(kind) ? kind : CardKinds.group,
parent: parent is String && parent != id ? parent : null,
members: m is List ? {for (final x in m) if (x is String) x} : const {},
formers: f is List ? {for (final x in f) if (x is String) x} : const {},
lock: PinHash.fromJson(j['lock']),
);
}

View File

@ -155,14 +155,14 @@ class GroupBook {
return true;
}
/// 구성원을 더한다. 새로 들어간 수를 돌려준다.
/// 구성원을 더한다 (이전 구성원이었으면 다시 구성원으로). 새로 들어간 수를 돌려준다.
int addMembers(String id, Iterable<String> people) {
final i = _index(id);
if (i < 0) return 0;
final m = {..._groups[i].members};
final before = m.length;
m.addAll(people);
_groups[i] = _groups[i].copyWith(members: m);
_groups[i] = _groups[i].copyWith(members: m, formers: {..._groups[i].formers}..removeAll(people));
return m.length - before;
}
@ -175,11 +175,31 @@ class GroupBook {
return ok;
}
/// 사람을 지울 때 모든 그룹에서 뺀다.
/// 구성원을 이전 구성원으로 돌린다 (흐리게 남김). 구성원이 아니었으면 false.
bool markFormer(String id, String person) {
final i = _index(id);
if (i < 0 || !_groups[i].members.contains(person)) return false;
_groups[i] = _groups[i].copyWith(
members: {..._groups[i].members}..remove(person),
formers: {..._groups[i].formers, person},
);
return true;
}
/// 이전 구성원 표시를 지운다.
bool removeFormer(String id, String person) {
final i = _index(id);
if (i < 0 || !_groups[i].formers.contains(person)) return false;
_groups[i] = _groups[i].copyWith(formers: {..._groups[i].formers}..remove(person));
return true;
}
/// 사람을 지울 때 모든 그룹에서 뺀다 (이전 구성원 표시도).
void forgetPerson(String person) {
for (var i = 0; i < _groups.length; i++) {
if (_groups[i].members.contains(person)) {
_groups[i] = _groups[i].copyWith(members: {..._groups[i].members}..remove(person));
final g = _groups[i];
if (g.members.contains(person) || g.formers.contains(person)) {
_groups[i] = g.copyWith(members: {...g.members}..remove(person), formers: {...g.formers}..remove(person));
}
}
}
@ -191,7 +211,10 @@ class GroupBook {
/// 다른 그룹에도 있어도 잠긴 쪽을 따른다.
Set<String> hiddenPeople(bool Function(Group g) closed) => {
for (final g in _groups)
if (g.locked && closed(g)) ...membersDeep(g.id),
if (g.locked && closed(g)) ...{
...membersDeep(g.id),
for (final x in subtree(g.id)) ...byId(x)!.formers,
},
};
int _index(String id) => _groups.indexWhere((g) => g.id == id);

View File

@ -6,6 +6,7 @@ import 'package:friendly_map/modules/storage/storage.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/career_panel.dart';
import '../widgets/common.dart';
import 'add_friend_screen.dart';
import 'chat_room_screen.dart';
@ -38,10 +39,13 @@ class ChatListScreen extends StatelessWidget {
body: AsyncView<List<RoomSummary>>(
load: (app) => app.chats.summaries(),
builder: (context, rooms) {
if (rooms.isEmpty) return const EmptyNote('아직 대화가 없어요.\n오른쪽 아래 버튼으로 친구와 대화를 시작해 보세요.');
return ListView(
padding: const EdgeInsets.only(top: 8, bottom: 90),
children: [for (final r in rooms) _RoomTile(r)],
children: [
const CareerNoticesPanel(),
if (rooms.isEmpty) const EmptyNote('아직 대화가 없어요.\n오른쪽 아래 버튼으로 친구와 대화를 시작해 보세요.'),
for (final r in rooms) _RoomTile(r),
],
);
},
),

View File

@ -15,11 +15,12 @@ import 'contact_import_screen.dart';
import 'person_screen.dart';
class _Data {
const _Data(this.view, this.children, this.grandchildren, this.members);
const _Data(this.view, this.children, this.grandchildren, this.members, this.formers);
final GroupView? view;
final List<GroupView> children;
final Map<String, int> grandchildren; // 하위 그룹 id → 그 아래 그룹 수
final List<Person>? members; // 닫혀 있으면 null
final List<Person> formers; // 이전 구성원 (흐리게)
}
/// 그룹 한 개: 하위 그룹과 구성원 보기·넣기·빼기. 잠긴 그룹이 닫히면 모두 감춘다.
@ -35,6 +36,7 @@ class GroupDetailScreen extends StatelessWidget {
kids,
{for (final k in kids) k.group.id: (await a.groups.children(k.group.id)).length},
await a.groups.members(groupId),
await a.groups.formers(groupId) ?? const [],
);
}
@ -153,6 +155,22 @@ class GroupDetailScreen extends StatelessWidget {
},
),
),
if (d.formers.isNotEmpty) SectionHeader('이전 구성원 ${d.formers.length}명'),
for (final p in d.formers)
Opacity(
opacity: 0.45,
child: ListTile(
leading: Avatar(p.name, size: 40, color: Pal.muted),
title: Text(p.name),
subtitle: const Text('떠남 · 지금은 이 그룹에 없어요', style: TextStyle(fontSize: 12, color: Pal.muted)),
onTap: () => pushPage(context, PersonScreen(personId: p.id)),
trailing: IconButton(
tooltip: '이전 구성원에서 지우기',
icon: const Icon(Icons.close, color: Pal.muted),
onPressed: () => app.groups.removeFormer(groupId, p.id),
),
),
),
]);
}
}

View File

@ -4,6 +4,7 @@ import 'package:friendly_map/modules/app/app.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/career_panel.dart';
import '../widgets/common.dart';
import '../widgets/group_dialogs.dart';
import '../widgets/group_menu.dart';
@ -48,6 +49,7 @@ class GroupsScreen extends StatelessWidget {
'비밀번호를 건 그룹은 열 때마다 물어보고, 닫혀 있으면 하위 그룹과 인맥 목록에서도 숨겨요. 그룹은 이 기기에만 저장돼요.',
style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)),
),
const CareerNoticesPanel(),
for (final v in d.groups)
if (v.depth == 1)
GroupTile(

View File

@ -6,6 +6,7 @@ import 'package:friendly_map/modules/storage/storage.dart';
import '../app_scope.dart';
import '../theme.dart';
import '../widgets/async_view.dart';
import '../widgets/career_panel.dart';
import '../widgets/common.dart';
import '../widgets/labels.dart';
import '../widgets/person_summary.dart';
@ -72,6 +73,7 @@ class PersonScreen extends StatelessWidget {
]),
body: ListView(padding: const EdgeInsets.only(bottom: 32), children: [
if (d.info.markDeceased) DeceasedBanner(d.info.facts),
CareerNoticesPanel(personId: p.id),
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [

View File

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart';
import '../app_scope.dart';
import '../theme.dart';
import 'async_view.dart';
import 'common.dart';
class _Item {
const _Item(this.notice, this.text);
final CareerNotice notice;
final String text;
}
/// 이직 알림 칸들: "김민수님이 한빛전자에서 누리소프트로 옮겼어요" + 옮기기 / 이전 회사에서 지우기 / 무시.
/// [personId] 를 주면 그 사람 것만 보인다. 알림이 없으면 아무것도 그리지 않는다.
class CareerNoticesPanel extends StatelessWidget {
const CareerNoticesPanel({super.key, this.personId});
final String? personId;
Future<void> _accept(BuildContext context, CareerNotice n, bool keepOld) async {
final placed = await AppScope.of(context).app.career.accept(n.id, keepOld: keepOld);
if (!context.mounted) return;
final old = n.from.isEmpty ? '' : (keepOld ? '${n.from}에서는 이전 구성원으로 흐리게 남겼어요. ' : '${n.from}에서 뺐어요. ');
showNote(context, '$old${placed == null ? '' : '$placed에 넣었어요.'}'.trim());
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context).app;
return AsyncView<List<_Item>>(
load: (a) async => [for (final n in await a.career.notices(personId: personId)) _Item(n, await a.career.describe(n))],
builder: (context, items) => Column(children: [
for (final it in items)
Panel(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Icon(Icons.swap_horiz, color: Pal.company),
const SizedBox(width: 10),
Expanded(child: Text(it.text, style: const TextStyle(fontWeight: FontWeight.w600))),
]),
const SizedBox(height: 4),
Text(
it.notice.left ? '확인하면 이전 회사 그룹에서 흐리게 남겨요.' : '확인하면 이전 회사 그룹에서는 흐리게 남기고 새 회사 그룹으로 옮겨요.',
style: const TextStyle(fontSize: 12, color: Pal.muted),
),
const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 4, children: [
FilledButton(onPressed: () => _accept(context, it.notice, true), child: Text(it.notice.left ? '확인' : '확인 · 옮기기')),
if (it.notice.from.isNotEmpty)
OutlinedButton(onPressed: () => _accept(context, it.notice, false), child: const Text('이전 회사에서 지우기')),
TextButton(onPressed: () => app.career.dismiss(it.notice.id), child: const Text('무시')),
]),
]),
),
]),
);
}
}

View File

@ -0,0 +1,110 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/core/core.dart';
import 'package:friendly_map/modules/storage/storage.dart';
import 'package:friendly_map/modules/transport/transport.dart';
import 'support/app_device.dart';
Future<void> befriend(AppDevice a, AppDevice b) async {
await a.app.friends.add(b.phone, b.name);
await AppDevice.settleAll();
await b.app.friends.accept(a.phone);
await AppDevice.settleAll();
}
void main() {
late AppDevice a, b;
late Person aOfB;
late String cardId;
/// a 가 회사 명함을 [company] 로 고쳐 다시 보낸다.
Future<void> moveTo(String company, {bool former = false}) async {
await a.app.cards.edit(cardId, fields: {'name': '김민수', 'company': company, if (former) 'former': 'true'});
for (final s in await a.app.cards.needingResend()) {
await a.app.cards.resend(s.id);
}
await AppDevice.settleAll();
}
setUp(() async {
final hub = MemoryHub();
a = await AppDevice.create(hub, '010-1111-2222', '+821011112222', '김민수');
b = await AppDevice.create(hub, '010-2222-3333', '+821022223333', '나');
await befriend(a, b);
final bOfA = (await a.app.friends.people()).single;
cardId = (await a.app.cards.mine()).firstWhere((c) => c.kind == CardKinds.company).id;
await a.app.cards.edit(cardId, fields: {'name': '김민수', 'company': '한빛전자'});
await a.app.cards.shareWith(bOfA.id, [cardId]);
await AppDevice.settleAll();
aOfB = (await b.app.friends.people()).single;
});
test('처음 받은 회사 명함은 알림이 없고, 회사가 바뀌면 이직 알림이 생긴다', () async {
expect(await b.app.career.notices(), isEmpty);
await moveTo('한빛전자');
expect(await b.app.career.notices(), isEmpty, reason: '회사가 그대로면 알림 없음');
await moveTo('누리소프트');
final n = (await b.app.career.notices()).single;
expect((n.from, n.to), ('한빛전자', '누리소프트'));
expect(await b.app.career.describe(n), '김민수님이 한빛전자에서 누리소프트로 옮겼어요');
expect(await b.app.career.notices(personId: aOfB.id), hasLength(1));
});
test('확인하면 이전 회사에서는 흐리게 남고 새 회사 아래로 옮겨지며, 연결 고리가 바뀐다', () async {
final hb = (await b.app.groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!;
final team = (await b.app.groups.add('결제팀', CardKinds.company, parent: hb.id))!;
await b.app.relate.into(aOfB.id, team.id);
await moveTo('누리소프트');
final n = (await b.app.career.notices()).single;
expect(await b.app.career.accept(n.id), '회사 › 누리소프트');
expect(await b.app.groups.members(team.id), isEmpty);
expect((await b.app.groups.formers(team.id))!.single.name, '김민수');
final links = {for (final l in await b.app.network.links(aOfB.id)) l.relation: l.detail};
expect(links, {'직장 동료': '회사 › 누리소프트', '전 직장 동료': '회사 › 한빛전자 › 결제팀'});
expect(await b.app.career.notices(), isEmpty);
expect((await b.app.groups.view(team.id))!.memberCount, 0, reason: '이전 구성원은 수에 들지 않는다');
});
test('이전 회사에서 지우기를 고르면 흐리게 남기지 않는다, 이미 있는 새 회사 그룹을 쓴다', () async {
final hb = (await b.app.groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!;
final nuri = (await b.app.groups.add('누리소프트', CardKinds.company, parent: CardKinds.company))!;
await b.app.relate.into(aOfB.id, hb.id);
await moveTo('누리소프트');
final n = (await b.app.career.notices()).single;
await b.app.career.accept(n.id, keepOld: false);
expect(await b.app.groups.formers(hb.id), isEmpty);
expect((await b.app.groups.members(nuri.id))!.single.id, aOfB.id);
expect((await b.app.network.links(aOfB.id)).map((l) => l.relation), ['직장 동료']);
});
test('퇴사(지금은 다니지 않음)는 떠남 알림, 새 회사로 넣지 않는다. 무시하면 알림만 지운다', () async {
final hb = (await b.app.groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!;
await b.app.relate.into(aOfB.id, hb.id);
await moveTo('한빛전자', former: true);
final n = (await b.app.career.notices()).single;
expect(n.left, isTrue);
expect(await b.app.career.describe(n), '김민수님이 한빛전자를 떠났어요');
expect(await b.app.career.accept(n.id), isNull);
expect((await b.app.groups.formers(hb.id))!.single.id, aOfB.id);
await moveTo('새회사');
final n2 = (await b.app.career.notices()).single;
await b.app.career.dismiss(n2.id);
expect(await b.app.career.notices(), isEmpty);
expect((await b.app.groups.formers(hb.id))!.length, 1, reason: '무시하면 그룹은 그대로');
});
test('앱을 새로 열면 이미 받은 회사 명함으로 알던 회사를 채운다 (알림 없음)', () async {
final fresh = CareerService(b.app.deps.storage, b.app.deps.clock, b.app.groups, b.app.relate, b.app.network, b.app.bus);
await fresh.seed();
expect(await fresh.notices(), isEmpty);
expect(currentCompany({'company': ' 한빛 '}), '한빛');
expect(currentCompany({'company': '한빛', 'former': 'true'}), '');
expect(withJosa('한빛', '으로', '로'), '한빛으로');
expect(withJosa('서울', '으로', '로'), '서울로');
expect(withJosa('누리', '으로', '로'), '누리로');
expect(withJosa('한빛', '을', '를'), '한빛을');
expect(withJosa('ABC', '으로', '로'), 'ABC(으)로');
expect(withJosa('ABC', '을', '를'), 'ABC을(를)');
});
}

View File

@ -193,4 +193,24 @@ void main() {
expect(d.byId('z')!.parent, isNull);
});
});
test('이전 구성원: 흐리게 남기고, 다시 넣으면 구성원으로, 잠긴 그룹이 닫히면 숨김, 저장 유지', () async {
final b = GroupBook();
b.addMembers(CardKinds.company, ['a']);
expect(b.markFormer(CardKinds.company, 'a'), isTrue);
expect(b.markFormer(CardKinds.company, 'a'), isFalse);
expect(b.byId(CardKinds.company)!.members, isEmpty);
expect(b.byId(CardKinds.company)!.formers, {'a'});
expect(GroupBook.fromJson(b.toJson()).byId(CardKinds.company)!.formers, {'a'});
b.setLock(CardKinds.company, await Pin.make('1234', iterations: 10));
expect(b.hiddenPeople((g) => true), {'a'});
b.addMembers(CardKinds.company, ['a']);
expect(b.byId(CardKinds.company)!.formers, isEmpty);
b.markFormer(CardKinds.company, 'a');
expect(b.removeFormer(CardKinds.company, 'a'), isTrue);
b.addMembers(CardKinds.school, ['z']);
b.markFormer(CardKinds.school, 'z');
b.forgetPerson('z');
expect(b.byId(CardKinds.school)!.formers, isEmpty);
});
}

View File

@ -18,7 +18,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"
[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"
[platform_android]="df_parser_test.dart"
[ui]="widget_test.dart"
)