업무 묶음: 팀 대화방, 업무 맡기기·정리, 긴급 호출 즉시 알림

- work 모듈(순수): 대화 속 업무 표시(📌 업무 [담당] 할 일 (마감 M/D), 🚨 긴급 호출:) 읽고 쓰기, 맡은 업무 목록
- platform: InstantNotifier 약속, platform_android: 높은 중요도 즉시 알림
- app: WorkService — 회사 그룹 → 팀 대화방(회사 명함 공유 규칙 지킴), 업무 맡기면 대화로 보내고 받는 쪽도 목록에 넣음, 긴급 호출 받으면 바로 알림, 지난 대화를 업무로 정리
- ui: 대화방 업무 메뉴, 대화 길게 눌러 업무로 정리, 긴급 호출 말풍선 강조, 맡은 업무 화면, 회사 그룹의 팀 대화방 버튼
- 시험 13개 추가 (전체 301개)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
diadmin 2026-10-26 14:44:58 +09:00
parent 98fb0f2d0d
commit 3ceaf11fcc
25 changed files with 928 additions and 13 deletions

View File

@ -25,15 +25,15 @@ Android 다크모드 인맥 앱(Flutter/Dart). 옛 "Family MAP" + 카카오톡
## 구조 ## 구조
- `lib/modules/<이름>/<이름>.dart` 가 모듈의 공개 입구(예외: platform_ui.dart, storage_sqlite.dart). 다른 모듈은 입구 파일만 import. - `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`. - 조립은 모듈 밖: `lib/main.dart`, `lib/composition/android_bootstrap.dart`.
- 안드로이드 전용 플러그인은 platform_android 에서만 import (PLATFORM_ONLY 목록). - 안드로이드 전용 플러그인은 platform_android 에서만 import (PLATFORM_ONLY 목록).
- 화면은 `AppScope.of(context).app`(FriendlyApp)의 서비스만 쓴다. - 화면은 `AppScope.of(context).app`(FriendlyApp)의 서비스만 쓴다.
## 상태 (2026-10-26 기준) ## 상태 (2026-10-26 기준)
- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 288개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과. - 맥 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단계(팀 그룹↔그룹 대화방, 맡은 업무 정리, 긴급 호출), 4단계(이직 알림). - 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 를 내려받느라 약 17분 걸린다. - `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 만 나온다. - 빌드 경고(아직 빌드를 막지는 않음): `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). - 확인 안 된 곳: 실제 기기에서 화면 실행과 플러그인 동작(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 추가 때부터). - pubspec.lock 은 이 컨테이너(Flutter stable)에서 풀린 판을 기준으로 올린다(2026-10-26 flutter_contacts 추가 때부터).

View File

@ -29,9 +29,10 @@
| layout | 홈·명함 틀(템플릿) 순서/보이기/편집 | core | | layout | 홈·명함 틀(템플릿) 순서/보이기/편집 | core |
| kinship | 가계도로 촌수·호칭 계산(순수) | 없음 | | kinship | 가계도로 촌수·호칭 계산(순수) | 없음 |
| events | 생일·기일 일정, 알림 날짜 계획, 통화 기록 요약(순수) | 없음 | | events | 생일·기일 일정, 알림 날짜 계획, 통화 기록 요약(순수) | 없음 |
| groups | 그룹 정의(이름·종류·순서·구성원), 비밀번호 잠금, 여러 번 틀리면 기다리기(순수) | core | | groups | 그룹 정의(이름·종류·순서·구성원·하위 그룹), 비밀번호 잠금, 여러 번 틀리면 기다리기(순수) | core |
| work | 대화 속 업무 표시(📌 업무·🚨 긴급 호출) 읽고 쓰기, 맡은 업무 목록(순수) | 없음 |
| app | 서비스 묶음(`FriendlyApp`). 화면이 쓰는 유일한 내부 입구 | 위 모듈들(키·전송 구현 제외) | | 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/main.dart`, `lib/composition/` 이 부품을 고르는 유일한 곳이다.
암호 구현·저장소·연결 방식을 바꾸려면 `lib/composition/android_bootstrap.dart` 의 해당 줄만 바꾼다. 암호 구현·저장소·연결 방식을 바꾸려면 `lib/composition/android_bootstrap.dart` 의 해당 줄만 바꾼다.
@ -117,3 +118,10 @@ flutter run
- 형제자매를 고를 때 기준 사람의 부모가 가계도에 없으면 "부모(이름 모름)"를 이 기기 안에서만 쓰는 사람으로 만들어 잇는다. - 형제자매를 고를 때 기준 사람의 부모가 가계도에 없으면 "부모(이름 모름)"를 이 기기 안에서만 쓰는 사람으로 만들어 잇는다.
- 학교·회사·모임: 그 종류의 그룹 나무에서 골라 넣거나, + 로 그 아래 새 그룹(예: 학교 › 고등학교 › 가람고 27회, 회사 › 한빛전자 › 결제팀)을 만들어 바로 넣는다. - 학교·회사·모임: 그 종류의 그룹 나무에서 골라 넣거나, + 로 그 아래 새 그룹(예: 학교 › 고등학교 › 가람고 27회, 회사 › 한빛전자 › 결제팀)을 만들어 바로 넣는다.
- 넣으면 연결 고리(가족·동문·직장 동료·같은 모임)가 하나 남아, 공개 범위 표가 그 관계로 적용된다. 같은 종류의 그룹에서 모두 빠지면 연결 고리도 지운다. - 넣으면 연결 고리(가족·동문·직장 동료·같은 모임)가 하나 남아, 공개 범위 표가 그 관계로 적용된다. 같은 종류의 그룹에서 모두 빠지면 연결 고리도 지운다.
## 업무 · 팀 · 긴급 호출
- 회사 종류 그룹(예: 회사 › 한빛전자 › 결제팀) 안의 "팀 대화방"을 누르면 구성원과 회사 맥락의 대화방이 생긴다. 회사 명함을 아직 공유하지 않은 사람이 있으면 그 이름을 알려 주고 만들지 않는다.
- 대화방 위 업무 메뉴: "📌 업무 맡기기"(할 일·담당·마감), "🚨 긴급 호출", "이 방 업무 보기". 채팅 목록 위 체크 아이콘은 모든 방의 "맡은 업무".
- 업무와 긴급 호출은 보통 대화로 간다(`📌 업무 [김민수] 결제 API 정리 (마감 10/30)`, `🚨 긴급 호출: …`). 받는 쪽 앱이 읽어서 업무 목록에 넣고(담당 이름이 내 이름이면 "내 업무"), 긴급 호출은 바로 알림을 띄운다. 새 봉투 형식은 없다.
- 지난 대화를 길게 누르면 "업무로 정리"(내 목록에만). 업무 목록은 이 기기에만 저장하고, 끝냄 표시·밀어서 지우기가 된다.
- 안드로이드: 긴급 호출 알림은 `flutter_local_notifications` 의 높은 중요도 채널(urgent)을 쓴다.

View File

@ -82,6 +82,7 @@ class AndroidBootstrap implements Bootstrap {
phone: AndroidPhoneActions(), phone: AndroidPhoneActions(),
notifier: AndroidNotifier(), notifier: AndroidNotifier(),
contacts: AndroidContacts(), contacts: AndroidContacts(),
instant: AndroidInstantNotifier(),
verifier: LatePhoneVerifier(() async { verifier: LatePhoneVerifier(() async {
final me = await storage.settings.loadProfile(); final me = await storage.settings.loadProfile();
final url = (await storage.settings.getText(mailboxUrlKey))?.trim() ?? ''; final url = (await storage.settings.getText(mailboxUrlKey))?.trim() ?? '';

View File

@ -27,3 +27,4 @@ export 'src/relate_service.dart';
export 'src/settings_service.dart'; export 'src/settings_service.dart';
export 'src/verify_service.dart'; export 'src/verify_service.dart';
export 'src/visibility_service.dart'; export 'src/visibility_service.dart';
export 'src/work_service.dart';

View File

@ -19,6 +19,7 @@ class AppDeps {
this.phone, this.phone,
this.notifier, this.notifier,
this.contacts, this.contacts,
this.instant,
Clock? clock, Clock? clock,
}) : clock = clock ?? Clock(); }) : clock = clock ?? Clock();
@ -35,5 +36,6 @@ class AppDeps {
final PhoneActions? phone; final PhoneActions? phone;
final Notifier? notifier; final Notifier? notifier;
final ContactsProvider? contacts; final ContactsProvider? contacts;
final InstantNotifier? instant;
final Clock clock; final Clock clock;
} }

View File

@ -23,6 +23,7 @@ import 'relate_service.dart';
import 'settings_service.dart'; import 'settings_service.dart';
import 'verify_service.dart'; import 'verify_service.dart';
import 'visibility_service.dart'; import 'visibility_service.dart';
import 'work_service.dart';
/// 앱 내부의 입구. 화면은 이 객체의 서비스들만 쓴다 (저장소·전송·암호를 직접 만지지 않는다). /// 앱 내부의 입구. 화면은 이 객체의 서비스들만 쓴다 (저장소·전송·암호를 직접 만지지 않는다).
/// ///
@ -60,6 +61,7 @@ class FriendlyApp {
groups = GroupService(storage, clock, bus); groups = GroupService(storage, clock, bus);
contacts = ContactImportService(storage, clock, friends, groups, bus, deps.contacts); contacts = ContactImportService(storage, clock, friends, groups, bus, deps.contacts);
relate = RelateService(groups, kin, network, bus); relate = RelateService(groups, kin, network, bus);
work = WorkService(storage, clock, share, chats, groups, bus, deps.instant);
} }
final AppDeps deps; final AppDeps deps;
@ -83,6 +85,7 @@ class FriendlyApp {
late final GroupService groups; late final GroupService groups;
late final ContactImportService contacts; late final ContactImportService contacts;
late final RelateService relate; late final RelateService relate;
late final WorkService work;
StreamSubscription<SyncEvent>? _events; StreamSubscription<SyncEvent>? _events;
final Set<Future<void>> _tasks = {}; final Set<Future<void>> _tasks = {};
@ -98,6 +101,13 @@ class FriendlyApp {
_tasks.add(f); _tasks.add(f);
unawaited(f.whenComplete(() => _tasks.remove(f))); unawaited(f.whenComplete(() => _tasks.remove(f)));
} else { } 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(); bus.notify();
} }
}); });

View File

@ -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<Person> 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<TaskBook> _book() async => TaskBook.fromJson(await _storage.settings.getText(_tasksKey));
Future<void> _saveBook(TaskBook b) async {
await _storage.settings.putText(_tasksKey, b.toJson(), _clock.now());
_bus.notify();
}
Future<Map<String, String>> _rooms() async {
final t = await _storage.settings.getText(_roomsKey);
if (t == null) return {};
try {
return (jsonDecode(t) as Map).cast<String, String>();
} catch (_) {
return {};
}
}
/// 그룹의 팀 대화방 (회사 맥락). 없으면 구성원으로 새로 만든다.
Future<TeamRoomResult> 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<String?> groupOfRoom(String roomId) async {
for (final e in (await _rooms()).entries) {
if (e.value == roomId) return e.key;
}
return null;
}
/// 방의 사람들 (업무를 맡길 사람 고르기용).
Future<List<Person>> roomPeople(String roomId) async {
final room = await _storage.rooms.get(roomId);
if (room == null) return const [];
final out = <Person>[];
for (final id in (jsonDecode(room.membersJson) as List).whereType<String>()) {
final p = await _storage.people.get(id);
if (p != null) out.add(p);
}
return out;
}
Future<String?> _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<Task?> 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<Message?> urgent(String roomId, String text) => _chats.send(roomId, UrgentNote(text.trim()).encode());
/// 지난 대화 하나를 업무로 정리한다 (내 목록에만, 상대에게는 아무것도 가지 않는다).
Future<Task?> 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<String?> _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<void> 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<List<Task>> tasks({String? roomId, bool onlyMine = false, bool includeDone = false}) async =>
(await _book()).query(roomId: roomId, onlyMine: onlyMine, includeDone: includeDone);
Future<void> setDone(String taskId, bool done) async {
final b = await _book();
if (b.setDone(taskId, done)) await _saveBook(b);
}
Future<void> remove(String taskId) async {
final b = await _book();
if (b.remove(taskId)) await _saveBook(b);
}
/// 화면용: 이 대화가 긴급 호출인지, 업무 표시인지.
static WorkNote? noteOf(String body) => WorkNote.parse(body);
}

View File

@ -8,6 +8,7 @@ library;
export 'src/call_log_provider.dart'; export 'src/call_log_provider.dart';
export 'src/contacts_provider.dart'; export 'src/contacts_provider.dart';
export 'src/device_storage_probe.dart'; export 'src/device_storage_probe.dart';
export 'src/instant_notifier.dart';
export 'src/lan_discovery.dart'; export 'src/lan_discovery.dart';
export 'src/location_provider.dart'; export 'src/location_provider.dart';
export 'src/motion_sensor.dart'; export 'src/motion_sensor.dart';

View File

@ -0,0 +1,4 @@
/// 지금 바로 띄우는 기기 알림 (긴급 호출 등). 예약 알림([Notifier])과 따로 둔다.
abstract class InstantNotifier {
Future<void> showNow(String title, String text);
}

View File

@ -8,6 +8,7 @@ library;
export 'src/android_call_log.dart'; export 'src/android_call_log.dart';
export 'src/android_contacts.dart'; export 'src/android_contacts.dart';
export 'src/android_database.dart'; export 'src/android_database.dart';
export 'src/android_instant_notifier.dart';
export 'src/android_lan_discovery.dart'; export 'src/android_lan_discovery.dart';
export 'src/android_location_provider.dart'; export 'src/android_location_provider.dart';
export 'src/android_motion_sensor.dart'; export 'src/android_motion_sensor.dart';

View File

@ -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<void> _init() async {
if (_ready) return;
await _plugin.initialize(const InitializationSettings(android: AndroidInitializationSettings('@mipmap/ic_launcher')));
final a = _plugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
await a?.requestNotificationsPermission();
_ready = true;
}
@override
Future<void> 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 (_) {}
}
}

View File

@ -9,6 +9,7 @@ import '../widgets/async_view.dart';
import '../widgets/common.dart'; import '../widgets/common.dart';
import 'add_friend_screen.dart'; import 'add_friend_screen.dart';
import 'chat_room_screen.dart'; import 'chat_room_screen.dart';
import 'tasks_screen.dart';
/// 채팅 목록. /// 채팅 목록.
class ChatListScreen extends StatelessWidget { class ChatListScreen extends StatelessWidget {
@ -20,6 +21,7 @@ class ChatListScreen extends StatelessWidget {
appBar: AppBar( appBar: AppBar(
title: const Text('채팅', style: TextStyle(fontWeight: FontWeight.w700)), title: const Text('채팅', style: TextStyle(fontWeight: FontWeight.w700)),
actions: [ actions: [
IconButton(tooltip: '맡은 업무', onPressed: () => pushPage(context, const TasksScreen()), icon: const Icon(Icons.checklist)),
IconButton( IconButton(
tooltip: '새 친구', tooltip: '새 친구',
onPressed: () => pushPage(context, const AddFriendScreen()), onPressed: () => pushPage(context, const AddFriendScreen()),

View File

@ -4,13 +4,18 @@ import 'package:flutter/material.dart';
import 'package:friendly_map/modules/app/app.dart'; import 'package:friendly_map/modules/app/app.dart';
import 'package:friendly_map/modules/core/core.dart'; import 'package:friendly_map/modules/core/core.dart';
import 'package:friendly_map/modules/storage/storage.dart'; import 'package:friendly_map/modules/storage/storage.dart';
import 'package:friendly_map/modules/work/work.dart';
import '../app_scope.dart'; import '../app_scope.dart';
import '../theme.dart'; import '../theme.dart';
import '../widgets/async_view.dart'; import '../widgets/async_view.dart';
import '../widgets/common.dart'; import '../widgets/common.dart';
import '../widgets/relate_sheet.dart'; import '../widgets/relate_sheet.dart';
import '../widgets/work_dialogs.dart';
import 'archive_screen.dart'; import 'archive_screen.dart';
import 'tasks_screen.dart';
enum _Work { assign, urgent, tasks }
class _RoomData { class _RoomData {
const _RoomData(this.room, this.messages, this.advice); const _RoomData(this.room, this.messages, this.advice);
@ -45,6 +50,45 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
await AppScope.of(context).app.chats.send(widget.roomId, text); await AppScope.of(context).app.chats.send(widget.roomId, text);
} }
Future<void> _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<void> _toTask(Message m) async {
final app = AppScope.of(context).app;
final ok = await showModalBottomSheet<bool>(
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 { Future<_RoomData> _load(FriendlyApp app) async {
final rooms = await app.chats.summaries(); final rooms = await app.chats.summaries();
ChatRoom? room; ChatRoom? room;
@ -79,6 +123,17 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
if (ids.isNotEmpty) showRelateSheet(context, ids.first, room.title); 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: [ body: Column(children: [
@ -105,7 +160,10 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
reverse: true, reverse: true,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
itemCount: d.messages.length, 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( SafeArea(
@ -142,6 +200,8 @@ class _Bubble extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mine = m.senderId == ChatService.me; final mine = m.senderId == ChatService.me;
final note = WorkService.noteOf(m.body);
final urgent = note is UrgentNote;
return Align( return Align(
alignment: mine ? Alignment.centerRight : Alignment.centerLeft, alignment: mine ? Alignment.centerRight : Alignment.centerLeft,
child: Container( child: Container(
@ -151,9 +211,11 @@ class _Bubble extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: mine ? Pal.accent : Pal.surface, color: mine ? Pal.accent : Pal.surface,
borderRadius: BorderRadius.circular(14), 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: [ 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)), Text(m.body, style: TextStyle(color: mine ? Colors.black : Pal.text, height: 1.4)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(shortTime(m.sentAt), style: TextStyle(fontSize: 10, color: mine ? Colors.black54 : Pal.muted)), Text(shortTime(m.sentAt), style: TextStyle(fontSize: 10, color: mine ? Colors.black54 : Pal.muted)),

View File

@ -10,6 +10,7 @@ import '../widgets/common.dart';
import '../widgets/group_dialogs.dart'; import '../widgets/group_dialogs.dart';
import '../widgets/group_menu.dart'; import '../widgets/group_menu.dart';
import '../widgets/pin_dialog.dart'; import '../widgets/pin_dialog.dart';
import 'chat_room_screen.dart';
import 'contact_import_screen.dart'; import 'contact_import_screen.dart';
import 'person_screen.dart'; import 'person_screen.dart';
@ -48,6 +49,19 @@ class GroupDetailScreen extends StatelessWidget {
if (context.mounted) showNote(context, '$n명을 넣었어요'); if (context.mounted) showNote(context, '$n명을 넣었어요');
} }
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final app = AppScope.of(context).app; final app = AppScope.of(context).app;
@ -105,7 +119,10 @@ class GroupDetailScreen extends StatelessWidget {
: null), : null),
for (final c in d.children) for (final c in d.children)
GroupTile(c, children: d.grandchildren[c.group.id] ?? 0, onOpen: () => pushPage(context, GroupDetailScreen(groupId: c.group.id))), 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(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Row(children: [ child: Row(children: [

View File

@ -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<TasksScreen> createState() => _TasksScreenState();
}
class _TasksScreenState extends State<TasksScreen> {
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<List<Task>>(
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!))),
),
),
]);
},
),
),
]),
);
}
}

View File

@ -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<AssignInput?> askAssign(BuildContext context, List<Person> people) =>
showDialog<AssignInput>(context: context, builder: (_) => _AssignDialog(people));
class _AssignDialog extends StatefulWidget {
const _AssignDialog(this.people);
final List<Person> 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<void> _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<String?> askUrgent(BuildContext context) async {
final ctl = TextEditingController();
final r = await showDialog<String>(
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;
}

View File

@ -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;
}
}

View File

@ -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<String, Object?> 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<Task>? tasks]) : _tasks = [...?tasks];
final List<Task> _tasks;
List<Task> 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<Task> 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();
}
}
}

View File

@ -0,0 +1,8 @@
/// 업무: 대화에서 맡긴 일·긴급 호출 표시와 맡은 업무 목록
///
/// 순수 계산만 한다(저장·화면 없음). 어떤 모듈도 쓰지 않는다.
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
library;
export 'src/marker.dart';
export 'src/task.dart';

View File

@ -27,7 +27,8 @@ class AppDevice {
CallLogProvider? callLog, CallLogProvider? callLog,
PhoneActions? phone, PhoneActions? phone,
Notifier? notifier, Notifier? notifier,
ContactsProvider? contacts}) async { ContactsProvider? contacts,
InstantNotifier? instant}) async {
final key = 'KEY-$name'; final key = 'KEY-$name';
final app = FriendlyApp(AppDeps( final app = FriendlyApp(AppDeps(
storage: Storage.memory(), storage: Storage.memory(),
@ -42,6 +43,7 @@ class AppDevice {
phone: phone, phone: phone,
notifier: notifier, notifier: notifier,
contacts: contacts, contacts: contacts,
instant: instant,
)); ));
final d = AppDevice._(app, normalized, name, key); final d = AppDevice._(app, normalized, name, key);
await app.profile.setUp(rawPhone, name); await app.profile.setUp(rawPhone, name);

118
test/work_service_test.dart Normal file
View File

@ -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 = <String>[];
@override
Future<void> showNow(String title, String text) async => shown.add('$title|$text');
}
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();
}
Future<void> 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<String> team(List<AppDevice> 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<UrgentNote>());
});
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);
});
}

76
test/work_test.dart Normal file
View File

@ -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);
});
});
}

View File

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

View File

@ -8,6 +8,7 @@ declare -A T=(
[visibility]="visibility_test.dart" [visibility]="visibility_test.dart"
[kinship]="kinship_test.dart kinship_side_test.dart" [kinship]="kinship_test.dart kinship_side_test.dart"
[groups]="groups_test.dart" [groups]="groups_test.dart"
[work]="work_test.dart"
[events]="events_test.dart lunar_test.dart" [events]="events_test.dart lunar_test.dart"
[storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart" [storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart"
[transport]="transport_test.dart" [transport]="transport_test.dart"
@ -17,7 +18,7 @@ declare -A T=(
[inference]="inference_test.dart" [inference]="inference_test.dart"
[layout]="layout_test.dart" [layout]="layout_test.dart"
[sharing]="sharing_service_test.dart sharing_status_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" [platform_android]="df_parser_test.dart"
[ui]="widget_test.dart" [ui]="widget_test.dart"
) )