업무 묶음: 팀 대화방, 업무 맡기기·정리, 긴급 호출 즉시 알림
- work 모듈(순수): 대화 속 업무 표시(📌 업무 [담당] 할 일 (마감 M/D), 🚨 긴급 호출:) 읽고 쓰기, 맡은 업무 목록 - platform: InstantNotifier 약속, platform_android: 높은 중요도 즉시 알림 - app: WorkService — 회사 그룹 → 팀 대화방(회사 명함 공유 규칙 지킴), 업무 맡기면 대화로 보내고 받는 쪽도 목록에 넣음, 긴급 호출 받으면 바로 알림, 지난 대화를 업무로 정리 - ui: 대화방 업무 메뉴, 대화 길게 눌러 업무로 정리, 긴급 호출 말풍선 강조, 맡은 업무 화면, 회사 그룹의 팀 대화방 버튼 - 시험 13개 추가 (전체 301개) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
98fb0f2d0d
commit
3ceaf11fcc
|
|
@ -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 추가 때부터).
|
||||
|
|
|
|||
12
README.md
12
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)을 쓴다.
|
||||
|
|
|
|||
|
|
@ -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() ?? '';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SyncEvent>? _events;
|
||||
final Set<Future<void>> _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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
/// 지금 바로 띄우는 기기 알림 (긴급 호출 등). 예약 알림([Notifier])과 따로 둔다.
|
||||
abstract class InstantNotifier {
|
||||
Future<void> showNow(String title, String text);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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<ChatRoomScreen> {
|
|||
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 {
|
||||
final rooms = await app.chats.summaries();
|
||||
ChatRoom? room;
|
||||
|
|
@ -79,6 +123,17 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||
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<ChatRoomScreen> {
|
|||
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)),
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
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: [
|
||||
|
|
|
|||
|
|
@ -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!))),
|
||||
),
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/// 업무: 대화에서 맡긴 일·긴급 호출 표시와 맡은 업무 목록
|
||||
///
|
||||
/// 순수 계산만 한다(저장·화면 없음). 어떤 모듈도 쓰지 않는다.
|
||||
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
|
||||
library;
|
||||
|
||||
export 'src/marker.dart';
|
||||
export 'src/task.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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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"}}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue