From 3ceaf11fccb474cfe126d6115ee7b5d3589eab5f Mon Sep 17 00:00:00 2001 From: diadmin Date: Mon, 26 Oct 2026 14:44:58 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=85=EB=AC=B4=20=EB=AC=B6=EC=9D=8C:=20?= =?UTF-8?q?=ED=8C=80=20=EB=8C=80=ED=99=94=EB=B0=A9,=20=EC=97=85=EB=AC=B4?= =?UTF-8?q?=20=EB=A7=A1=EA=B8=B0=EA=B8=B0=C2=B7=EC=A0=95=EB=A6=AC,=20?= =?UTF-8?q?=EA=B8=B4=EA=B8=89=20=ED=98=B8=EC=B6=9C=20=EC=A6=89=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - work 모듈(순수): 대화 속 업무 표시(📌 업무 [담당] 할 일 (마감 M/D), 🚨 긴급 호출:) 읽고 쓰기, 맡은 업무 목록 - platform: InstantNotifier 약속, platform_android: 높은 중요도 즉시 알림 - app: WorkService — 회사 그룹 → 팀 대화방(회사 명함 공유 규칙 지킴), 업무 맡기면 대화로 보내고 받는 쪽도 목록에 넣음, 긴급 호출 받으면 바로 알림, 지난 대화를 업무로 정리 - ui: 대화방 업무 메뉴, 대화 길게 눌러 업무로 정리, 긴급 호출 말풍선 강조, 맡은 업무 화면, 회사 그룹의 팀 대화방 버튼 - 시험 13개 추가 (전체 301개) Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 +- README.md | 12 +- ...otlin-compiler-14262624802283613528.salive | 0 lib/composition/android_bootstrap.dart | 1 + lib/modules/app/app.dart | 1 + lib/modules/app/src/app_deps.dart | 2 + lib/modules/app/src/friendly_app.dart | 10 + lib/modules/app/src/work_service.dart | 187 ++++++++++++++++++ lib/modules/platform/platform.dart | 1 + .../platform/src/instant_notifier.dart | 4 + .../platform_android/platform_android.dart | 1 + .../src/android_instant_notifier.dart | 33 ++++ .../ui/src/screens/chat_list_screen.dart | 2 + .../ui/src/screens/chat_room_screen.dart | 66 ++++++- .../ui/src/screens/group_detail_screen.dart | 19 +- lib/modules/ui/src/screens/tasks_screen.dart | 84 ++++++++ lib/modules/ui/src/widgets/work_dialogs.dart | 98 +++++++++ lib/modules/work/src/marker.dart | 78 ++++++++ lib/modules/work/src/task.dart | 120 +++++++++++ lib/modules/work/work.dart | 8 + test/support/app_device.dart | 4 +- test/work_service_test.dart | 118 +++++++++++ test/work_test.dart | 76 +++++++ tool/check_modules.py | 5 +- tool/test_module.sh | 3 +- 25 files changed, 928 insertions(+), 13 deletions(-) delete mode 100644 android/.kotlin/sessions/kotlin-compiler-14262624802283613528.salive create mode 100644 lib/modules/app/src/work_service.dart create mode 100644 lib/modules/platform/src/instant_notifier.dart create mode 100644 lib/modules/platform_android/src/android_instant_notifier.dart create mode 100644 lib/modules/ui/src/screens/tasks_screen.dart create mode 100644 lib/modules/ui/src/widgets/work_dialogs.dart create mode 100644 lib/modules/work/src/marker.dart create mode 100644 lib/modules/work/src/task.dart create mode 100644 lib/modules/work/work.dart create mode 100644 test/work_service_test.dart create mode 100644 test/work_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index ce1a258..d001285 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,15 +25,15 @@ Android 다크모드 인맥 앱(Flutter/Dart). 옛 "Family MAP" + 카카오톡 ## 구조 - `lib/modules/<이름>/<이름>.dart` 가 모듈의 공개 입구(예외: platform_ui.dart, storage_sqlite.dart). 다른 모듈은 입구 파일만 import. -- 모듈: core, platform, platform_android, storage, keys, sharing, sync, transport, discovery, inference, layout, visibility, kinship, events, groups, app, ui. +- 모듈: core, platform, platform_android, storage, keys, sharing, sync, transport, discovery, inference, layout, visibility, kinship, events, groups, work, app, ui. - 조립은 모듈 밖: `lib/main.dart`, `lib/composition/android_bootstrap.dart`. - 안드로이드 전용 플러그인은 platform_android 에서만 import (PLATFORM_ONLY 목록). - 화면은 `AppScope.of(context).app`(FriendlyApp)의 서비스만 쓴다. ## 상태 (2026-10-26 기준) -- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 288개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과. -- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 관계망 로드맵 1단계(그룹 계층: 학교 › 초등~대학원, 가족 › 친가·외가·처가·시가·촌수 모름, 회사 › 회사 › 팀) 끝. 2단계(채팅·사람 화면의 "관계 정하기": RelateService, 촌수 맞춰 친가·외가·처가, 촌수 모름 모음, 연결 고리) 끝. 다음은 3단계(팀 그룹↔그룹 대화방, 맡은 업무 정리, 긴급 호출), 4단계(이직 알림). -- `flutter build apk --debug` 성공(app-debug.apk 약 190MB). 첫 빌드는 SDK 34·35, CMake 를 내려받느라 약 17분 걸린다. +- 맥 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단계(받은 회사 명함 비교로 이직 알림 → 이전 회사 흐리게, 새 회사로 이동·삭제). +- `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). - pubspec.lock 은 이 컨테이너(Flutter stable)에서 풀린 판을 기준으로 올린다(2026-10-26 flutter_contacts 추가 때부터). diff --git a/README.md b/README.md index 599962d..57e96e2 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ | layout | 홈·명함 틀(템플릿) 순서/보이기/편집 | core | | kinship | 가계도로 촌수·호칭 계산(순수) | 없음 | | events | 생일·기일 일정, 알림 날짜 계획, 통화 기록 요약(순수) | 없음 | -| groups | 그룹 정의(이름·종류·순서·구성원), 비밀번호 잠금, 여러 번 틀리면 기다리기(순수) | core | +| groups | 그룹 정의(이름·종류·순서·구성원·하위 그룹), 비밀번호 잠금, 여러 번 틀리면 기다리기(순수) | core | +| work | 대화 속 업무 표시(📌 업무·🚨 긴급 호출) 읽고 쓰기, 맡은 업무 목록(순수) | 없음 | | app | 서비스 묶음(`FriendlyApp`). 화면이 쓰는 유일한 내부 입구 | 위 모듈들(키·전송 구현 제외) | -| ui | 화면. `app` 의 서비스만 쓴다 | app, core, platform, storage, discovery, inference, layout, visibility, kinship, events, groups | +| ui | 화면. `app` 의 서비스만 쓴다 | app, core, platform, storage, discovery, inference, layout, visibility, kinship, events, groups, work | 모듈 폴더 밖의 `lib/main.dart`, `lib/composition/` 이 부품을 고르는 유일한 곳이다. 암호 구현·저장소·연결 방식을 바꾸려면 `lib/composition/android_bootstrap.dart` 의 해당 줄만 바꾼다. @@ -117,3 +118,10 @@ flutter run - 형제자매를 고를 때 기준 사람의 부모가 가계도에 없으면 "부모(이름 모름)"를 이 기기 안에서만 쓰는 사람으로 만들어 잇는다. - 학교·회사·모임: 그 종류의 그룹 나무에서 골라 넣거나, + 로 그 아래 새 그룹(예: 학교 › 고등학교 › 가람고 27회, 회사 › 한빛전자 › 결제팀)을 만들어 바로 넣는다. - 넣으면 연결 고리(가족·동문·직장 동료·같은 모임)가 하나 남아, 공개 범위 표가 그 관계로 적용된다. 같은 종류의 그룹에서 모두 빠지면 연결 고리도 지운다. + +## 업무 · 팀 · 긴급 호출 +- 회사 종류 그룹(예: 회사 › 한빛전자 › 결제팀) 안의 "팀 대화방"을 누르면 구성원과 회사 맥락의 대화방이 생긴다. 회사 명함을 아직 공유하지 않은 사람이 있으면 그 이름을 알려 주고 만들지 않는다. +- 대화방 위 업무 메뉴: "📌 업무 맡기기"(할 일·담당·마감), "🚨 긴급 호출", "이 방 업무 보기". 채팅 목록 위 체크 아이콘은 모든 방의 "맡은 업무". +- 업무와 긴급 호출은 보통 대화로 간다(`📌 업무 [김민수] 결제 API 정리 (마감 10/30)`, `🚨 긴급 호출: …`). 받는 쪽 앱이 읽어서 업무 목록에 넣고(담당 이름이 내 이름이면 "내 업무"), 긴급 호출은 바로 알림을 띄운다. 새 봉투 형식은 없다. +- 지난 대화를 길게 누르면 "업무로 정리"(내 목록에만). 업무 목록은 이 기기에만 저장하고, 끝냄 표시·밀어서 지우기가 된다. +- 안드로이드: 긴급 호출 알림은 `flutter_local_notifications` 의 높은 중요도 채널(urgent)을 쓴다. diff --git a/android/.kotlin/sessions/kotlin-compiler-14262624802283613528.salive b/android/.kotlin/sessions/kotlin-compiler-14262624802283613528.salive deleted file mode 100644 index e69de29..0000000 diff --git a/lib/composition/android_bootstrap.dart b/lib/composition/android_bootstrap.dart index 8cdb70b..41a60b3 100644 --- a/lib/composition/android_bootstrap.dart +++ b/lib/composition/android_bootstrap.dart @@ -82,6 +82,7 @@ class AndroidBootstrap implements Bootstrap { phone: AndroidPhoneActions(), notifier: AndroidNotifier(), contacts: AndroidContacts(), + instant: AndroidInstantNotifier(), verifier: LatePhoneVerifier(() async { final me = await storage.settings.loadProfile(); final url = (await storage.settings.getText(mailboxUrlKey))?.trim() ?? ''; diff --git a/lib/modules/app/app.dart b/lib/modules/app/app.dart index 1679cf6..988c69d 100644 --- a/lib/modules/app/app.dart +++ b/lib/modules/app/app.dart @@ -27,3 +27,4 @@ export 'src/relate_service.dart'; export 'src/settings_service.dart'; export 'src/verify_service.dart'; export 'src/visibility_service.dart'; +export 'src/work_service.dart'; diff --git a/lib/modules/app/src/app_deps.dart b/lib/modules/app/src/app_deps.dart index d40070c..f731a6b 100644 --- a/lib/modules/app/src/app_deps.dart +++ b/lib/modules/app/src/app_deps.dart @@ -19,6 +19,7 @@ class AppDeps { this.phone, this.notifier, this.contacts, + this.instant, Clock? clock, }) : clock = clock ?? Clock(); @@ -35,5 +36,6 @@ class AppDeps { final PhoneActions? phone; final Notifier? notifier; final ContactsProvider? contacts; + final InstantNotifier? instant; final Clock clock; } diff --git a/lib/modules/app/src/friendly_app.dart b/lib/modules/app/src/friendly_app.dart index ffafc17..22d18d6 100644 --- a/lib/modules/app/src/friendly_app.dart +++ b/lib/modules/app/src/friendly_app.dart @@ -23,6 +23,7 @@ import 'relate_service.dart'; import 'settings_service.dart'; import 'verify_service.dart'; import 'visibility_service.dart'; +import 'work_service.dart'; /// 앱 내부의 입구. 화면은 이 객체의 서비스들만 쓴다 (저장소·전송·암호를 직접 만지지 않는다). /// @@ -60,6 +61,7 @@ class FriendlyApp { groups = GroupService(storage, clock, bus); 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); } final AppDeps deps; @@ -83,6 +85,7 @@ class FriendlyApp { late final GroupService groups; late final ContactImportService contacts; late final RelateService relate; + late final WorkService work; StreamSubscription? _events; final Set> _tasks = {}; @@ -98,6 +101,13 @@ class FriendlyApp { _tasks.add(f); unawaited(f.whenComplete(() => _tasks.remove(f))); } else { + // 받은 대화에 업무 표시·긴급 호출이 있으면 업무 쪽이 처리한다. + final id = e.refId; + if (e.type == SyncEventType.message && id != null) { + final f = work.onMessage(id).catchError((Object _) {}); + _tasks.add(f); + unawaited(f.whenComplete(() => _tasks.remove(f))); + } bus.notify(); } }); diff --git a/lib/modules/app/src/work_service.dart b/lib/modules/app/src/work_service.dart new file mode 100644 index 0000000..d2da6c7 --- /dev/null +++ b/lib/modules/app/src/work_service.dart @@ -0,0 +1,187 @@ +import 'dart:convert'; + +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:friendly_map/modules/sharing/sharing.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; +import 'package:friendly_map/modules/work/work.dart'; + +import 'change_bus.dart'; +import 'chat_service.dart'; +import 'group_service.dart'; + +/// 팀 대화방 열기 결과. 회사 명함을 아직 서로 공유하지 않은 사람이 있으면 [missing] 에 담고 방은 만들지 않는다. +class TeamRoomResult { + const TeamRoomResult({this.room, this.missing = const [], this.closed = false}); + final ChatRoom? room; + final List missing; + final bool closed; // 잠긴 그룹이 닫혀 있음 +} + +/// 업무로 묶인 사람들: 그룹 → 팀 대화방, 업무 맡기기·정리, 긴급 호출. +/// +/// 업무 표시는 보통 대화로 오가고(📌 업무 …, 🚨 긴급 호출: …), 받는 쪽은 그 대화를 읽어 자기 업무 목록에 넣는다. +/// 업무 목록과 그룹-방 이음은 이 기기에만 저장한다. +class WorkService { + WorkService(this._storage, this._clock, this._share, this._chats, this._groups, this._bus, [this._instant]); + + static const _tasksKey = 'work.tasks'; + static const _roomsKey = 'work.rooms'; + + final Storage _storage; + final Clock _clock; + final ShareService _share; + final ChatService _chats; + final GroupService _groups; + final ChangeBus _bus; + final InstantNotifier? _instant; + + Future _book() async => TaskBook.fromJson(await _storage.settings.getText(_tasksKey)); + + Future _saveBook(TaskBook b) async { + await _storage.settings.putText(_tasksKey, b.toJson(), _clock.now()); + _bus.notify(); + } + + Future> _rooms() async { + final t = await _storage.settings.getText(_roomsKey); + if (t == null) return {}; + try { + return (jsonDecode(t) as Map).cast(); + } catch (_) { + return {}; + } + } + + /// 그룹의 팀 대화방 (회사 맥락). 없으면 구성원으로 새로 만든다. + Future teamRoom(String groupId) async { + final rooms = await _rooms(); + final known = rooms[groupId]; + if (known != null) { + final r = await _storage.rooms.get(known); + if (r != null) return TeamRoomResult(room: r); + } + final v = await _groups.view(groupId); + final members = await _groups.members(groupId); + if (v == null || members == null) return const TeamRoomResult(closed: true); + if (members.isEmpty) return const TeamRoomResult(); + final missing = [ + for (final m in members) + if (!await _share.canStartContextRoom(CardKinds.company, m.id)) m, + ]; + if (missing.isNotEmpty) return TeamRoomResult(missing: missing); + final room = members.length == 1 + ? await _chats.openDirect(members.single.id, CardKinds.company) + : await _chats.createGroup(v.path, [for (final m in members) m.id], CardKinds.company); + if (room == null) return const TeamRoomResult(); + rooms[groupId] = room.id; + await _storage.settings.putText(_roomsKey, jsonEncode(rooms), _clock.now()); + _bus.notify(); + return TeamRoomResult(room: room); + } + + /// 이 방이 어느 그룹의 팀 대화방인지. + Future groupOfRoom(String roomId) async { + for (final e in (await _rooms()).entries) { + if (e.value == roomId) return e.key; + } + return null; + } + + /// 방의 사람들 (업무를 맡길 사람 고르기용). + Future> roomPeople(String roomId) async { + final room = await _storage.rooms.get(roomId); + if (room == null) return const []; + final out = []; + for (final id in (jsonDecode(room.membersJson) as List).whereType()) { + final p = await _storage.people.get(id); + if (p != null) out.add(p); + } + return out; + } + + Future _nameOf(String? id) async { + if (id == null) return null; + if (id == Task.me) return (await _storage.settings.loadProfile())?.name; + return (await _storage.people.get(id))?.name; + } + + /// 업무를 맡긴다: 방에 업무 표시 대화를 보내고 내 업무 목록에도 적는다. + Future assign(String roomId, String title, {String? assigneeId, DateTime? due}) async { + if (title.trim().isEmpty) return null; + final name = await _nameOf(assigneeId); + final m = await _chats.send(roomId, TaskNote(title.trim(), assignee: name, due: due).encode()); + if (m == null) return null; + final t = Task( + id: newId(), title: title.trim(), createdAt: _clock.now(), roomId: roomId, messageId: m.id, + assigneeId: assigneeId, assigneeName: name, due: due); + final b = await _book(); + b.add(t); + await _saveBook(b); + return t; + } + + /// 긴급 호출을 보낸다 (받는 쪽 기기에서 바로 알림이 뜬다). + Future urgent(String roomId, String text) => _chats.send(roomId, UrgentNote(text.trim()).encode()); + + /// 지난 대화 하나를 업무로 정리한다 (내 목록에만, 상대에게는 아무것도 가지 않는다). + Future fromMessage(String messageId, {String? assigneeId}) async { + final m = await _storage.messages.get(messageId); + if (m == null) return null; + final note = WorkNote.parse(m.body); + final title = switch (note) { + TaskNote(:final title) => title, + UrgentNote(:final text) => text, + null => m.body.trim(), + }; + if (title.isEmpty) return null; + final who = assigneeId ?? (note is TaskNote ? await _resolve(note.assignee, m.roomId) : null); + final t = Task( + id: newId(), title: title.length > 120 ? title.substring(0, 120) : title, createdAt: _clock.now(), + roomId: m.roomId, messageId: m.id, assigneeId: who, assigneeName: await _nameOf(who) ?? (note is TaskNote ? note.assignee : null), + due: note is TaskNote ? note.due : null); + final b = await _book(); + if (!b.add(t)) return null; + await _saveBook(b); + return t; + } + + /// 이름 → 나(Task.me) 또는 방의 사람 id. + Future _resolve(String? name, String roomId) async { + if (name == null) return null; + if ((await _storage.settings.loadProfile())?.name == name) return Task.me; + for (final p in await roomPeople(roomId)) { + if (p.name == name) return p.id; + } + return null; + } + + /// 받은 대화를 살핀다 (동기화가 대화를 반영한 뒤 부른다). 업무 표시면 목록에, 긴급 호출이면 바로 알림. + Future onMessage(String messageId) async { + final m = await _storage.messages.get(messageId); + if (m == null || m.senderId == ChatService.me) return; + final note = WorkNote.parse(m.body); + if (note is TaskNote) { + await fromMessage(messageId); + } else if (note is UrgentNote) { + final who = (await _storage.people.get(m.senderId))?.name ?? '팀원'; + await _instant?.showNow('🚨 긴급 호출 · $who', note.text.isEmpty ? '확인이 필요해요' : note.text); + } + } + + Future> tasks({String? roomId, bool onlyMine = false, bool includeDone = false}) async => + (await _book()).query(roomId: roomId, onlyMine: onlyMine, includeDone: includeDone); + + Future setDone(String taskId, bool done) async { + final b = await _book(); + if (b.setDone(taskId, done)) await _saveBook(b); + } + + Future remove(String taskId) async { + final b = await _book(); + if (b.remove(taskId)) await _saveBook(b); + } + + /// 화면용: 이 대화가 긴급 호출인지, 업무 표시인지. + static WorkNote? noteOf(String body) => WorkNote.parse(body); +} diff --git a/lib/modules/platform/platform.dart b/lib/modules/platform/platform.dart index f986c76..eebefb5 100644 --- a/lib/modules/platform/platform.dart +++ b/lib/modules/platform/platform.dart @@ -8,6 +8,7 @@ library; export 'src/call_log_provider.dart'; export 'src/contacts_provider.dart'; export 'src/device_storage_probe.dart'; +export 'src/instant_notifier.dart'; export 'src/lan_discovery.dart'; export 'src/location_provider.dart'; export 'src/motion_sensor.dart'; diff --git a/lib/modules/platform/src/instant_notifier.dart b/lib/modules/platform/src/instant_notifier.dart new file mode 100644 index 0000000..2d84f9e --- /dev/null +++ b/lib/modules/platform/src/instant_notifier.dart @@ -0,0 +1,4 @@ +/// 지금 바로 띄우는 기기 알림 (긴급 호출 등). 예약 알림([Notifier])과 따로 둔다. +abstract class InstantNotifier { + Future showNow(String title, String text); +} diff --git a/lib/modules/platform_android/platform_android.dart b/lib/modules/platform_android/platform_android.dart index 9fbd959..4bb367d 100644 --- a/lib/modules/platform_android/platform_android.dart +++ b/lib/modules/platform_android/platform_android.dart @@ -8,6 +8,7 @@ library; export 'src/android_call_log.dart'; export 'src/android_contacts.dart'; export 'src/android_database.dart'; +export 'src/android_instant_notifier.dart'; export 'src/android_lan_discovery.dart'; export 'src/android_location_provider.dart'; export 'src/android_motion_sensor.dart'; diff --git a/lib/modules/platform_android/src/android_instant_notifier.dart b/lib/modules/platform_android/src/android_instant_notifier.dart new file mode 100644 index 0000000..94b11e8 --- /dev/null +++ b/lib/modules/platform_android/src/android_instant_notifier.dart @@ -0,0 +1,33 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; + +/// 안드로이드 즉시 알림 (긴급 호출). 소리·진동이 나는 높은 중요도 채널을 쓴다. +class AndroidInstantNotifier implements InstantNotifier { + final _plugin = FlutterLocalNotificationsPlugin(); + bool _ready = false; + int _id = 1000; + + Future _init() async { + if (_ready) return; + await _plugin.initialize(const InitializationSettings(android: AndroidInitializationSettings('@mipmap/ic_launcher'))); + final a = _plugin.resolvePlatformSpecificImplementation(); + await a?.requestNotificationsPermission(); + _ready = true; + } + + @override + Future showNow(String title, String text) async { + try { + await _init(); + await _plugin.show( + _id++, + title, + text, + const NotificationDetails( + android: AndroidNotificationDetails('urgent', '긴급 호출', + channelDescription: '팀원이 보낸 긴급 호출', importance: Importance.max, priority: Priority.high), + ), + ); + } catch (_) {} + } +} diff --git a/lib/modules/ui/src/screens/chat_list_screen.dart b/lib/modules/ui/src/screens/chat_list_screen.dart index 9fddd4b..8c0f60c 100644 --- a/lib/modules/ui/src/screens/chat_list_screen.dart +++ b/lib/modules/ui/src/screens/chat_list_screen.dart @@ -9,6 +9,7 @@ import '../widgets/async_view.dart'; import '../widgets/common.dart'; import 'add_friend_screen.dart'; import 'chat_room_screen.dart'; +import 'tasks_screen.dart'; /// 채팅 목록. class ChatListScreen extends StatelessWidget { @@ -20,6 +21,7 @@ class ChatListScreen extends StatelessWidget { appBar: AppBar( title: const Text('채팅', style: TextStyle(fontWeight: FontWeight.w700)), actions: [ + IconButton(tooltip: '맡은 업무', onPressed: () => pushPage(context, const TasksScreen()), icon: const Icon(Icons.checklist)), IconButton( tooltip: '새 친구', onPressed: () => pushPage(context, const AddFriendScreen()), diff --git a/lib/modules/ui/src/screens/chat_room_screen.dart b/lib/modules/ui/src/screens/chat_room_screen.dart index 366f377..a588305 100644 --- a/lib/modules/ui/src/screens/chat_room_screen.dart +++ b/lib/modules/ui/src/screens/chat_room_screen.dart @@ -4,13 +4,18 @@ import 'package:flutter/material.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/work/work.dart'; import '../app_scope.dart'; import '../theme.dart'; import '../widgets/async_view.dart'; import '../widgets/common.dart'; import '../widgets/relate_sheet.dart'; +import '../widgets/work_dialogs.dart'; import 'archive_screen.dart'; +import 'tasks_screen.dart'; + +enum _Work { assign, urgent, tasks } class _RoomData { const _RoomData(this.room, this.messages, this.advice); @@ -45,6 +50,45 @@ class _ChatRoomScreenState extends State { await AppScope.of(context).app.chats.send(widget.roomId, text); } + Future _work(_Work w, ChatRoom room) async { + final app = AppScope.of(context).app; + switch (w) { + case _Work.assign: + final people = await app.work.roomPeople(room.id); + if (!mounted) return; + final r = await askAssign(context, people); + if (r == null) return; + await app.work.assign(room.id, r.title, assigneeId: r.assigneeId, due: r.due); + case _Work.urgent: + final text = await askUrgent(context); + if (text == null) return; + await app.work.urgent(room.id, text); + case _Work.tasks: + pushPage(context, TasksScreen(roomId: room.id, title: '${room.title} 업무')); + } + } + + Future _toTask(Message m) async { + final app = AppScope.of(context).app; + final ok = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (c) => SafeArea( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Text(m.body, maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Pal.muted)), + ), + ListTile(leading: const Icon(Icons.push_pin_outlined), title: const Text('업무로 정리 (내가 할 일)'), onTap: () => Navigator.pop(c, true)), + ListTile(leading: const Icon(Icons.inbox_outlined), title: const Text('업무로 정리 (담당 없음)'), onTap: () => Navigator.pop(c, false)), + ]), + ), + ); + if (ok == null) return; + final t = await app.work.fromMessage(m.id, assigneeId: ok ? Task.me : null); + if (mounted) showNote(context, t == null ? '이미 업무로 정리한 대화예요' : '업무 목록에 넣었어요'); + } + Future<_RoomData> _load(FriendlyApp app) async { final rooms = await app.chats.summaries(); ChatRoom? room; @@ -79,6 +123,17 @@ class _ChatRoomScreenState extends State { if (ids.isNotEmpty) showRelateSheet(context, ids.first, room.title); }, ), + if (room != null) + PopupMenuButton<_Work>( + tooltip: '업무', + icon: const Icon(Icons.work_outline), + onSelected: (w) => _work(w, room), + itemBuilder: (_) => const [ + PopupMenuItem(value: _Work.assign, child: Text('📌 업무 맡기기')), + PopupMenuItem(value: _Work.urgent, child: Text('🚨 긴급 호출')), + PopupMenuItem(value: _Work.tasks, child: Text('이 방 업무 보기')), + ], + ), ], ), body: Column(children: [ @@ -105,7 +160,10 @@ class _ChatRoomScreenState extends State { reverse: true, padding: const EdgeInsets.all(12), itemCount: d.messages.length, - itemBuilder: (_, i) => _Bubble(d.messages[d.messages.length - 1 - i]), + itemBuilder: (_, i) { + final m = d.messages[d.messages.length - 1 - i]; + return GestureDetector(onLongPress: () => _toTask(m), child: _Bubble(m)); + }, ), ), SafeArea( @@ -142,6 +200,8 @@ class _Bubble extends StatelessWidget { @override Widget build(BuildContext context) { final mine = m.senderId == ChatService.me; + final note = WorkService.noteOf(m.body); + final urgent = note is UrgentNote; return Align( alignment: mine ? Alignment.centerRight : Alignment.centerLeft, child: Container( @@ -151,9 +211,11 @@ class _Bubble extends StatelessWidget { decoration: BoxDecoration( color: mine ? Pal.accent : Pal.surface, borderRadius: BorderRadius.circular(14), - border: mine ? null : Border.all(color: Pal.line), + border: urgent ? Border.all(color: Pal.danger, width: 2) : (mine ? null : Border.all(color: Pal.line)), ), child: Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + if (note is TaskNote) + Text(note.assignee == null ? '업무' : '업무 · ${note.assignee}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: mine ? Colors.black54 : Pal.accent)), Text(m.body, style: TextStyle(color: mine ? Colors.black : Pal.text, height: 1.4)), const SizedBox(height: 2), Text(shortTime(m.sentAt), style: TextStyle(fontSize: 10, color: mine ? Colors.black54 : Pal.muted)), diff --git a/lib/modules/ui/src/screens/group_detail_screen.dart b/lib/modules/ui/src/screens/group_detail_screen.dart index c499662..11e82a5 100644 --- a/lib/modules/ui/src/screens/group_detail_screen.dart +++ b/lib/modules/ui/src/screens/group_detail_screen.dart @@ -10,6 +10,7 @@ import '../widgets/common.dart'; import '../widgets/group_dialogs.dart'; import '../widgets/group_menu.dart'; import '../widgets/pin_dialog.dart'; +import 'chat_room_screen.dart'; import 'contact_import_screen.dart'; import 'person_screen.dart'; @@ -48,6 +49,19 @@ class GroupDetailScreen extends StatelessWidget { if (context.mounted) showNote(context, '$n명을 넣었어요'); } + Future _teamRoom(BuildContext context) async { + final r = await AppScope.of(context).app.work.teamRoom(groupId); + if (!context.mounted) return; + final room = r.room; + if (room != null) { + pushPage(context, ChatRoomScreen(roomId: room.id)); + } else if (r.missing.isNotEmpty) { + showNote(context, '회사 명함을 먼저 공유해 주세요: ${r.missing.map((p) => p.name).join(', ')}'); + } else { + showNote(context, r.closed ? '잠긴 그룹이에요' : '이 그룹에 사람을 먼저 넣어 주세요'); + } + } + @override Widget build(BuildContext context) { final app = AppScope.of(context).app; @@ -105,7 +119,10 @@ class GroupDetailScreen extends StatelessWidget { : null), for (final c in d.children) GroupTile(c, children: d.grandchildren[c.group.id] ?? 0, onOpen: () => pushPage(context, GroupDetailScreen(groupId: c.group.id))), - SectionHeader('사람 ${members.length}명'), + SectionHeader('사람 ${members.length}명', + trailing: g.kind == CardKinds.company && members.isNotEmpty + ? TextButton.icon(onPressed: () => _teamRoom(context), icon: const Icon(Icons.forum_outlined, size: 18), label: const Text('팀 대화방')) + : null), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Row(children: [ diff --git a/lib/modules/ui/src/screens/tasks_screen.dart b/lib/modules/ui/src/screens/tasks_screen.dart new file mode 100644 index 0000000..2cf9633 --- /dev/null +++ b/lib/modules/ui/src/screens/tasks_screen.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/work/work.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import 'chat_room_screen.dart'; + +/// 맡은 업무: 내 업무 / 전체, 끝난 것 보기. [roomId] 를 주면 그 방의 업무만. +class TasksScreen extends StatefulWidget { + const TasksScreen({super.key, this.roomId, this.title = '맡은 업무'}); + + final String? roomId; + final String title; + + @override + State createState() => _TasksScreenState(); +} + +class _TasksScreenState extends State { + bool _onlyMine = false; + bool _done = false; + + String _due(DateTime d) { + final today = DateTime.now(); + final left = DateTime(d.year, d.month, d.day).difference(DateTime(today.year, today.month, today.day)).inDays; + final when = left == 0 ? '오늘' : left < 0 ? '${-left}일 지남' : 'D-$left'; + return '마감 ${d.month}/${d.day} · $when'; + } + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context).app; + return Scaffold( + appBar: AppBar(title: Text(widget.title)), + body: Column(children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Row(children: [ + ChoiceChip(label: const Text('전체'), selected: !_onlyMine, onSelected: (_) => setState(() => _onlyMine = false)), + const SizedBox(width: 6), + ChoiceChip(label: const Text('내 업무'), selected: _onlyMine, onSelected: (_) => setState(() => _onlyMine = true)), + const Spacer(), + FilterChip(label: const Text('끝난 것도'), selected: _done, onSelected: (v) => setState(() => _done = v)), + ]), + ), + Expanded( + child: AsyncView>( + key: ValueKey('$_onlyMine$_done'), + load: (a) => a.work.tasks(roomId: widget.roomId, onlyMine: _onlyMine, includeDone: _done), + builder: (context, list) { + if (list.isEmpty) return const EmptyNote('맡은 업무가 없어요.\n대화방 메뉴의 "업무 맡기기"나, 대화를 길게 눌러 "업무로 정리"해 보세요.'); + return ListView(padding: const EdgeInsets.only(bottom: 32), children: [ + for (final t in list) + Dismissible( + key: ValueKey(t.id), + direction: DismissDirection.endToStart, + background: Container(alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 20), color: Pal.danger.withValues(alpha: 0.3), child: const Icon(Icons.delete_outline)), + onDismissed: (_) => app.work.remove(t.id), + child: CheckboxListTile( + value: t.done, + onChanged: (v) => app.work.setDone(t.id, v ?? false), + title: Text(t.title, style: TextStyle(decoration: t.done ? TextDecoration.lineThrough : null, color: t.done ? Pal.muted : null)), + subtitle: Text( + [ + t.mine ? '담당: 나' : (t.assigneeName == null ? '담당 없음' : '담당: ${t.assigneeName}'), + if (t.due != null) _due(t.due!), + ].join(' · '), + style: TextStyle(fontSize: 12, color: t.due != null && !t.done && t.due!.isBefore(DateTime.now()) ? Pal.danger : Pal.muted), + ), + secondary: t.roomId == null || widget.roomId != null + ? null + : IconButton(tooltip: '대화 보기', icon: const Icon(Icons.chat_bubble_outline), onPressed: () => pushPage(context, ChatRoomScreen(roomId: t.roomId!))), + ), + ), + ]); + }, + ), + ), + ]), + ); + } +} diff --git a/lib/modules/ui/src/widgets/work_dialogs.dart b/lib/modules/ui/src/widgets/work_dialogs.dart new file mode 100644 index 0000000..01a005a --- /dev/null +++ b/lib/modules/ui/src/widgets/work_dialogs.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; +import 'package:friendly_map/modules/work/work.dart'; + +import '../theme.dart'; + +/// 업무 맡기기에서 고른 값. +class AssignInput { + const AssignInput(this.title, this.assigneeId, this.due); + final String title; + final String? assigneeId; // 사람 id, Task.me, 또는 null(담당 없음) + final DateTime? due; +} + +/// 업무 맡기기: 할 일, 담당(방의 사람·나·없음), 마감. +Future askAssign(BuildContext context, List people) => + showDialog(context: context, builder: (_) => _AssignDialog(people)); + +class _AssignDialog extends StatefulWidget { + const _AssignDialog(this.people); + final List people; + + @override + State<_AssignDialog> createState() => _AssignDialogState(); +} + +class _AssignDialogState extends State<_AssignDialog> { + final _ctl = TextEditingController(); + String? _who; + DateTime? _due; + + @override + void dispose() { + _ctl.dispose(); + super.dispose(); + } + + Future _pickDue() async { + final now = DateTime.now(); + final d = await showDatePicker(context: context, firstDate: DateTime(now.year, now.month, now.day), lastDate: DateTime(now.year + 2), initialDate: _due ?? now); + if (d != null) setState(() => _due = d); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('업무 맡기기'), + content: SingleChildScrollView( + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + TextField(controller: _ctl, autofocus: true, maxLength: 80, decoration: const InputDecoration(labelText: '할 일 (예: 결제 API 정리)', isDense: true)), + const Text('담당', style: TextStyle(fontSize: 12, color: Pal.muted)), + const SizedBox(height: 6), + Wrap(spacing: 6, runSpacing: 6, children: [ + ChoiceChip(label: const Text('없음'), selected: _who == null, onSelected: (_) => setState(() => _who = null)), + ChoiceChip(label: const Text('나'), selected: _who == Task.me, onSelected: (_) => setState(() => _who = Task.me)), + for (final p in widget.people) + ChoiceChip(label: Text(p.name), selected: _who == p.id, onSelected: (_) => setState(() => _who = p.id)), + ]), + const SizedBox(height: 10), + Row(children: [ + Expanded(child: Text(_due == null ? '마감 없음' : '마감 ${_due!.month}/${_due!.day}', style: const TextStyle(fontSize: 13))), + TextButton(onPressed: _pickDue, child: const Text('마감 고르기')), + if (_due != null) IconButton(tooltip: '마감 지우기', onPressed: () => setState(() => _due = null), icon: const Icon(Icons.close, size: 18)), + ]), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('취소')), + TextButton( + onPressed: () => _ctl.text.trim().isEmpty ? null : Navigator.pop(context, AssignInput(_ctl.text.trim(), _who, _due)), + child: const Text('보내기'), + ), + ], + ); + } +} + +/// 긴급 호출 글을 묻는다. +Future askUrgent(BuildContext context) async { + final ctl = TextEditingController(); + final r = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: const Text('🚨 긴급 호출'), + content: Column(mainAxisSize: MainAxisSize.min, children: [ + const Text('이 방 사람들의 휴대폰에 바로 알림이 떠요. 꼭 필요할 때만 써 주세요.', style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)), + const SizedBox(height: 8), + TextField(controller: ctl, autofocus: true, maxLength: 80, decoration: const InputDecoration(hintText: '예: 결제 서버가 멈췄어요', isDense: true)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(c), child: const Text('취소')), + TextButton(onPressed: () => Navigator.pop(c, ctl.text), child: const Text('호출하기', style: TextStyle(color: Pal.danger))), + ], + ), + ); + ctl.dispose(); + return r; +} diff --git a/lib/modules/work/src/marker.dart b/lib/modules/work/src/marker.dart new file mode 100644 index 0000000..f71592a --- /dev/null +++ b/lib/modules/work/src/marker.dart @@ -0,0 +1,78 @@ +/// 대화 글자에 붙이는 업무 표시. 새 봉투 형식 없이 보통 대화로 오가고, 받는 쪽이 읽어서 업무 목록에 넣는다. +/// +/// 📌 업무 [김민수] 결제 API 정리 (마감 10/30) +/// 🚨 긴급 호출: 서버가 멈췄어요 +sealed class WorkNote { + const WorkNote(); + + /// 대화 글자로. + String encode(); + + /// 대화 글자에서 읽는다. 표시가 없으면 null. [now] 는 마감 연도를 정할 때 쓴다. + static WorkNote? parse(String body, {DateTime? now}) { + final t = body.trim(); + if (t.startsWith(UrgentNote.prefix)) { + final text = t.substring(UrgentNote.prefix.length).trim(); + return UrgentNote(text); + } + if (t.startsWith(TaskNote.prefix)) return TaskNote._parse(t.substring(TaskNote.prefix.length).trim(), now: now); + return null; + } +} + +/// 긴급 호출. +class UrgentNote extends WorkNote { + const UrgentNote(this.text); + static const prefix = '🚨 긴급 호출:'; + final String text; + + @override + String encode() => text.isEmpty ? prefix : '$prefix $text'; +} + +/// 업무 맡기기. [assignee] 는 이름(받는 쪽 기기에서 사람을 찾는 데 쓴다), [due] 는 마감 날짜. +class TaskNote extends WorkNote { + const TaskNote(this.title, {this.assignee, this.due}); + static const prefix = '📌 업무'; + final String title; + final String? assignee; + final DateTime? due; + + @override + String encode() { + final b = StringBuffer(prefix); + if (assignee != null && assignee!.trim().isNotEmpty) b.write(' [${assignee!.trim().replaceAll(']', '')}]'); + b.write(' ${title.trim()}'); + if (due != null) b.write(' (마감 ${due!.month}/${due!.day})'); + return b.toString(); + } + + static final _who = RegExp(r'^\[([^\]]*)\]\s*'); + static final _due = RegExp(r'\s*\(마감 (\d{1,2})/(\d{1,2})\)\s*$'); + + static TaskNote? _parse(String rest, {DateTime? now}) { + var s = rest; + String? who; + final w = _who.firstMatch(s); + if (w != null) { + who = w.group(1)!.trim(); + s = s.substring(w.end); + } + DateTime? due; + final d = _due.firstMatch(s); + if (d != null) { + final m = int.parse(d.group(1)!), day = int.parse(d.group(2)!); + if (m >= 1 && m <= 12 && day >= 1 && day <= 31) due = dueFrom(m, day, now ?? DateTime.now()); + s = s.substring(0, d.start); + } + s = s.trim(); + if (s.isEmpty) return null; + return TaskNote(s, assignee: who == null || who.isEmpty ? null : who, due: due); + } + + /// "10/30" 은 [now] 기준 가장 가까운 앞날 (지난 지 두 달이 넘었으면 내년). + static DateTime dueFrom(int month, int day, DateTime now) { + final thisYear = DateTime(now.year, month, day); + return now.difference(thisYear).inDays > 60 ? DateTime(now.year + 1, month, day) : thisYear; + } +} diff --git a/lib/modules/work/src/task.dart b/lib/modules/work/src/task.dart new file mode 100644 index 0000000..e0ebeb6 --- /dev/null +++ b/lib/modules/work/src/task.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; + +/// 맡은 업무 하나. [assigneeId] 는 사람 id 또는 [me], 모르면 null(이름만 앎). +class Task { + const Task({ + required this.id, + required this.title, + required this.createdAt, + this.roomId, + this.messageId, + this.assigneeId, + this.assigneeName, + this.due, + this.done = false, + }); + + static const me = 'me'; + + final String id; + final String title; + final int createdAt; // 1/100초 + final String? roomId; + final String? messageId; // 이 업무가 나온 대화 + final String? assigneeId; + final String? assigneeName; + final DateTime? due; + final bool done; + + bool get mine => assigneeId == me; + + Task withDone(bool v) => Task( + id: id, title: title, createdAt: createdAt, roomId: roomId, messageId: messageId, + assigneeId: assigneeId, assigneeName: assigneeName, due: due, done: v); + + Map toJson() => { + 'id': id, + 't': title, + 'at': createdAt, + if (roomId != null) 'room': roomId, + if (messageId != null) 'msg': messageId, + if (assigneeId != null) 'who': assigneeId, + if (assigneeName != null) 'name': assigneeName, + if (due != null) 'due': '${due!.year}-${due!.month}-${due!.day}', + if (done) 'done': true, + }; + + static Task? fromJson(Object? j) { + if (j is! Map) return null; + final id = j['id'], t = j['t'], at = j['at']; + if (id is! String || t is! String || at is! int) return null; + DateTime? due; + final d = j['due']; + if (d is String) { + final p = d.split('-').map(int.tryParse).toList(); + if (p.length == 3 && !p.contains(null)) due = DateTime(p[0]!, p[1]!, p[2]!); + } + String? s(Object? v) => v is String ? v : null; + return Task( + id: id, title: t, createdAt: at, roomId: s(j['room']), messageId: s(j['msg']), + assigneeId: s(j['who']), assigneeName: s(j['name']), due: due, done: j['done'] == true, + ); + } +} + +/// 맡은 업무 목록. 같은 대화에서 나온 업무는 한 번만 넣는다. +class TaskBook { + TaskBook([List? tasks]) : _tasks = [...?tasks]; + + final List _tasks; + + List get all => List.unmodifiable(_tasks); + + /// 넣었으면 true (같은 id 나 같은 대화의 업무가 이미 있으면 false). + bool add(Task t) { + if (_tasks.any((x) => x.id == t.id || (t.messageId != null && x.messageId == t.messageId))) return false; + _tasks.add(t); + return true; + } + + bool setDone(String id, bool done) { + final i = _tasks.indexWhere((t) => t.id == id); + if (i < 0) return false; + _tasks[i] = _tasks[i].withDone(done); + return true; + } + + bool remove(String id) { + final n = _tasks.length; + _tasks.removeWhere((t) => t.id == id); + return _tasks.length != n; + } + + /// 거른 목록: 안 끝난 것 먼저, 마감이 이른 것, 마감 없는 것, 최근 것 순. + List query({String? roomId, bool onlyMine = false, bool includeDone = false}) { + final out = [ + for (final t in _tasks) + if ((roomId == null || t.roomId == roomId) && (!onlyMine || t.mine) && (includeDone || !t.done)) t, + ]; + out.sort((a, b) { + if (a.done != b.done) return a.done ? 1 : -1; + if (a.due != null && b.due != null && a.due != b.due) return a.due!.compareTo(b.due!); + if ((a.due == null) != (b.due == null)) return a.due == null ? 1 : -1; + return b.createdAt.compareTo(a.createdAt); + }); + return out; + } + + String toJson() => jsonEncode([for (final t in _tasks) t.toJson()]); + + factory TaskBook.fromJson(String? text) { + if (text == null || text.isEmpty) return TaskBook(); + try { + final j = jsonDecode(text); + if (j is! List) return TaskBook(); + return TaskBook([for (final x in j) if (Task.fromJson(x) case final t?) t]); + } on FormatException { + return TaskBook(); + } + } +} diff --git a/lib/modules/work/work.dart b/lib/modules/work/work.dart new file mode 100644 index 0000000..a006c65 --- /dev/null +++ b/lib/modules/work/work.dart @@ -0,0 +1,8 @@ +/// 업무: 대화에서 맡긴 일·긴급 호출 표시와 맡은 업무 목록 +/// +/// 순수 계산만 한다(저장·화면 없음). 어떤 모듈도 쓰지 않는다. +/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다. +library; + +export 'src/marker.dart'; +export 'src/task.dart'; diff --git a/test/support/app_device.dart b/test/support/app_device.dart index 33f2c6a..ab35ac2 100644 --- a/test/support/app_device.dart +++ b/test/support/app_device.dart @@ -27,7 +27,8 @@ class AppDevice { CallLogProvider? callLog, PhoneActions? phone, Notifier? notifier, - ContactsProvider? contacts}) async { + ContactsProvider? contacts, + InstantNotifier? instant}) async { final key = 'KEY-$name'; final app = FriendlyApp(AppDeps( storage: Storage.memory(), @@ -42,6 +43,7 @@ class AppDevice { phone: phone, notifier: notifier, contacts: contacts, + instant: instant, )); final d = AppDevice._(app, normalized, name, key); await app.profile.setUp(rawPhone, name); diff --git a/test/work_service_test.dart b/test/work_service_test.dart new file mode 100644 index 0000000..d38586a --- /dev/null +++ b/test/work_service_test.dart @@ -0,0 +1,118 @@ +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/platform/platform.dart'; +import 'package:friendly_map/modules/transport/transport.dart'; +import 'package:friendly_map/modules/work/work.dart'; + +import 'support/app_device.dart'; + +class FakeInstant implements InstantNotifier { + final shown = []; + @override + Future showNow(String title, String text) async => shown.add('$title|$text'); +} + +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(); +} + +Future shareCompany(AppDevice from, AppDevice to) async { + final peer = (await from.app.deps.storage.people.byPhone(to.phone))!; + final company = (await from.app.cards.mine()).firstWhere((c) => c.kind == CardKinds.company); + await from.app.cards.shareWith(peer.id, [company.id]); + await AppDevice.settleAll(); +} + +void main() { + late AppDevice a, b, c; + late FakeInstant bAlerts; + + setUp(() async { + final hub = MemoryHub(); + bAlerts = FakeInstant(); + a = await AppDevice.create(hub, '010-1111-2222', '+821011112222', '팀장'); + b = await AppDevice.create(hub, '010-2222-3333', '+821022223333', '김민수', instant: bAlerts); + c = await AppDevice.create(hub, '010-3333-4444', '+821033334444', '이영희'); + await befriend(a, b); + await befriend(a, c); + }); + + Future team(List members) async { + final hb = (await a.app.groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!; + final t = (await a.app.groups.add('결제팀', CardKinds.company, parent: hb.id))!; + final ids = [for (final m in members) (await a.app.deps.storage.people.byPhone(m.phone))!.id]; + await a.app.groups.addMembers(t.id, ids); + return t.id; + } + + test('팀 대화방: 회사 명함을 공유하지 않은 사람이 있으면 알려 주고, 모두 공유하면 방이 생긴다', () async { + final t = await team([b, c]); + await shareCompany(a, b); + final r1 = await a.app.work.teamRoom(t); + expect(r1.room, isNull); + expect(r1.missing.map((p) => p.name), ['이영희']); + await shareCompany(a, c); + final r2 = await a.app.work.teamRoom(t); + expect(r2.room!.title, '회사 › 한빛전자 › 결제팀'); + expect(r2.room!.contextKind, CardKinds.company); + expect((await a.app.work.teamRoom(t)).room!.id, r2.room!.id, reason: '두 번째부터는 같은 방'); + expect(await a.app.work.groupOfRoom(r2.room!.id), t); + }); + + test('업무를 맡기면 내 목록에 들어가고, 받는 쪽 목록에도 "내 업무"로 들어간다', () async { + final t = await team([b, c]); + await shareCompany(a, b); + await shareCompany(a, c); + final room = (await a.app.work.teamRoom(t)).room!; + await AppDevice.settleAll(); + final bId = (await a.app.deps.storage.people.byPhone(b.phone))!.id; + final task = (await a.app.work.assign(room.id, '결제 API 정리', assigneeId: bId, due: DateTime(2026, 10, 30)))!; + expect(task.assigneeName, '김민수'); + expect((await a.app.work.tasks(roomId: room.id)).single.title, '결제 API 정리'); + await AppDevice.settleAll(); + + final mine = await b.app.work.tasks(onlyMine: true); + expect(mine.single.title, '결제 API 정리'); + expect(mine.single.due, DateTime(2026, 10, 30)); + final cTasks = await c.app.work.tasks(); + expect(cTasks.single.assigneeName, '김민수'); + expect(cTasks.single.mine, isFalse); + + await b.app.work.setDone(mine.single.id, true); + expect(await b.app.work.tasks(onlyMine: true), isEmpty); + expect((await b.app.work.tasks(includeDone: true)).single.done, isTrue); + }); + + test('긴급 호출은 받는 쪽에서 바로 알림이 뜨고, 보통 대화는 뜨지 않는다', () async { + await shareCompany(a, b); + final bOfA = (await a.app.deps.storage.people.byPhone(b.phone))!; + final room = (await a.app.chats.openDirect(bOfA.id, CardKinds.company))!; + await a.app.chats.send(room.id, '보통 대화'); + await a.app.work.urgent(room.id, '서버가 멈췄어요'); + await AppDevice.settleAll(); + expect(bAlerts.shown, ['🚨 긴급 호출 · 팀장|서버가 멈췄어요']); + expect(await a.app.work.tasks(), isEmpty); + }); + + test('지난 대화를 업무로 정리하기 (같은 대화는 한 번만)', () async { + final bOfA = (await a.app.deps.storage.people.byPhone(b.phone))!; + final room = (await a.app.chats.openDirect(bOfA.id, CardKinds.personal))!; + final m = (await a.app.chats.send(room.id, '금요일까지 견적서 보내 주세요'))!; + final t = (await a.app.work.fromMessage(m.id, assigneeId: Task.me))!; + expect(t.title, '금요일까지 견적서 보내 주세요'); + expect(t.mine, isTrue); + expect(await a.app.work.fromMessage(m.id), isNull); + expect(WorkService.noteOf('🚨 긴급 호출: x'), isA()); + }); + + test('닫힌 잠긴 그룹은 팀 대화방을 만들 수 없다', () async { + final t = await team([b]); + await a.app.groups.setPin(CardKinds.company, '1234'); + a.app.groups.lockAll(); + expect((await a.app.work.teamRoom(t)).closed, isTrue); + }); +} diff --git a/test/work_test.dart b/test/work_test.dart new file mode 100644 index 0000000..e7fa907 --- /dev/null +++ b/test/work_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/work/work.dart'; + +void main() { + final now = DateTime(2026, 10, 26); + + group('업무 표시', () { + test('업무: 담당·제목·마감을 글자로 만들고 다시 읽는다', () { + final s = TaskNote('결제 API 정리', assignee: '김민수', due: DateTime(2026, 10, 30)).encode(); + expect(s, '📌 업무 [김민수] 결제 API 정리 (마감 10/30)'); + final n = WorkNote.parse(s, now: now) as TaskNote; + expect(n.title, '결제 API 정리'); + expect(n.assignee, '김민수'); + expect(n.due, DateTime(2026, 10, 30)); + }); + + test('담당·마감 없이도 되고, 제목이 비면 업무가 아니다', () { + final n = WorkNote.parse('📌 업무 회의록 쓰기', now: now) as TaskNote; + expect(n.assignee, isNull); + expect(n.due, isNull); + expect(WorkNote.parse('📌 업무 [나]', now: now), isNull); + }); + + test('마감 월/일은 가까운 앞날: 1월 5일은 내년, 지난주는 올해', () { + expect((WorkNote.parse('📌 업무 보고 (마감 1/5)', now: now) as TaskNote).due, DateTime(2027, 1, 5)); + expect((WorkNote.parse('📌 업무 보고 (마감 10/20)', now: now) as TaskNote).due, DateTime(2026, 10, 20)); + expect((WorkNote.parse('📌 업무 보고 (마감 13/40)', now: now) as TaskNote).due, isNull); + }); + + test('긴급 호출, 보통 대화는 표시 없음', () { + expect((WorkNote.parse('🚨 긴급 호출: 서버가 멈췄어요') as UrgentNote).text, '서버가 멈췄어요'); + expect(const UrgentNote('').encode(), '🚨 긴급 호출:'); + expect(WorkNote.parse('안녕하세요'), isNull); + }); + + test('담당 이름의 ] 는 지운다', () { + expect(const TaskNote('일', assignee: 'a]b').encode(), '📌 업무 [ab] 일'); + }); + }); + + group('업무 목록', () { + Task t(String id, {bool mine = false, DateTime? due, String? msg, String room = 'r', int at = 0}) => + Task(id: id, title: id, createdAt: at, roomId: room, messageId: msg, assigneeId: mine ? Task.me : null, due: due); + + test('같은 대화의 업무는 한 번만, 끝냄 표시와 지우기', () { + final b = TaskBook(); + expect(b.add(t('a', msg: 'm1')), isTrue); + expect(b.add(t('b', msg: 'm1')), isFalse); + expect(b.setDone('a', true), isTrue); + expect(b.query(), isEmpty); + expect(b.query(includeDone: true).single.done, isTrue); + expect(b.remove('a'), isTrue); + expect(b.remove('a'), isFalse); + }); + + test('정렬: 안 끝난 것, 마감 이른 것, 마감 없는 것은 최근 것 먼저. 내 것·방별로 거르기', () { + final b = TaskBook() + ..add(t('늦은마감', due: DateTime(2026, 12, 1))) + ..add(t('이른마감', due: DateTime(2026, 11, 1), mine: true)) + ..add(t('마감없음옛날', at: 1)) + ..add(t('마감없음최근', at: 9, room: 'x')); + expect(b.query().map((x) => x.id), ['이른마감', '늦은마감', '마감없음최근', '마감없음옛날']); + expect(b.query(onlyMine: true).map((x) => x.id), ['이른마감']); + expect(b.query(roomId: 'x').map((x) => x.id), ['마감없음최근']); + }); + + test('글자로 저장했다 되살리기, 깨진 글자는 빈 목록', () { + final b = TaskBook()..add(Task(id: 'a', title: '일', createdAt: 5, roomId: 'r', messageId: 'm', assigneeId: 'p', assigneeName: '김', due: DateTime(2026, 11, 2))); + b.setDone('a', true); + final c = TaskBook.fromJson(b.toJson()).all.single; + expect((c.title, c.roomId, c.messageId, c.assigneeId, c.assigneeName, c.due, c.done), ('일', 'r', 'm', 'p', '김', DateTime(2026, 11, 2), true)); + expect(TaskBook.fromJson('{').all, isEmpty); + expect(TaskBook.fromJson('[{"id":1}]').all, isEmpty); + }); + }); +} diff --git a/tool/check_modules.py b/tool/check_modules.py index b44072b..26805f3 100644 --- a/tool/check_modules.py +++ b/tool/check_modules.py @@ -19,9 +19,10 @@ ALLOW = { "visibility": {"core"}, "kinship": set(), "groups": {"core"}, + "work": set(), "events": set(), - "app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups"}, - "ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups"}, + "app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work"}, + "ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups", "work"}, } # 모듈 입구가 둘 이상인 경우: 입구 파일이 모듈 이름.dart 외에 허용되는 것들. EXTRA_BARRELS = {"platform": {"platform_ui.dart"}, "storage": {"storage_sqlite.dart"}} diff --git a/tool/test_module.sh b/tool/test_module.sh index 376fc7b..fd634be 100644 --- a/tool/test_module.sh +++ b/tool/test_module.sh @@ -8,6 +8,7 @@ declare -A T=( [visibility]="visibility_test.dart" [kinship]="kinship_test.dart kinship_side_test.dart" [groups]="groups_test.dart" + [work]="work_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" @@ -17,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" + [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" [platform_android]="df_parser_test.dart" [ui]="widget_test.dart" )