diff --git a/CLAUDE.md b/CLAUDE.md index d001285..4bcdee8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/README.md b/README.md index 57e96e2..b867cbc 100644 --- a/README.md +++ b/README.md @@ -125,3 +125,9 @@ flutter run - 업무와 긴급 호출은 보통 대화로 간다(`📌 업무 [김민수] 결제 API 정리 (마감 10/30)`, `🚨 긴급 호출: …`). 받는 쪽 앱이 읽어서 업무 목록에 넣고(담당 이름이 내 이름이면 "내 업무"), 긴급 호출은 바로 알림을 띄운다. 새 봉투 형식은 없다. - 지난 대화를 길게 누르면 "업무로 정리"(내 목록에만). 업무 목록은 이 기기에만 저장하고, 끝냄 표시·밀어서 지우기가 된다. - 안드로이드: 긴급 호출 알림은 `flutter_local_notifications` 의 높은 중요도 채널(urgent)을 쓴다. + +## 이직 알림 +- 상대가 회사 명함을 고쳐 "다시 보내기" 하면, 이 기기가 알던 회사와 비교해 바뀌었으면 알림이 생긴다(채팅 목록 맨 위, 사람 화면, 그룹 관리). 예: "김민수님이 한빛전자에서 누리소프트로 옮겼어요", "지금은 다니지 않음"이면 "…를 떠났어요". +- "확인 · 옮기기": 이전 회사 이름이 경로에 든 회사 그룹(예: 회사 › 한빛전자 › 결제팀)에서는 이전 구성원으로 흐리게 남기고, "회사 › 새 회사" 그룹(없으면 만든다)에 넣는다. 연결 고리는 '직장 동료'가 새 회사로 바뀌고 '전 직장 동료'(이전 경로)가 남는다. +- "이전 회사에서 지우기": 흐리게 남기지 않고 뺀다. "무시": 알림만 지운다. 이전 구성원은 그룹 안에서 ✕ 로 지울 수 있다. +- 처음 받는 회사 명함, 앱을 새로 연 뒤의 첫 비교는 알림을 만들지 않는다(알던 회사로만 기억). 알던 회사·알림은 이 기기에만 저장한다. diff --git a/lib/modules/app/app.dart b/lib/modules/app/app.dart index 988c69d..a3134b8 100644 --- a/lib/modules/app/app.dart +++ b/lib/modules/app/app.dart @@ -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'; diff --git a/lib/modules/app/src/career_service.dart b/lib/modules/app/src/career_service.dart new file mode 100644 index 0000000..0392cd2 --- /dev/null +++ b/lib/modules/app/src/career_service.dart @@ -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 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 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> _known() async { + try { + return (jsonDecode(await _storage.settings.getText(_knownKey) ?? '{}') as Map).cast(); + } catch (_) { + return {}; + } + } + + Future _saveKnown(Map m) => _storage.settings.putText(_knownKey, jsonEncode(m), _clock.now()); + + Future> _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 _saveAll(List list) async { + await _storage.settings.putText(_noticesKey, jsonEncode([for (final n in list) n.toJson()]), _clock.now()); + _bus.notify(); + } + + /// 이미 받아 둔 회사 명함으로 "알던 회사"를 채운다 (앱 시작 때). 알림은 만들지 않는다. + Future 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 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> notices({String? personId}) async => + [for (final n in (await _all()).reversed) if (personId == null || n.personId == personId) n]; + + /// "김민수님이 한빛전자에서 누리소프트로 옮겼어요" + Future 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 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 dismiss(String noticeId) async { + final list = await _all(); + list.removeWhere((x) => x.id == noticeId); + await _saveAll(list); + } + + /// 이름이 [name] 인 회사 그룹 (회사 바로 아래를 먼저). 없으면 회사 아래에 만든다. + Future _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; + } +} diff --git a/lib/modules/app/src/friendly_app.dart b/lib/modules/app/src/friendly_app.dart index 22d18d6..d780ce7 100644 --- a/lib/modules/app/src/friendly_app.dart +++ b/lib/modules/app/src/friendly_app.dart @@ -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? _events; final Set> _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(); diff --git a/lib/modules/app/src/group_service.dart b/lib/modules/app/src/group_service.dart index cb571b5..d38bdcf 100644 --- a/lib/modules/app/src/group_service.dart +++ b/lib/modules/app/src/group_service.dart @@ -223,6 +223,26 @@ class GroupService { return out; } + /// 이전 구성원 (이름순). 잠겨 있으면 null. + Future?> formers(String id) async { + final b = await _load(); + final g = b.byId(id); + if (g == null || !_isOpen(b, g)) return null; + final out = []; + 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 markFormer(String id, String person) => _edit(id, (b) => b.markFormer(id, person)); + + /// 이전 구성원 표시를 지운다. + Future removeFormer(String id, String person) => _edit(id, (b) => b.removeFormer(id, person)); + /// 구성원을 더한다 (잠긴 그룹은 열려 있을 때만). 새로 들어간 수. Future addMembers(String id, Iterable people) async { final b = await _load(); diff --git a/lib/modules/groups/src/group.dart b/lib/modules/groups/src/group.dart index dbde037..c917ae6 100644 --- a/lib/modules/groups/src/group.dart +++ b/lib/modules/groups/src/group.dart @@ -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 members; // 사람 id + final Set formers; // 이전 구성원 사람 id final PinHash? lock; bool get locked => lock != null; - Group copyWith({String? name, String? kind, Set? 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? members, Set? 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 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']), ); } diff --git a/lib/modules/groups/src/group_book.dart b/lib/modules/groups/src/group_book.dart index d2c71e1..d9a33fc 100644 --- a/lib/modules/groups/src/group_book.dart +++ b/lib/modules/groups/src/group_book.dart @@ -155,14 +155,14 @@ class GroupBook { return true; } - /// 구성원을 더한다. 새로 들어간 수를 돌려준다. + /// 구성원을 더한다 (이전 구성원이었으면 다시 구성원으로). 새로 들어간 수를 돌려준다. int addMembers(String id, Iterable 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 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); diff --git a/lib/modules/ui/src/screens/chat_list_screen.dart b/lib/modules/ui/src/screens/chat_list_screen.dart index 8c0f60c..bdea2a2 100644 --- a/lib/modules/ui/src/screens/chat_list_screen.dart +++ b/lib/modules/ui/src/screens/chat_list_screen.dart @@ -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>( 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), + ], ); }, ), diff --git a/lib/modules/ui/src/screens/group_detail_screen.dart b/lib/modules/ui/src/screens/group_detail_screen.dart index 11e82a5..672c0ab 100644 --- a/lib/modules/ui/src/screens/group_detail_screen.dart +++ b/lib/modules/ui/src/screens/group_detail_screen.dart @@ -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 children; final Map grandchildren; // 하위 그룹 id → 그 아래 그룹 수 final List? members; // 닫혀 있으면 null + final List 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), + ), + ), + ), ]); } } diff --git a/lib/modules/ui/src/screens/groups_screen.dart b/lib/modules/ui/src/screens/groups_screen.dart index 4b2aec0..42814e5 100644 --- a/lib/modules/ui/src/screens/groups_screen.dart +++ b/lib/modules/ui/src/screens/groups_screen.dart @@ -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( diff --git a/lib/modules/ui/src/screens/person_screen.dart b/lib/modules/ui/src/screens/person_screen.dart index b469a36..eb355e8 100644 --- a/lib/modules/ui/src/screens/person_screen.dart +++ b/lib/modules/ui/src/screens/person_screen.dart @@ -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: [ diff --git a/lib/modules/ui/src/widgets/career_panel.dart b/lib/modules/ui/src/widgets/career_panel.dart new file mode 100644 index 0000000..05a388f --- /dev/null +++ b/lib/modules/ui/src/widgets/career_panel.dart @@ -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 _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>( + 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('무시')), + ]), + ]), + ), + ]), + ); + } +} diff --git a/test/career_service_test.dart b/test/career_service_test.dart new file mode 100644 index 0000000..7c44eab --- /dev/null +++ b/test/career_service_test.dart @@ -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 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 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을(를)'); + }); +} diff --git a/test/groups_test.dart b/test/groups_test.dart index e82865d..aa06aea 100644 --- a/test/groups_test.dart +++ b/test/groups_test.dart @@ -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); + }); } diff --git a/tool/test_module.sh b/tool/test_module.sh index fd634be..970fdb2 100644 --- a/tool/test_module.sh +++ b/tool/test_module.sh @@ -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" )