Compare commits
5 Commits
95141686d0
...
3a31b40e54
| Author | SHA1 | Date |
|---|---|---|
|
|
3a31b40e54 | |
|
|
3ceaf11fcc | |
|
|
98fb0f2d0d | |
|
|
fc78d35109 | |
|
|
413489f49c |
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -25,17 +25,19 @@ 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, 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` 239개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과.
|
||||
- `flutter build apk --debug` 성공(app-debug.apk 약 190MB). 첫 빌드는 SDK 34·35, CMake 를 내려받느라 약 17분 걸린다.
|
||||
- 빌드 경고(아직 빌드를 막지는 않음): `nsd_android` 가 Kotlin Gradle Plugin 을 직접 적용한다. 앞으로 Flutter 판에서 빌드가 실패할 수 있으니, 지원 판이 나오면 nsd 버전을 올린다. geolocator_android 는 deprecated API 를 쓴다는 Note 만 나온다.
|
||||
- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 307개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과.
|
||||
- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 관계망 로드맵 1단계(그룹 계층: 학교 › 초등~대학원, 가족 › 친가·외가·처가·시가·촌수 모름, 회사 › 회사 › 팀) 끝. 2단계(채팅·사람 화면의 "관계 정하기": RelateService, 촌수 맞춰 친가·외가·처가, 촌수 모름 모음, 연결 고리) 끝. 3단계(work 모듈, WorkService: 팀 대화방, 📌 업무 맡기기·정리, 🚨 긴급 호출 즉시 알림) 끝. 4단계(CareerService: 받은 회사 명함 비교로 이직 알림, 확인하면 이전 회사 그룹에 이전 구성원으로 흐리게·새 회사 그룹으로 이동, 지우기·무시) 끝. 로드맵 1~4단계 완료.
|
||||
- `flutter build apk --debug` 성공(app-debug.apk 약 190MB). 컨테이너마다 SDK 34·35, CMake 를 다시 내려받아 15분 넘게 걸린다. 빌드할 때는 시험을 같이 돌리지 않는다(podman 머신 8GB 에서 Gradle 데몬이 메모리 부족으로 꺼진 적 있음).
|
||||
- 빌드 경고(아직 빌드를 막지는 않음): `nsd_android`, `flutter_contacts` 가 Kotlin Gradle Plugin 을 직접 적용한다. 앞으로 Flutter 판에서 빌드가 실패할 수 있으니, 지원 판이 나오면 nsd 버전을 올린다. geolocator_android 는 deprecated API 를 쓴다는 Note 만 나온다.
|
||||
- 확인 안 된 곳: 실제 기기에서 화면 실행과 플러그인 동작(geolocator, call_log, url_launcher, flutter_local_notifications, timezone, sqflite_sqlcipher, nsd, mobile_scanner, sensors_plus, flutter_secure_storage).
|
||||
- 컨테이너의 `flutter pub get` 은 pubspec.lock 을 낮은 판으로 바꾼다. 검사가 끝나면 `git checkout pubspec.lock` 으로 되돌린다.
|
||||
- pubspec.lock 은 이 컨테이너(Flutter stable)에서 풀린 판을 기준으로 올린다(2026-10-26 flutter_contacts 추가 때부터).
|
||||
- 빌드 캐시: `-v fm-gradle:/root/.gradle -v fm-pub:/root/.pub-cache` 를 붙이면 두 번째 빌드부터 빨라진다.
|
||||
- 알려진 한계: 윤달 생일 미지원(기일만 지원), 음력 변환 1951~2049년, 통화 기록 읽기(READ_CALL_LOG)는 Play 스토어 제한, WebRTC·앱 잠금·복구 단어 재확인 알림·공유 이미지/PDF 저장·우편함 요청 서명은 미구현. 문자 인증은 서버에 `--sms-webhook` 연결이 필요.
|
||||
|
||||
## 빌드·시험 방법 (Mac Pro 2013, podman)
|
||||
|
|
|
|||
35
README.md
35
README.md
|
|
@ -16,7 +16,7 @@
|
|||
| 모듈 | 하는 일 | 쓸 수 있는 모듈 |
|
||||
|---|---|---|
|
||||
| core | 모듈끼리 주고받는 약속(키·전송·암호·봉투·명함 항목 이름) | 없음 |
|
||||
| platform | 기기마다 다른 기능의 약속(보안 저장소, 저장 공간, 센서, QR, 발견, 위치, 통화 기록·전화, 알림) | 없음 |
|
||||
| platform | 기기마다 다른 기능의 약속(보안 저장소, 저장 공간, 센서, QR, 발견, 위치, 통화 기록·전화, 알림, 주소록) | 없음 |
|
||||
| platform_android | platform 의 안드로이드 구현(플러그인은 여기서만) | core, platform, storage |
|
||||
| storage | 저장소 약속 + 메모리/SQLite 구현 | platform |
|
||||
| keys | 12단어 복구 코드, 키 파생, 둘만 아는 키 암호, 복구 조각 나누기·합치기 | core, platform |
|
||||
|
|
@ -29,8 +29,10 @@
|
|||
| layout | 홈·명함 틀(템플릿) 순서/보이기/편집 | core |
|
||||
| kinship | 가계도로 촌수·호칭 계산(순수) | 없음 |
|
||||
| events | 생일·기일 일정, 알림 날짜 계획, 통화 기록 요약(순수) | 없음 |
|
||||
| groups | 그룹 정의(이름·종류·순서·구성원·하위 그룹), 비밀번호 잠금, 여러 번 틀리면 기다리기(순수) | core |
|
||||
| work | 대화 속 업무 표시(📌 업무·🚨 긴급 호출) 읽고 쓰기, 맡은 업무 목록(순수) | 없음 |
|
||||
| app | 서비스 묶음(`FriendlyApp`). 화면이 쓰는 유일한 내부 입구 | 위 모듈들(키·전송 구현 제외) |
|
||||
| ui | 화면. `app` 의 서비스만 쓴다 | app, core, platform, storage, discovery, inference, layout, visibility, kinship, events |
|
||||
| ui | 화면. `app` 의 서비스만 쓴다 | app, core, platform, storage, discovery, inference, layout, visibility, kinship, events, groups, work |
|
||||
|
||||
모듈 폴더 밖의 `lib/main.dart`, `lib/composition/` 이 부품을 고르는 유일한 곳이다.
|
||||
암호 구현·저장소·연결 방식을 바꾸려면 `lib/composition/android_bootstrap.dart` 의 해당 줄만 바꾼다.
|
||||
|
|
@ -100,3 +102,32 @@ flutter run
|
|||
- `--require-verify` 를 켜면 확인을 마친 기기만 우편함을 열 수 있다 (번호 선점 차단). 켜지 않으면 예전처럼 처음 쓴 기기에 묶인다.
|
||||
- 5분 만료, 번호당 1시간 3번, 5번 오답이면 새로 요청. `--sms-log` 는 시험용(문자를 보내지 않고 기록)이라 실서비스에서 쓰면 안 된다.
|
||||
- 서버 시험: `cd server && python3 -m unittest test_mailbox test_verify`
|
||||
|
||||
## 그룹 · 주소록 가져오기
|
||||
- 설정(내 정보 → 저장·보안) → "그룹 · 주소록". 처음 그룹은 가족·학교·회사·그룹·여행이고, 더하기·이름과 종류 고치기·순서 바꾸기·지우기가 된다(지워도 사람은 인맥에 남는다).
|
||||
- 그룹 아래에 하위 그룹을 4단까지 만든다. 처음에는 학교 › 초등학교·중학교·고등학교·대학교·대학원, 가족 › 친가·외가·처가·시가·친척(촌수 모름)이 있고, 회사 › 회사 이름 › 프로젝트·팀은 직접 만든다. 같은 부모 아래에서만 이름이 겹치지 않으면 된다. 지우면 하위 그룹도 함께 지워진다(사람은 남는다).
|
||||
- 그룹마다 비밀번호(4~16자)를 걸 수 있다. 잠긴 그룹은 열 때 비밀번호를 묻고, 닫혀 있으면 하위 그룹과 인맥 목록에서도 그 사람들을 숨긴다. 연 뒤 정한 시간(기본 5분, 1~60분)이 지나거나 앱을 내리면 다시 잠근다.
|
||||
- 5번 틀리면 30초, 그 뒤로 틀릴 때마다 두 배(최대 1시간) 기다려야 한다. 비밀번호는 PBKDF2-SHA256 값만 남긴다. 잊으면 그 그룹은 지우고 다시 만들어야 한다.
|
||||
- "주소록에서 가져오기": 이름·초성(ㄱㅁㅅ)·번호로 찾고, 휴대폰 주소록 그룹(라벨)으로 거르고, 이미 있는 사람은 빼고 볼 수 있다. 한 명씩 또는 보이는 사람을 모두 고른 뒤, 넣을 그룹(여러 개)을 골라 가져온다.
|
||||
- 번호가 여럿이면 휴대폰(010) 번호를 먼저 쓴다. 가져온 사람은 이 기기 인맥에만 들어가고, "친구 요청도 보내기"를 켰을 때만 상대에게 요청이 간다. 주소록은 읽기만 한다.
|
||||
- 그룹은 이 기기에만 저장한다. 안드로이드: `flutter_contacts` 플러그인과 READ_CONTACTS 권한이 필요하다(`tool/android_patch.py` 가 넣는다).
|
||||
|
||||
## 관계 정하기 (채팅에서 시작하는 관계망)
|
||||
- 대화방 위의 관계 아이콘, 또는 사람 화면의 "관계 정하기"에서 고른다: 가족·친척 / 학교 동문 / 회사·업무·팀 / 그룹·모임·여행.
|
||||
- 가족: 나(또는 가계도에 있는 다른 가족)의 부모·자녀·배우자·형제자매를 고르면 가계도에 잇고 촌수·호칭을 계산한다. 아버지 쪽은 친가, 어머니 쪽은 외가, 배우자 쪽은 처가·시가 그룹에 들어간다. 모르면 "친척(촌수 모름)"에 모아 두고, 나중에 이으면 모음에서 빠진다.
|
||||
- 형제자매를 고를 때 기준 사람의 부모가 가계도에 없으면 "부모(이름 모름)"를 이 기기 안에서만 쓰는 사람으로 만들어 잇는다.
|
||||
- 학교·회사·모임: 그 종류의 그룹 나무에서 골라 넣거나, + 로 그 아래 새 그룹(예: 학교 › 고등학교 › 가람고 27회, 회사 › 한빛전자 › 결제팀)을 만들어 바로 넣는다.
|
||||
- 넣으면 연결 고리(가족·동문·직장 동료·같은 모임)가 하나 남아, 공개 범위 표가 그 관계로 적용된다. 같은 종류의 그룹에서 모두 빠지면 연결 고리도 지운다.
|
||||
|
||||
## 업무 · 팀 · 긴급 호출
|
||||
- 회사 종류 그룹(예: 회사 › 한빛전자 › 결제팀) 안의 "팀 대화방"을 누르면 구성원과 회사 맥락의 대화방이 생긴다. 회사 명함을 아직 공유하지 않은 사람이 있으면 그 이름을 알려 주고 만들지 않는다.
|
||||
- 대화방 위 업무 메뉴: "📌 업무 맡기기"(할 일·담당·마감), "🚨 긴급 호출", "이 방 업무 보기". 채팅 목록 위 체크 아이콘은 모든 방의 "맡은 업무".
|
||||
- 업무와 긴급 호출은 보통 대화로 간다(`📌 업무 [김민수] 결제 API 정리 (마감 10/30)`, `🚨 긴급 호출: …`). 받는 쪽 앱이 읽어서 업무 목록에 넣고(담당 이름이 내 이름이면 "내 업무"), 긴급 호출은 바로 알림을 띄운다. 새 봉투 형식은 없다.
|
||||
- 지난 대화를 길게 누르면 "업무로 정리"(내 목록에만). 업무 목록은 이 기기에만 저장하고, 끝냄 표시·밀어서 지우기가 된다.
|
||||
- 안드로이드: 긴급 호출 알림은 `flutter_local_notifications` 의 높은 중요도 채널(urgent)을 쓴다.
|
||||
|
||||
## 이직 알림
|
||||
- 상대가 회사 명함을 고쳐 "다시 보내기" 하면, 이 기기가 알던 회사와 비교해 바뀌었으면 알림이 생긴다(채팅 목록 맨 위, 사람 화면, 그룹 관리). 예: "김민수님이 한빛전자에서 누리소프트로 옮겼어요", "지금은 다니지 않음"이면 "…를 떠났어요".
|
||||
- "확인 · 옮기기": 이전 회사 이름이 경로에 든 회사 그룹(예: 회사 › 한빛전자 › 결제팀)에서는 이전 구성원으로 흐리게 남기고, "회사 › 새 회사" 그룹(없으면 만든다)에 넣는다. 연결 고리는 '직장 동료'가 새 회사로 바뀌고 '전 직장 동료'(이전 경로)가 남는다.
|
||||
- "이전 회사에서 지우기": 흐리게 남기지 않고 뺀다. "무시": 알림만 지운다. 이전 구성원은 그룹 안에서 ✕ 로 지울 수 있다.
|
||||
- 처음 받는 회사 명함, 앱을 새로 연 뒤의 첫 비교는 알림을 만들지 않는다(알던 회사로만 기억). 알던 회사·알림은 이 기기에만 저장한다.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ class AndroidBootstrap implements Bootstrap {
|
|||
callLog: AndroidCallLog(),
|
||||
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() ?? '';
|
||||
|
|
|
|||
|
|
@ -7,20 +7,25 @@ library;
|
|||
export 'src/app_deps.dart';
|
||||
export 'src/call_service.dart';
|
||||
export 'src/card_service.dart';
|
||||
export 'src/career_service.dart';
|
||||
export 'src/change_bus.dart';
|
||||
export 'src/chat_service.dart';
|
||||
export 'src/contact_import_service.dart';
|
||||
export 'src/event_service.dart';
|
||||
export 'src/facts_service.dart';
|
||||
export 'src/fields.dart';
|
||||
export 'src/friend_service.dart' show FriendService, NearbyPerson, NewFriendCheck;
|
||||
export 'src/friendly_app.dart';
|
||||
export 'src/geo.dart';
|
||||
export 'src/group_service.dart';
|
||||
export 'src/kin_service.dart';
|
||||
export 'src/layout_service.dart' show LayoutService;
|
||||
export 'src/location_service.dart';
|
||||
export 'src/network_service.dart';
|
||||
export 'src/profile_service.dart';
|
||||
export 'src/recovery_service.dart';
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ class AppDeps {
|
|||
this.callLog,
|
||||
this.phone,
|
||||
this.notifier,
|
||||
this.contacts,
|
||||
this.instant,
|
||||
Clock? clock,
|
||||
}) : clock = clock ?? Clock();
|
||||
|
||||
|
|
@ -33,5 +35,7 @@ class AppDeps {
|
|||
final CallLogProvider? callLog;
|
||||
final PhoneActions? phone;
|
||||
final Notifier? notifier;
|
||||
final ContactsProvider? contacts;
|
||||
final InstantNotifier? instant;
|
||||
final Clock clock;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:friendly_map/modules/core/core.dart';
|
||||
import 'package:friendly_map/modules/storage/storage.dart';
|
||||
|
||||
import 'change_bus.dart';
|
||||
import 'fields.dart';
|
||||
import 'group_service.dart';
|
||||
import 'network_service.dart';
|
||||
import 'relate_service.dart';
|
||||
|
||||
/// 이직 알림 하나: [personId] 의 회사가 [from] 에서 [to] 로 바뀜. 빈 글자는 "회사 없음/떠남".
|
||||
class CareerNotice {
|
||||
const CareerNotice(this.id, this.personId, this.from, this.to, this.at);
|
||||
final String id;
|
||||
final String personId;
|
||||
final String from;
|
||||
final String to;
|
||||
final int at;
|
||||
|
||||
bool get left => to.isEmpty;
|
||||
|
||||
Map<String, Object> toJson() => {'id': id, 'p': personId, 'f': from, 't': to, 'at': at};
|
||||
|
||||
static CareerNotice? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
final id = j['id'], p = j['p'], f = j['f'], t = j['t'], at = j['at'];
|
||||
if (id is! String || p is! String || f is! String || t is! String || at is! int) return null;
|
||||
return CareerNotice(id, p, f, t, at);
|
||||
}
|
||||
}
|
||||
|
||||
/// 받은 회사 명함에서 지금 다니는 회사 (지금은 다니지 않으면 빈 글자).
|
||||
String currentCompany(Map<String, String> fields) =>
|
||||
fields[CardKeys.former] == 'true' ? '' : (fields[CardKeys.company] ?? '').trim();
|
||||
|
||||
/// 받침에 맞는 조사 ("누리소프트" + 로, "회사" + 를, "한빛" + 으로). 한글이 아니면 "(으)로"·"을(를)".
|
||||
String withJosa(String word, String withFinal, String withoutFinal) {
|
||||
if (word.isEmpty) return word;
|
||||
final r = word.runes.last;
|
||||
if (r < 0xAC00 || r > 0xD7A3) return '$word$withFinal($withoutFinal)'.replaceFirst('으로(로)', '(으)로');
|
||||
final fin = (r - 0xAC00) % 28;
|
||||
final useFinal = fin != 0 && !(withFinal == '으로' && fin == 8); // ㄹ 받침은 "로"
|
||||
return '$word${useFinal ? withFinal : withoutFinal}';
|
||||
}
|
||||
|
||||
/// 이직 알림: 상대가 회사 명함을 "다시 보내기"로 고쳐 보내면 회사가 바뀌었는지 보고 알린다.
|
||||
///
|
||||
/// 확인하면 이전 회사 그룹에서는 흐리게(이전 구성원) 남기거나 지우고, 새 회사 그룹(회사 › 새 회사)으로 옮긴다.
|
||||
/// 알던 회사와 알림은 이 기기에만 저장한다.
|
||||
class CareerService {
|
||||
CareerService(this._storage, this._clock, this._groups, this._relate, this._network, this._bus);
|
||||
|
||||
static const _knownKey = 'career.known';
|
||||
static const _noticesKey = 'career.notices';
|
||||
|
||||
final Storage _storage;
|
||||
final Clock _clock;
|
||||
final GroupService _groups;
|
||||
final RelateService _relate;
|
||||
final NetworkService _network;
|
||||
final ChangeBus _bus;
|
||||
|
||||
Future<Map<String, String>> _known() async {
|
||||
try {
|
||||
return (jsonDecode(await _storage.settings.getText(_knownKey) ?? '{}') as Map).cast<String, String>();
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveKnown(Map<String, String> m) => _storage.settings.putText(_knownKey, jsonEncode(m), _clock.now());
|
||||
|
||||
Future<List<CareerNotice>> _all() async {
|
||||
try {
|
||||
final j = jsonDecode(await _storage.settings.getText(_noticesKey) ?? '[]');
|
||||
return [for (final x in j as List) if (CareerNotice.fromJson(x) case final n?) n];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveAll(List<CareerNotice> list) async {
|
||||
await _storage.settings.putText(_noticesKey, jsonEncode([for (final n in list) n.toJson()]), _clock.now());
|
||||
_bus.notify();
|
||||
}
|
||||
|
||||
/// 이미 받아 둔 회사 명함으로 "알던 회사"를 채운다 (앱 시작 때). 알림은 만들지 않는다.
|
||||
Future<void> seed() async {
|
||||
final known = await _known();
|
||||
var changed = false;
|
||||
for (final c in await _storage.receivedCards.all()) {
|
||||
if (c.kind != CardKinds.company || known.containsKey(c.personId)) continue;
|
||||
known[c.personId] = currentCompany(decodeFields(c.fieldsJson));
|
||||
changed = true;
|
||||
}
|
||||
if (changed) await _saveKnown(known);
|
||||
}
|
||||
|
||||
/// 받은 명함을 살핀다 (동기화가 명함을 반영한 뒤 부른다). 회사가 바뀌었으면 알림을 만든다.
|
||||
Future<CareerNotice?> onCard(String cardId) async {
|
||||
final c = await _storage.receivedCards.get(cardId);
|
||||
if (c == null || c.kind != CardKinds.company) return null;
|
||||
final now = currentCompany(decodeFields(c.fieldsJson));
|
||||
final known = await _known();
|
||||
final before = known[c.personId];
|
||||
known[c.personId] = now;
|
||||
await _saveKnown(known);
|
||||
if (before == null || before == now) return null;
|
||||
final list = (await _all())..removeWhere((n) => n.personId == c.personId);
|
||||
final n = CareerNotice(newId(), c.personId, before, now, _clock.now());
|
||||
list.add(n);
|
||||
await _saveAll(list);
|
||||
return n;
|
||||
}
|
||||
|
||||
/// 확인 안 한 이직 알림 (최근 것부터). [personId] 를 주면 그 사람 것만.
|
||||
Future<List<CareerNotice>> notices({String? personId}) async =>
|
||||
[for (final n in (await _all()).reversed) if (personId == null || n.personId == personId) n];
|
||||
|
||||
/// "김민수님이 한빛전자에서 누리소프트로 옮겼어요"
|
||||
Future<String> describe(CareerNotice n) async {
|
||||
final name = (await _storage.people.get(n.personId))?.name ?? '친구';
|
||||
if (n.from.isEmpty) return '$name님이 ${n.to}에 다니기 시작했어요';
|
||||
if (n.left) return '$name님이 ${withJosa(n.from, '을', '를')} 떠났어요';
|
||||
return '$name님이 ${n.from}에서 ${withJosa(n.to, '으로', '로')} 옮겼어요';
|
||||
}
|
||||
|
||||
/// 알림을 받아들인다. 이전 회사 그룹에서는 [keepOld] 면 흐리게 남기고 아니면 뺀다. 새 회사가 있으면 그 그룹에 넣는다.
|
||||
/// 들어간 새 회사 그룹 경로 (없으면 null).
|
||||
Future<String?> accept(String noticeId, {bool keepOld = true}) async {
|
||||
final list = await _all();
|
||||
final i = list.indexWhere((x) => x.id == noticeId);
|
||||
if (i < 0) return null;
|
||||
final n = list.removeAt(i);
|
||||
final pid = n.personId;
|
||||
String? oldPath;
|
||||
if (n.from.isNotEmpty) {
|
||||
for (final v in await _groups.all()) {
|
||||
if (v.group.kind != CardKinds.company || !v.open || !v.group.members.contains(pid)) continue;
|
||||
if (!v.path.split(' › ').contains(n.from)) continue;
|
||||
oldPath ??= v.path;
|
||||
keepOld ? await _groups.markFormer(v.group.id, pid) : await _groups.removeMember(v.group.id, pid);
|
||||
}
|
||||
}
|
||||
for (final l in await _network.links(pid)) {
|
||||
if (l.relation == '직장 동료') await _network.removeLink(l.id);
|
||||
}
|
||||
if (keepOld && n.from.isNotEmpty) await _network.addLink(pid, '전 직장 동료', oldPath ?? n.from);
|
||||
String? placed;
|
||||
if (n.to.isNotEmpty) {
|
||||
final target = await _companyGroup(n.to);
|
||||
if (target != null) placed = (await _relate.into(pid, target))?.groupPath;
|
||||
}
|
||||
await _saveAll(list);
|
||||
return placed;
|
||||
}
|
||||
|
||||
/// 알림을 그냥 지운다.
|
||||
Future<void> dismiss(String noticeId) async {
|
||||
final list = await _all();
|
||||
list.removeWhere((x) => x.id == noticeId);
|
||||
await _saveAll(list);
|
||||
}
|
||||
|
||||
/// 이름이 [name] 인 회사 그룹 (회사 바로 아래를 먼저). 없으면 회사 아래에 만든다.
|
||||
Future<String?> _companyGroup(String name) async {
|
||||
String? found;
|
||||
for (final v in await _groups.all()) {
|
||||
if (v.group.kind != CardKinds.company || !v.open || v.group.name != name) continue;
|
||||
if (v.group.parent == CardKinds.company) return v.group.id;
|
||||
found ??= v.group.id;
|
||||
}
|
||||
if (found != null) return found;
|
||||
final root = await _groups.view(CardKinds.company);
|
||||
final g = await _groups.add(name, CardKinds.company, parent: root != null && root.open ? CardKinds.company : null);
|
||||
return g?.id;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import 'package:friendly_map/modules/platform/platform.dart';
|
||||
import 'package:friendly_map/modules/storage/storage.dart';
|
||||
|
||||
import 'change_bus.dart';
|
||||
import 'friend_service.dart';
|
||||
import 'group_service.dart';
|
||||
|
||||
/// 주소록 한 사람을 가져올 수 있는지 살핀 결과.
|
||||
class ContactCandidate {
|
||||
const ContactCandidate(this.contact, this.phone, this.known, {this.isMe = false});
|
||||
|
||||
final DeviceContact contact;
|
||||
|
||||
/// 가져올 번호 (표준 형태). 쓸 수 있는 번호가 없으면 null.
|
||||
final String? phone;
|
||||
|
||||
/// 이미 인맥에 있는 사람.
|
||||
final Person? known;
|
||||
final bool isMe;
|
||||
|
||||
String get name => contact.name;
|
||||
bool get importable => phone != null && !isMe;
|
||||
}
|
||||
|
||||
/// 주소록 거르기 조건.
|
||||
class ContactFilter {
|
||||
const ContactFilter({this.query = '', this.label, this.hideKnown = false});
|
||||
|
||||
/// 이름 일부, 초성(예: ㄱㅁㅅ), 번호 일부.
|
||||
final String query;
|
||||
|
||||
/// 주소록 그룹(라벨). null 이면 전부.
|
||||
final String? label;
|
||||
|
||||
/// 이미 인맥에 있는 사람은 빼고 보기.
|
||||
final bool hideKnown;
|
||||
}
|
||||
|
||||
/// 가져오기 결과.
|
||||
class ImportSummary {
|
||||
const ImportSummary({this.added = 0, this.existing = 0, this.skipped = 0, this.grouped = 0, this.requested = 0});
|
||||
final int added; // 새로 인맥에 넣은 사람
|
||||
final int existing; // 이미 있던 사람 (그룹에만 넣음)
|
||||
final int skipped; // 번호가 없거나 내 번호라 건너뜀
|
||||
final int grouped; // 그룹에 새로 들어간 횟수
|
||||
final int requested; // 친구 요청을 보낸 사람
|
||||
}
|
||||
|
||||
const _choseong = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
|
||||
|
||||
/// 한글 이름의 초성 ("김민수" → "ㄱㅁㅅ"). 한글이 아닌 글자는 그대로.
|
||||
String choseongOf(String s) {
|
||||
final b = StringBuffer();
|
||||
for (final r in s.runes) {
|
||||
if (r >= 0xAC00 && r <= 0xD7A3) {
|
||||
b.write(_choseong[(r - 0xAC00) ~/ 588]);
|
||||
} else {
|
||||
b.writeCharCode(r);
|
||||
}
|
||||
}
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
bool _onlyChoseong(String s) => s.isNotEmpty && s.runes.every((r) => _choseong.contains(String.fromCharCode(r)));
|
||||
|
||||
/// 거르기 조건에 맞는지 (순수 계산).
|
||||
bool matchesContact(ContactCandidate c, ContactFilter f) {
|
||||
if (f.hideKnown && c.known != null) return false;
|
||||
if (f.label != null && !c.contact.labels.contains(f.label)) return false;
|
||||
final q = f.query.replaceAll(' ', '').toLowerCase();
|
||||
if (q.isEmpty) return true;
|
||||
final name = c.name.replaceAll(' ', '').toLowerCase();
|
||||
if (name.contains(q)) return true;
|
||||
if (_onlyChoseong(q) && choseongOf(name).contains(q)) return true;
|
||||
final digits = q.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
if (digits.length >= 3 && digits.length == q.replaceAll('-', '').length) {
|
||||
for (final p in c.contact.phones) {
|
||||
if (p.replaceAll(RegExp(r'[^0-9]'), '').contains(digits)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 여러 번호 중 가져올 번호: 휴대폰(+8210)을 먼저, 없으면 처음으로 읽히는 번호.
|
||||
String? bestPhone(List<String> raw) {
|
||||
String? first;
|
||||
for (final r in raw) {
|
||||
final n = normalizePhone(r);
|
||||
if (n == null) continue;
|
||||
if (n.startsWith('+8210')) return n;
|
||||
first ??= n;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/// 기기 주소록에서 사람을 골라 인맥과 그룹에 넣는다. 주소록은 읽기만 하고 고치지 않는다.
|
||||
///
|
||||
/// 가져온 사람은 이 기기 인맥에만 들어가고, [import] 의 sendRequest 를 켰을 때만 친구 요청이 간다.
|
||||
class ContactImportService {
|
||||
ContactImportService(this._storage, this._clock, this._friends, this._groups, this._bus, this._provider);
|
||||
|
||||
final Storage _storage;
|
||||
final Clock _clock;
|
||||
final FriendService _friends;
|
||||
final GroupService _groups;
|
||||
final ChangeBus _bus;
|
||||
final ContactsProvider? _provider;
|
||||
|
||||
/// 이 기기에서 주소록을 읽을 수 있는지.
|
||||
bool get available => _provider != null;
|
||||
|
||||
Future<bool> requestPermission() async => await _provider?.requestPermission() ?? false;
|
||||
|
||||
/// 주소록 전부를 살펴 가져올 수 있는지 표시한다 (이름순).
|
||||
Future<List<ContactCandidate>> load() async {
|
||||
final p = _provider;
|
||||
if (p == null) return const [];
|
||||
final me = (await _storage.settings.loadProfile())?.phone;
|
||||
final out = <ContactCandidate>[];
|
||||
for (final c in await p.all()) {
|
||||
final phone = bestPhone(c.phones);
|
||||
final known = phone == null ? null : await _storage.people.byPhone(phone);
|
||||
out.add(ContactCandidate(c, phone, known, isMe: phone != null && phone == me));
|
||||
}
|
||||
out.sort((a, b) => a.name.compareTo(b.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 조건에 맞는 사람만 (순서 유지).
|
||||
static List<ContactCandidate> filter(List<ContactCandidate> all, ContactFilter f) =>
|
||||
[for (final c in all) if (matchesContact(c, f)) c];
|
||||
|
||||
/// 주소록 그룹(라벨) 이름들 (가나다순).
|
||||
static List<String> labels(List<ContactCandidate> all) =>
|
||||
{for (final c in all) ...c.contact.labels}.toList()..sort();
|
||||
|
||||
/// 고른 사람들을 가져와 [groupIds] 그룹에 넣는다. 같은 번호는 한 번만 넣는다.
|
||||
Future<ImportSummary> import(List<ContactCandidate> picked, {Set<String> groupIds = const {}, bool sendRequest = false}) async {
|
||||
var added = 0, existing = 0, skipped = 0, grouped = 0, requested = 0;
|
||||
final ids = <String>[];
|
||||
final done = <String>{};
|
||||
for (final c in picked) {
|
||||
final phone = c.phone;
|
||||
if (!c.importable || phone == null) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!done.add(phone)) continue;
|
||||
final known = await _storage.people.byPhone(phone);
|
||||
Person? p;
|
||||
if (sendRequest && known?.publicKey == null) {
|
||||
p = await _friends.add(phone, known?.name ?? c.name);
|
||||
if (p != null) requested++;
|
||||
} else {
|
||||
p = known ?? await _storage.people.register(phone, c.name.trim(), _clock.now());
|
||||
}
|
||||
if (p == null) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (known == null) {
|
||||
added++;
|
||||
} else {
|
||||
existing++;
|
||||
}
|
||||
ids.add(p.id);
|
||||
}
|
||||
for (final g in groupIds) {
|
||||
grouped += await _groups.addMembers(g, ids);
|
||||
}
|
||||
_bus.notify();
|
||||
return ImportSummary(added: added, existing: existing, skipped: skipped, grouped: grouped, requested: requested);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,21 +5,26 @@ import 'package:friendly_map/modules/sync/sync.dart';
|
|||
|
||||
import 'app_deps.dart';
|
||||
import 'card_service.dart';
|
||||
import 'career_service.dart';
|
||||
import 'change_bus.dart';
|
||||
import 'call_service.dart';
|
||||
import 'chat_service.dart';
|
||||
import 'contact_import_service.dart';
|
||||
import 'event_service.dart';
|
||||
import 'facts_service.dart';
|
||||
import 'friend_service.dart';
|
||||
import 'group_service.dart';
|
||||
import 'kin_service.dart';
|
||||
import 'layout_service.dart';
|
||||
import 'location_service.dart';
|
||||
import 'network_service.dart';
|
||||
import 'profile_service.dart';
|
||||
import 'recovery_service.dart';
|
||||
import 'relate_service.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'verify_service.dart';
|
||||
import 'visibility_service.dart';
|
||||
import 'work_service.dart';
|
||||
|
||||
/// 앱 내부의 입구. 화면은 이 객체의 서비스들만 쓴다 (저장소·전송·암호를 직접 만지지 않는다).
|
||||
///
|
||||
|
|
@ -54,6 +59,11 @@ class FriendlyApp {
|
|||
kin = KinService(storage, clock, facts, bus);
|
||||
events = EventService(storage, clock, facts, bus, deps.notifier);
|
||||
calls = CallService(storage, deps.callLog, deps.phone);
|
||||
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);
|
||||
career = CareerService(storage, clock, groups, relate, network, bus);
|
||||
}
|
||||
|
||||
final AppDeps deps;
|
||||
|
|
@ -74,6 +84,11 @@ class FriendlyApp {
|
|||
late final KinService kin;
|
||||
late final EventService events;
|
||||
late final CallService calls;
|
||||
late final GroupService groups;
|
||||
late final ContactImportService contacts;
|
||||
late final RelateService relate;
|
||||
late final WorkService work;
|
||||
late final CareerService career;
|
||||
|
||||
StreamSubscription<SyncEvent>? _events;
|
||||
final Set<Future<void>> _tasks = {};
|
||||
|
|
@ -89,9 +104,18 @@ class FriendlyApp {
|
|||
_tasks.add(f);
|
||||
unawaited(f.whenComplete(() => _tasks.remove(f)));
|
||||
} else {
|
||||
// 받은 대화에 업무 표시·긴급 호출이 있으면 업무 쪽이 처리한다.
|
||||
final id = e.refId;
|
||||
// 받은 회사 명함의 회사가 바뀌었으면 이직 알림을 만든다.
|
||||
if ((e.type == SyncEventType.message || e.type == SyncEventType.card) && id != null) {
|
||||
final f = (e.type == SyncEventType.message ? work.onMessage(id) : career.onCard(id)).then((_) {}).catchError((Object _) {});
|
||||
_tasks.add(f);
|
||||
unawaited(f.whenComplete(() => _tasks.remove(f)));
|
||||
}
|
||||
bus.notify();
|
||||
}
|
||||
});
|
||||
await career.seed();
|
||||
await sync.start();
|
||||
unawaited(events.reschedule());
|
||||
final me = await profile.me();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
import 'package:friendly_map/modules/groups/groups.dart';
|
||||
import 'package:friendly_map/modules/storage/storage.dart';
|
||||
|
||||
import 'change_bus.dart';
|
||||
|
||||
/// 그룹 목록 한 칸. 잠긴 그룹이 닫혀 있으면 구성원 수를 알려 주지 않는다.
|
||||
class GroupView {
|
||||
const GroupView(this.group, this.open, {this.depth = 1, this.path = '', this.index = 0, this.siblings = 1, this.deepCount = 0});
|
||||
final Group group;
|
||||
|
||||
/// 지금 구성원을 볼 수 있는지 (자신과 위쪽 잠긴 그룹이 모두 열려 있음).
|
||||
final bool open;
|
||||
|
||||
/// 나무에서의 단계 (맨 위가 1), 맨 위부터 이은 이름, 같은 부모 아래 순서와 개수.
|
||||
final int depth;
|
||||
final String path;
|
||||
final int index;
|
||||
final int siblings;
|
||||
|
||||
/// 하위 그룹까지 합친 사람 수.
|
||||
final int deepCount;
|
||||
|
||||
int? get memberCount => open ? group.members.length : null;
|
||||
int? get totalCount => open ? deepCount : null;
|
||||
bool get canAddChild => depth < GroupBook.maxDepth;
|
||||
}
|
||||
|
||||
/// 비밀번호로 열기 결과.
|
||||
enum UnlockStatus { opened, wrong, waiting, notLocked, missing }
|
||||
|
||||
class UnlockResult {
|
||||
const UnlockResult(this.status, {this.triesLeft = 0, this.waitSeconds = 0});
|
||||
final UnlockStatus status;
|
||||
final int triesLeft;
|
||||
final int waitSeconds;
|
||||
|
||||
bool get ok => status == UnlockStatus.opened || status == UnlockStatus.notLocked;
|
||||
}
|
||||
|
||||
/// 그룹 정의·수정, 구성원, 비밀번호 잠금. 그룹은 이 기기에만 저장하고 어디로도 보내지 않는다.
|
||||
///
|
||||
/// 연 그룹은 이 앱이 켜져 있는 동안만 기억하고, 정한 시간(기본 5분)이 지나면 다시 잠근다.
|
||||
class GroupService {
|
||||
GroupService(this._storage, this._clock, this._bus);
|
||||
|
||||
static const _key = 'groups.v1';
|
||||
final Storage _storage;
|
||||
final Clock _clock;
|
||||
final ChangeBus _bus;
|
||||
|
||||
/// 그룹 id → 열린 시각 (1/100초).
|
||||
final Map<String, int> _openedAt = {};
|
||||
final Map<String, UnlockGuard> _guards = {};
|
||||
|
||||
Future<GroupBook> _load() async => GroupBook.fromJson(await _storage.settings.getText(_key));
|
||||
|
||||
Future<void> _save(GroupBook b) async {
|
||||
await _storage.settings.putText(_key, b.toJson(), _clock.now());
|
||||
_bus.notify();
|
||||
}
|
||||
|
||||
/// 자신과 위쪽 그룹이 모두 열려 있는지.
|
||||
bool _isOpen(GroupBook b, Group g) => _selfOpen(b, g) && b.ancestors(g.id).every((a) => _selfOpen(b, a));
|
||||
|
||||
bool _selfOpen(GroupBook b, Group g) {
|
||||
if (!g.locked) return true;
|
||||
final at = _openedAt[g.id];
|
||||
if (at == null) return false;
|
||||
if (_clock.now() - at > b.relockMinutes * 6000) {
|
||||
_openedAt.remove(g.id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
GroupView _view(GroupBook b, Group g) {
|
||||
final sib = b.children(g.parent);
|
||||
return GroupView(g, _isOpen(b, g),
|
||||
depth: b.depth(g.id), path: b.path(g.id), index: sib.indexOf(g), siblings: sib.length, deepCount: b.membersDeep(g.id).length);
|
||||
}
|
||||
|
||||
/// 나무 순서로 펼친 전체. 닫힌 잠긴 그룹의 하위 그룹은 [includeHidden] 이 아니면 뺀다.
|
||||
Future<List<GroupView>> all({bool includeHidden = false}) async {
|
||||
final b = await _load();
|
||||
return [
|
||||
for (final g in b.groups)
|
||||
if (includeHidden || g.parent == null || _isOpen(b, b.byId(g.parent!)!)) _view(b, g),
|
||||
];
|
||||
}
|
||||
|
||||
Future<GroupView?> view(String id) async {
|
||||
final b = await _load();
|
||||
final g = b.byId(id);
|
||||
return g == null ? null : _view(b, g);
|
||||
}
|
||||
|
||||
/// 바로 아래 그룹들. 이 그룹이 닫혀 있으면 빈 목록.
|
||||
Future<List<GroupView>> children(String id) async {
|
||||
final b = await _load();
|
||||
final g = b.byId(id);
|
||||
if (g == null || !_isOpen(b, g)) return const [];
|
||||
return [for (final c in b.children(id)) _view(b, c)];
|
||||
}
|
||||
|
||||
/// 새 그룹. [parent] 를 주면 그 아래에 (부모가 열려 있어야 한다). 이름이 비었거나 겹치면 null.
|
||||
Future<Group?> add(String name, String kind, {String? parent}) async {
|
||||
final b = await _load();
|
||||
if (parent != null) {
|
||||
final p = b.byId(parent);
|
||||
if (p == null || !_isOpen(b, p)) return null;
|
||||
}
|
||||
final g = b.add(name, kind, parent: parent);
|
||||
if (g != null) await _save(b);
|
||||
return g;
|
||||
}
|
||||
|
||||
/// 이름·종류·순서 바꾸기. 잠긴 그룹은 열려 있을 때만.
|
||||
Future<bool> rename(String id, String name) => _edit(id, (b) => b.rename(id, name));
|
||||
Future<bool> setKind(String id, String kind) => _edit(id, (b) => b.setKind(id, kind));
|
||||
Future<bool> move(String id, int to) => _edit(id, (b) => b.move(id, to));
|
||||
|
||||
/// 그룹을 하위 그룹과 함께 지운다. 잠긴 그룹은 열려 있을 때만 지울 수 있다 (사람은 남는다).
|
||||
Future<bool> remove(String id) async {
|
||||
final b = await _load();
|
||||
final g = b.byId(id);
|
||||
if (g == null || !_isOpen(b, g)) return false;
|
||||
for (final x in b.remove(id)) {
|
||||
_openedAt.remove(x);
|
||||
}
|
||||
await _save(b);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 비밀번호를 걸거나 바꾼다. 이미 잠긴 그룹은 [current] 가 맞아야 한다. 문제가 있으면 안내 글.
|
||||
Future<String?> setPin(String id, String pin, {String? current}) async {
|
||||
final problem = Pin.problem(pin);
|
||||
if (problem != null) return problem;
|
||||
final b = await _load();
|
||||
final g = b.byId(id);
|
||||
if (g == null) return '그룹을 찾을 수 없어요';
|
||||
if (g.locked) {
|
||||
final r = await _check(g, current ?? '');
|
||||
if (!r.ok) return _explain(r);
|
||||
}
|
||||
b.setLock(id, await Pin.make(pin));
|
||||
_openedAt[id] = _clock.now();
|
||||
await _save(b);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 비밀번호를 푼다 (누구나 볼 수 있게 됨). [current] 가 맞아야 한다.
|
||||
Future<String?> clearPin(String id, String current) async {
|
||||
final b = await _load();
|
||||
final g = b.byId(id);
|
||||
if (g == null) return '그룹을 찾을 수 없어요';
|
||||
if (!g.locked) return null;
|
||||
final r = await _check(g, current);
|
||||
if (!r.ok) return _explain(r);
|
||||
b.setLock(id, null);
|
||||
_openedAt.remove(id);
|
||||
await _save(b);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 비밀번호로 연다.
|
||||
Future<UnlockResult> unlock(String id, String pin) async {
|
||||
final g = (await _load()).byId(id);
|
||||
if (g == null) return const UnlockResult(UnlockStatus.missing);
|
||||
final r = await _check(g, pin);
|
||||
if (r.status == UnlockStatus.opened) {
|
||||
_openedAt[id] = _clock.now();
|
||||
_bus.notify();
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/// 바로 다시 잠근다.
|
||||
void lock(String id) {
|
||||
if (_openedAt.remove(id) != null) _bus.notify();
|
||||
}
|
||||
|
||||
/// 연 그룹을 모두 잠근다 (앱이 뒤로 갈 때 등).
|
||||
void lockAll() {
|
||||
if (_openedAt.isEmpty) return;
|
||||
_openedAt.clear();
|
||||
_bus.notify();
|
||||
}
|
||||
|
||||
Future<UnlockResult> _check(Group g, String pin) async {
|
||||
final lock = g.lock;
|
||||
if (lock == null) return const UnlockResult(UnlockStatus.notLocked);
|
||||
final guard = _guards.putIfAbsent(g.id, UnlockGuard.new);
|
||||
final wait = guard.waitLeft(_clock.now());
|
||||
if (wait > 0) return UnlockResult(UnlockStatus.waiting, waitSeconds: (wait + 99) ~/ 100);
|
||||
if (await Pin.verify(pin, lock)) {
|
||||
guard.success();
|
||||
return const UnlockResult(UnlockStatus.opened);
|
||||
}
|
||||
guard.failure(_clock.now());
|
||||
final w = guard.waitLeft(_clock.now());
|
||||
return w > 0
|
||||
? UnlockResult(UnlockStatus.waiting, waitSeconds: (w + 99) ~/ 100)
|
||||
: UnlockResult(UnlockStatus.wrong, triesLeft: guard.triesLeft());
|
||||
}
|
||||
|
||||
String _explain(UnlockResult r) => switch (r.status) {
|
||||
UnlockStatus.waiting => '여러 번 틀려서 ${r.waitSeconds}초 뒤에 다시 해 주세요',
|
||||
UnlockStatus.wrong => '비밀번호가 맞지 않아요 (남은 횟수 ${r.triesLeft})',
|
||||
_ => '그룹을 찾을 수 없어요',
|
||||
};
|
||||
|
||||
/// 구성원 (이름순). 잠겨 있으면 null.
|
||||
Future<List<Person>?> members(String id) async {
|
||||
final b = await _load();
|
||||
final g = b.byId(id);
|
||||
if (g == null || !_isOpen(b, g)) return null;
|
||||
final out = <Person>[];
|
||||
for (final pid in g.members) {
|
||||
final p = await _storage.people.get(pid);
|
||||
if (p != null) out.add(p);
|
||||
}
|
||||
out.sort((a, c) => a.name.compareTo(c.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 이전 구성원 (이름순). 잠겨 있으면 null.
|
||||
Future<List<Person>?> formers(String id) async {
|
||||
final b = await _load();
|
||||
final g = b.byId(id);
|
||||
if (g == null || !_isOpen(b, g)) return null;
|
||||
final out = <Person>[];
|
||||
for (final pid in g.formers) {
|
||||
final p = await _storage.people.get(pid);
|
||||
if (p != null) out.add(p);
|
||||
}
|
||||
out.sort((a, c) => a.name.compareTo(c.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 구성원을 이전 구성원으로 돌린다 (흐리게 남김).
|
||||
Future<bool> markFormer(String id, String person) => _edit(id, (b) => b.markFormer(id, person));
|
||||
|
||||
/// 이전 구성원 표시를 지운다.
|
||||
Future<bool> removeFormer(String id, String person) => _edit(id, (b) => b.removeFormer(id, person));
|
||||
|
||||
/// 구성원을 더한다 (잠긴 그룹은 열려 있을 때만). 새로 들어간 수.
|
||||
Future<int> addMembers(String id, Iterable<String> people) async {
|
||||
final b = await _load();
|
||||
final g = b.byId(id);
|
||||
if (g == null || !_isOpen(b, g)) return 0;
|
||||
final n = b.addMembers(id, people);
|
||||
if (n > 0) await _save(b);
|
||||
return n;
|
||||
}
|
||||
|
||||
Future<bool> removeMember(String id, String person) async {
|
||||
final b = await _load();
|
||||
final g = b.byId(id);
|
||||
if (g == null || !_isOpen(b, g)) return false;
|
||||
final ok = b.removeMember(id, person);
|
||||
if (ok) await _save(b);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// 이 사람이 든 그룹 중 지금 볼 수 있는 것들.
|
||||
Future<List<Group>> groupsOf(String person) async {
|
||||
final b = await _load();
|
||||
return [for (final g in b.groupsOf(person)) if (_isOpen(b, g)) g];
|
||||
}
|
||||
|
||||
/// 닫힌 잠긴 그룹의 사람들 (인맥 목록 등에서 숨긴다).
|
||||
Future<Set<String>> hiddenPeople() async {
|
||||
final b = await _load();
|
||||
return b.hiddenPeople((g) => !_isOpen(b, g));
|
||||
}
|
||||
|
||||
Future<int> relockMinutes() async => (await _load()).relockMinutes;
|
||||
|
||||
Future<void> setRelockMinutes(int minutes) async {
|
||||
final b = await _load();
|
||||
b.relockMinutes = minutes.clamp(1, 60);
|
||||
await _save(b);
|
||||
}
|
||||
|
||||
Future<bool> _edit(String id, bool Function(GroupBook b) f) async {
|
||||
final b = await _load();
|
||||
final g = b.byId(id);
|
||||
if (g == null || !_isOpen(b, g)) return false;
|
||||
final ok = f(b);
|
||||
if (ok) await _save(b);
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import 'package:friendly_map/modules/core/core.dart';
|
||||
import 'package:friendly_map/modules/groups/groups.dart';
|
||||
import 'package:friendly_map/modules/kinship/kinship.dart';
|
||||
|
||||
import 'change_bus.dart';
|
||||
import 'group_service.dart';
|
||||
import 'kin_service.dart';
|
||||
import 'network_service.dart';
|
||||
|
||||
/// 가족으로 넣을 때 고르는 이음: [to](기본 나)의 부모·자녀·배우자·형제자매.
|
||||
enum KinLink { parent, child, spouse, sibling }
|
||||
|
||||
/// 넣은 결과: 들어간 그룹과 (가족이면) 내가 본 관계.
|
||||
class PlaceResult {
|
||||
const PlaceResult(this.groupId, this.groupPath, {this.relation});
|
||||
final String groupId;
|
||||
final String groupPath;
|
||||
final Relation? relation;
|
||||
}
|
||||
|
||||
/// 그룹 종류 → 연결 고리 이름. 공개 범위 표가 이 이름으로 관계 묶음을 찾는다.
|
||||
String? linkWordOf(String kind) => switch (kind) {
|
||||
CardKinds.family => '가족',
|
||||
CardKinds.school => '동문',
|
||||
CardKinds.company => '직장 동료',
|
||||
CardKinds.group => '같은 모임',
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// "채팅하다 보니 가족이네 / 동문이네 / 같은 팀이네" 를 한 번에 정한다.
|
||||
///
|
||||
/// 고른 그룹에 넣고, 연결 고리(가족·동문·직장 동료·같은 모임)를 남긴다. 가족은 가계도에 이어 촌수를 맞추고
|
||||
/// 친가·외가·처가·시가로 나눠 넣는다. 촌수를 모르면 "친척(촌수 모름)" 에 모은다. 모두 이 기기 안에서만 한다.
|
||||
class RelateService {
|
||||
RelateService(this._groups, this._kin, this._network, this._bus);
|
||||
|
||||
static const placeholderParent = '부모(이름 모름)';
|
||||
|
||||
final GroupService _groups;
|
||||
final KinService _kin;
|
||||
final NetworkService _network;
|
||||
final ChangeBus _bus;
|
||||
|
||||
/// 이 사람을 [groupId] 그룹에 넣고 연결 고리를 남긴다. 그룹이 없거나 닫혀 있으면 null.
|
||||
Future<PlaceResult?> into(String personId, String groupId) async {
|
||||
final v = await _groups.view(groupId);
|
||||
if (v == null || !v.open) return null;
|
||||
await _groups.addMembers(groupId, [personId]);
|
||||
final word = linkWordOf(v.group.kind);
|
||||
if (word != null) await _relink(personId, word, v.path);
|
||||
_bus.notify();
|
||||
return PlaceResult(groupId, v.path);
|
||||
}
|
||||
|
||||
/// 가족으로 넣는다. [link] 가 없으면 촌수 모름으로 모은다. 가계도에 이을 수 없으면 null.
|
||||
Future<PlaceResult?> asFamily(String personId, {KinLink? link, String to = KinGraph.meId, Gender gender = Gender.unknown}) async {
|
||||
if (link == null) {
|
||||
final id = await _familyGroup(FamilySide.unknown, unknownBucket: true);
|
||||
if (id == null) return null;
|
||||
await _groups.addMembers(id, [personId]);
|
||||
await _relink(personId, '가족', '촌수 모름');
|
||||
_bus.notify();
|
||||
return PlaceResult(id, (await _groups.view(id))!.path);
|
||||
}
|
||||
await _kin.include(personId, gender: gender);
|
||||
final ok = switch (link) {
|
||||
KinLink.parent => await _kin.setParent(to, personId),
|
||||
KinLink.child => await _kin.setParent(personId, to),
|
||||
KinLink.spouse => await _kin.setSpouse(to, personId),
|
||||
KinLink.sibling => await _sibling(to, personId),
|
||||
};
|
||||
if (!ok) return null;
|
||||
final g = await _kin.graph();
|
||||
final rel = relate(g, KinGraph.meId, personId);
|
||||
final id = await _familyGroup(familySide(g, KinGraph.meId, personId));
|
||||
if (id == null) return null;
|
||||
await _groups.removeMember(Group.unknownRelatives, personId);
|
||||
await _groups.addMembers(id, [personId]);
|
||||
await _relink(personId, '가족', rel.related ? rel.title : '가족');
|
||||
_bus.notify();
|
||||
return PlaceResult(id, (await _groups.view(id))!.path, relation: rel);
|
||||
}
|
||||
|
||||
/// 형제자매: [to] 의 부모를 함께 쓴다. 부모가 가계도에 없으면 이름 모를 부모를 하나 만든다.
|
||||
Future<bool> _sibling(String to, String personId) async {
|
||||
var parents = (await _kin.graph()).parentsOf(to);
|
||||
if (parents.isEmpty) {
|
||||
final p = await _kin.addRelative(placeholderParent);
|
||||
if (p == null || !await _kin.setParent(to, p.id)) return false;
|
||||
parents = {p.id};
|
||||
}
|
||||
for (final p in parents) {
|
||||
if (!await _kin.setParent(personId, p)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 쪽에 맞는 가족 하위 그룹. 지워졌으면 가족 그룹, 그것도 없으면 가족 종류의 첫 그룹.
|
||||
Future<String?> _familyGroup(FamilySide side, {bool unknownBucket = false}) async {
|
||||
final want = unknownBucket
|
||||
? Group.unknownRelatives
|
||||
: switch (side) {
|
||||
FamilySide.paternal => 'family.paternal',
|
||||
FamilySide.maternal => 'family.maternal',
|
||||
FamilySide.inlaw => 'family.inlaw',
|
||||
_ => CardKinds.family,
|
||||
};
|
||||
for (final id in [want, CardKinds.family]) {
|
||||
final v = await _groups.view(id);
|
||||
if (v != null && v.open) return id;
|
||||
}
|
||||
for (final v in await _groups.all()) {
|
||||
if (v.group.kind == CardKinds.family && v.open) return v.group.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 같은 이름의 연결 고리는 하나만 남긴다 (새 근거로 바꾼다).
|
||||
Future<void> _relink(String personId, String word, String detail) async {
|
||||
for (final l in await _network.links(personId)) {
|
||||
if (l.relation == word) await _network.removeLink(l.id);
|
||||
}
|
||||
await _network.addLink(personId, word, detail);
|
||||
}
|
||||
|
||||
/// 이 사람이 들어 있는 (지금 볼 수 있는) 그룹들의 경로 이름.
|
||||
Future<List<String>> placesOf(String personId) async {
|
||||
final out = <String>[];
|
||||
for (final g in await _groups.groupsOf(personId)) {
|
||||
final v = await _groups.view(g.id);
|
||||
if (v != null) out.add(v.path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 그룹에서 뺀다. 그 종류의 그룹에 더 남아 있지 않으면 연결 고리도 지운다.
|
||||
Future<void> leave(String personId, String groupId) async {
|
||||
final v = await _groups.view(groupId);
|
||||
if (v == null || !v.open) return;
|
||||
await _groups.removeMember(groupId, personId);
|
||||
final still = (await _groups.groupsOf(personId)).any((g) => g.kind == v.group.kind);
|
||||
final word = linkWordOf(v.group.kind);
|
||||
if (!still && word != null) {
|
||||
for (final l in await _network.links(personId)) {
|
||||
if (l.relation == word) await _network.removeLink(l.id);
|
||||
}
|
||||
}
|
||||
_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);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/// 그룹: 사람을 묶는 목록(가족·학교·회사·그룹·여행 …)과 비밀번호 잠금
|
||||
///
|
||||
/// 순수 계산만 한다(저장·화면 없음). core 의 명함 종류 이름만 쓴다.
|
||||
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
|
||||
library;
|
||||
|
||||
export 'src/group.dart';
|
||||
export 'src/group_book.dart';
|
||||
export 'src/pin.dart';
|
||||
export 'src/unlock_guard.dart';
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import 'package:friendly_map/modules/core/core.dart';
|
||||
|
||||
import 'pin.dart';
|
||||
|
||||
/// 사람을 묶는 그룹 하나. [kind] 는 명함 종류(가족·학교·회사·그룹·개인) 중 하나다.
|
||||
/// [parent] 가 있으면 그 그룹의 하위 그룹이다 (예: 학교 > 고등학교, 회사 > 한빛전자 > 결제팀).
|
||||
/// [lock] 이 있으면 비밀번호를 넣어야 구성원과 하위 그룹을 볼 수 있다.
|
||||
/// [formers] 는 이전 구성원 (예: 이 회사를 떠난 사람). 화면에서 흐리게 보이고, 구성원 수에는 들지 않는다.
|
||||
class Group {
|
||||
const Group({required this.id, required this.name, required this.kind, this.parent, this.members = const {}, this.formers = const {}, this.lock});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String kind;
|
||||
final String? parent;
|
||||
final Set<String> members; // 사람 id
|
||||
final Set<String> formers; // 이전 구성원 사람 id
|
||||
final PinHash? lock;
|
||||
|
||||
bool get locked => lock != null;
|
||||
|
||||
Group copyWith({String? name, String? kind, Set<String>? members, Set<String>? formers}) => Group(
|
||||
id: id, name: name ?? this.name, kind: kind ?? this.kind, parent: parent,
|
||||
members: members ?? this.members, formers: formers ?? this.formers, lock: lock);
|
||||
|
||||
Group withLock(PinHash? lock) => Group(id: id, name: name, kind: kind, parent: parent, members: members, formers: formers, lock: lock);
|
||||
|
||||
Group withParent(String? parent) => Group(id: id, name: name, kind: kind, parent: parent, members: members, formers: formers, lock: lock);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'kind': kind,
|
||||
if (parent != null) 'parent': parent,
|
||||
'members': members.toList()..sort(),
|
||||
if (formers.isNotEmpty) 'formers': formers.toList()..sort(),
|
||||
if (lock != null) 'lock': lock!.toJson(),
|
||||
};
|
||||
|
||||
static Group? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
final id = j['id'], name = j['name'], kind = j['kind'], m = j['members'], parent = j['parent'], f = j['formers'];
|
||||
if (id is! String || name is! String) return null;
|
||||
return Group(
|
||||
id: id,
|
||||
name: name,
|
||||
kind: kind is String && CardKinds.all.contains(kind) ? kind : CardKinds.group,
|
||||
parent: parent is String && parent != id ? parent : null,
|
||||
members: m is List ? {for (final x in m) if (x is String) x} : const {},
|
||||
formers: f is List ? {for (final x in f) if (x is String) x} : const {},
|
||||
lock: PinHash.fromJson(j['lock']),
|
||||
);
|
||||
}
|
||||
|
||||
/// 촌수를 모르는 친척을 모아 두는 가족 하위 그룹.
|
||||
static const unknownRelatives = 'family.unknown';
|
||||
|
||||
/// 처음 쓸 때 만들어 두는 그룹들 (부모 다음에 자식 순서).
|
||||
static List<Group> defaults() {
|
||||
const f = CardKinds.family, s = CardKinds.school;
|
||||
return const [
|
||||
Group(id: f, name: '가족', kind: f),
|
||||
Group(id: 'family.paternal', name: '친가', kind: f, parent: f),
|
||||
Group(id: 'family.maternal', name: '외가', kind: f, parent: f),
|
||||
Group(id: 'family.inlaw', name: '처가·시가', kind: f, parent: f),
|
||||
Group(id: unknownRelatives, name: '친척(촌수 모름)', kind: f, parent: f),
|
||||
Group(id: s, name: '학교', kind: s),
|
||||
Group(id: 'school.elementary', name: '초등학교', kind: s, parent: s),
|
||||
Group(id: 'school.middle', name: '중학교', kind: s, parent: s),
|
||||
Group(id: 'school.high', name: '고등학교', kind: s, parent: s),
|
||||
Group(id: 'school.university', name: '대학교', kind: s, parent: s),
|
||||
Group(id: 'school.graduate', name: '대학원', kind: s, parent: s),
|
||||
Group(id: CardKinds.company, name: '회사', kind: CardKinds.company),
|
||||
Group(id: CardKinds.group, name: '그룹·여행', kind: CardKinds.group),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'group.dart';
|
||||
import 'pin.dart';
|
||||
|
||||
/// 그룹 나무 전체 (순서·하위 그룹·구성원·잠금·다시 잠그는 시간). 글자(JSON)로 저장했다가 되살린다.
|
||||
///
|
||||
/// 같은 부모 아래의 순서는 [_groups] 에 놓인 순서를 따른다.
|
||||
class GroupBook {
|
||||
GroupBook({List<Group>? groups, this.relockMinutes = defaultRelockMinutes, int nextId = 1})
|
||||
: _groups = [...(groups ?? Group.defaults())],
|
||||
_next = nextId;
|
||||
|
||||
static const defaultRelockMinutes = 5;
|
||||
static const maxNameLength = 20;
|
||||
|
||||
/// 가장 깊은 단계 (맨 위가 1).
|
||||
static const maxDepth = 4;
|
||||
|
||||
final List<Group> _groups;
|
||||
int _next;
|
||||
|
||||
/// 잠긴 그룹을 연 뒤 이 시간(분)이 지나면 다시 잠근다.
|
||||
int relockMinutes;
|
||||
|
||||
/// 나무 순서(부모 다음에 그 자식들)로 펼친 전체 목록.
|
||||
List<Group> get groups {
|
||||
final out = <Group>[];
|
||||
void walk(String? parent) {
|
||||
for (final g in _groups) {
|
||||
if (g.parent == parent) {
|
||||
out.add(g);
|
||||
walk(g.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(null);
|
||||
return List.unmodifiable(out);
|
||||
}
|
||||
|
||||
List<Group> get roots => children(null);
|
||||
|
||||
/// [id] 바로 아래 그룹들 (null 이면 맨 위 그룹들).
|
||||
List<Group> children(String? id) => [for (final g in _groups) if (g.parent == id) g];
|
||||
|
||||
Group? byId(String id) {
|
||||
for (final g in _groups) {
|
||||
if (g.id == id) return g;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 위쪽 그룹들 (가까운 부모부터).
|
||||
List<Group> ancestors(String id) {
|
||||
final out = <Group>[];
|
||||
var p = byId(id)?.parent;
|
||||
while (p != null) {
|
||||
final g = byId(p);
|
||||
if (g == null || out.contains(g)) break;
|
||||
out.add(g);
|
||||
p = g.parent;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int depth(String id) => ancestors(id).length + 1;
|
||||
|
||||
/// "학교 › 고등학교" 처럼 맨 위부터 이어 쓴 이름.
|
||||
String path(String id) => [...ancestors(id).reversed.map((g) => g.name), byId(id)?.name ?? ''].join(' › ');
|
||||
|
||||
/// 자신과 모든 하위 그룹의 id.
|
||||
Set<String> subtree(String id) {
|
||||
final out = <String>{};
|
||||
void walk(String x) {
|
||||
if (!out.add(x)) return;
|
||||
for (final c in children(x)) {
|
||||
walk(c.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (byId(id) != null) walk(id);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 자신과 하위 그룹 구성원 모두.
|
||||
Set<String> membersDeep(String id) => {for (final x in subtree(id)) ...byId(x)!.members};
|
||||
|
||||
/// 이름을 다듬는다. 비었거나 같은 부모 아래에 이미 있는 이름이면 null.
|
||||
String? _cleanName(String name, String? parent, {String? except}) {
|
||||
final n = name.trim();
|
||||
if (n.isEmpty) return null;
|
||||
final cut = n.length > maxNameLength ? n.substring(0, maxNameLength) : n;
|
||||
for (final g in _groups) {
|
||||
if (g.parent == parent && g.id != except && g.name == cut) return null;
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
|
||||
/// 새 그룹을 [parent] 아래 맨 뒤에 더한다. 이름이 비었거나 겹치거나, 부모가 없거나 너무 깊으면 null.
|
||||
Group? add(String name, String kind, {String? parent}) {
|
||||
if (parent != null && (byId(parent) == null || depth(parent) >= maxDepth)) return null;
|
||||
final n = _cleanName(name, parent);
|
||||
if (n == null) return null;
|
||||
var id = 'g${_next++}';
|
||||
while (byId(id) != null) {
|
||||
id = 'g${_next++}';
|
||||
}
|
||||
final g = Group(id: id, name: n, kind: kind, parent: parent);
|
||||
_groups.add(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
bool rename(String id, String name) {
|
||||
final i = _index(id);
|
||||
if (i < 0) return false;
|
||||
final n = _cleanName(name, _groups[i].parent, except: id);
|
||||
if (n == null) return false;
|
||||
_groups[i] = _groups[i].copyWith(name: n);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool setKind(String id, String kind) {
|
||||
final i = _index(id);
|
||||
if (i < 0) return false;
|
||||
_groups[i] = _groups[i].copyWith(kind: kind);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 그룹과 그 하위 그룹을 모두 지운다 (사람은 그대로 남는다). 지운 id 들.
|
||||
Set<String> remove(String id) {
|
||||
final gone = subtree(id);
|
||||
_groups.removeWhere((g) => gone.contains(g.id));
|
||||
return gone;
|
||||
}
|
||||
|
||||
/// [id] 그룹을 같은 부모 아래 [to] 번째로 옮긴다.
|
||||
bool move(String id, int to) {
|
||||
final g = byId(id);
|
||||
if (g == null) return false;
|
||||
_groups.remove(g);
|
||||
final sib = children(g.parent);
|
||||
if (sib.isEmpty || to >= sib.length) {
|
||||
_groups.add(g);
|
||||
} else {
|
||||
_groups.insert(_groups.indexOf(sib[to < 0 ? 0 : to]), g);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool setLock(String id, PinHash? lock) {
|
||||
final i = _index(id);
|
||||
if (i < 0) return false;
|
||||
_groups[i] = _groups[i].withLock(lock);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 구성원을 더한다 (이전 구성원이었으면 다시 구성원으로). 새로 들어간 수를 돌려준다.
|
||||
int addMembers(String id, Iterable<String> people) {
|
||||
final i = _index(id);
|
||||
if (i < 0) return 0;
|
||||
final m = {..._groups[i].members};
|
||||
final before = m.length;
|
||||
m.addAll(people);
|
||||
_groups[i] = _groups[i].copyWith(members: m, formers: {..._groups[i].formers}..removeAll(people));
|
||||
return m.length - before;
|
||||
}
|
||||
|
||||
bool removeMember(String id, String person) {
|
||||
final i = _index(id);
|
||||
if (i < 0) return false;
|
||||
final m = {..._groups[i].members};
|
||||
final ok = m.remove(person);
|
||||
if (ok) _groups[i] = _groups[i].copyWith(members: m);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// 구성원을 이전 구성원으로 돌린다 (흐리게 남김). 구성원이 아니었으면 false.
|
||||
bool markFormer(String id, String person) {
|
||||
final i = _index(id);
|
||||
if (i < 0 || !_groups[i].members.contains(person)) return false;
|
||||
_groups[i] = _groups[i].copyWith(
|
||||
members: {..._groups[i].members}..remove(person),
|
||||
formers: {..._groups[i].formers, person},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 이전 구성원 표시를 지운다.
|
||||
bool removeFormer(String id, String person) {
|
||||
final i = _index(id);
|
||||
if (i < 0 || !_groups[i].formers.contains(person)) return false;
|
||||
_groups[i] = _groups[i].copyWith(formers: {..._groups[i].formers}..remove(person));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 사람을 지울 때 모든 그룹에서 뺀다 (이전 구성원 표시도).
|
||||
void forgetPerson(String person) {
|
||||
for (var i = 0; i < _groups.length; i++) {
|
||||
final g = _groups[i];
|
||||
if (g.members.contains(person) || g.formers.contains(person)) {
|
||||
_groups[i] = g.copyWith(members: {...g.members}..remove(person), formers: {...g.formers}..remove(person));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 이 사람이 들어 있는 그룹들 (나무 순서).
|
||||
List<Group> groupsOf(String person) => [for (final g in groups) if (g.members.contains(person)) g];
|
||||
|
||||
/// 잠긴 그룹 중 [closed] 인 것(지금 열려 있지 않은 것)과 그 하위 그룹의 구성원.
|
||||
/// 다른 그룹에도 있어도 잠긴 쪽을 따른다.
|
||||
Set<String> hiddenPeople(bool Function(Group g) closed) => {
|
||||
for (final g in _groups)
|
||||
if (g.locked && closed(g)) ...{
|
||||
...membersDeep(g.id),
|
||||
for (final x in subtree(g.id)) ...byId(x)!.formers,
|
||||
},
|
||||
};
|
||||
|
||||
int _index(String id) => _groups.indexWhere((g) => g.id == id);
|
||||
|
||||
String toJson() => jsonEncode({
|
||||
'v': 2,
|
||||
'next': _next,
|
||||
'relock': relockMinutes,
|
||||
'groups': [for (final g in _groups) g.toJson()],
|
||||
});
|
||||
|
||||
/// 비었거나 깨진 글자면 처음 그룹들로 시작한다. 없는 부모나 돌고 도는 부모는 맨 위로 올린다.
|
||||
factory GroupBook.fromJson(String? text) {
|
||||
if (text == null || text.isEmpty) return GroupBook();
|
||||
try {
|
||||
final j = jsonDecode(text);
|
||||
if (j is! Map || j['groups'] is! List) return GroupBook();
|
||||
final list = <Group>[];
|
||||
final seen = <String>{};
|
||||
for (final x in j['groups'] as List) {
|
||||
final g = Group.fromJson(x);
|
||||
if (g != null && seen.add(g.id)) list.add(g);
|
||||
}
|
||||
final byId = {for (final g in list) g.id: g};
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
final chain = <String>{list[i].id};
|
||||
var p = list[i].parent;
|
||||
while (p != null && byId.containsKey(p) && chain.add(p)) {
|
||||
p = byId[p]!.parent;
|
||||
}
|
||||
// 맨 위까지 올라가지 못했으면(없는 부모, 돌고 도는 부모) 맨 위로.
|
||||
if (p != null) list[i] = list[i].withParent(null);
|
||||
}
|
||||
final relock = j['relock'];
|
||||
final next = j['next'];
|
||||
return GroupBook(
|
||||
groups: list,
|
||||
relockMinutes: relock is int ? relock.clamp(1, 60) : defaultRelockMinutes,
|
||||
nextId: next is int && next > 0 ? next : list.length + 1,
|
||||
);
|
||||
} on FormatException {
|
||||
return GroupBook();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
|
||||
/// 비밀번호를 그대로 두지 않고 남기는 값 (소금 + PBKDF2-SHA256 결과).
|
||||
class PinHash {
|
||||
const PinHash(this.salt, this.hash, this.iterations);
|
||||
|
||||
final String salt; // base64
|
||||
final String hash; // base64
|
||||
final int iterations;
|
||||
|
||||
Map<String, Object> toJson() => {'salt': salt, 'hash': hash, 'it': iterations};
|
||||
|
||||
static PinHash? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
final s = j['salt'], h = j['hash'], it = j['it'];
|
||||
if (s is! String || h is! String || it is! int) return null;
|
||||
return PinHash(s, h, it);
|
||||
}
|
||||
}
|
||||
|
||||
/// 그룹 비밀번호 만들기·맞춰 보기.
|
||||
class Pin {
|
||||
static const minLength = 4;
|
||||
static const maxLength = 16;
|
||||
static const defaultIterations = 20000;
|
||||
|
||||
static final Random _rng = Random.secure();
|
||||
|
||||
/// 쓸 수 있는 비밀번호인지. 문제가 있으면 안내 글, 괜찮으면 null.
|
||||
static String? problem(String pin) {
|
||||
if (pin.length < minLength) return '비밀번호는 $minLength자 이상이어야 해요';
|
||||
if (pin.length > maxLength) return '비밀번호는 $maxLength자까지예요';
|
||||
if (pin.trim() != pin) return '앞뒤에 빈칸을 넣을 수 없어요';
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<PinHash> make(String pin, {List<int>? salt, int iterations = defaultIterations}) async {
|
||||
final s = salt ?? List<int>.generate(16, (_) => _rng.nextInt(256));
|
||||
final h = await _derive(pin, s, iterations);
|
||||
return PinHash(base64Encode(s), base64Encode(h), iterations);
|
||||
}
|
||||
|
||||
static Future<bool> verify(String pin, PinHash stored) async {
|
||||
final List<int> salt, want;
|
||||
try {
|
||||
salt = base64Decode(stored.salt);
|
||||
want = base64Decode(stored.hash);
|
||||
} on FormatException {
|
||||
return false;
|
||||
}
|
||||
final got = await _derive(pin, salt, stored.iterations);
|
||||
if (got.length != want.length) return false;
|
||||
var diff = 0;
|
||||
for (var i = 0; i < got.length; i++) {
|
||||
diff |= got[i] ^ want[i];
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
static Future<List<int>> _derive(String pin, List<int> salt, int iterations) async {
|
||||
final kdf = Pbkdf2(macAlgorithm: Hmac.sha256(), iterations: iterations, bits: 256);
|
||||
final key = await kdf.deriveKey(secretKey: SecretKey(utf8.encode(pin)), nonce: salt);
|
||||
return key.extractBytes();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/// 비밀번호를 여러 번 틀리면 잠시 기다리게 한다. 시각은 1/100초 단위로 받는다.
|
||||
///
|
||||
/// [freeTries] 번 틀리면 [baseWait] 동안 막고, 그 뒤로 또 틀릴 때마다 기다림이 두 배가 된다(최대 [maxWait]).
|
||||
/// 맞히면 처음으로 돌아간다.
|
||||
class UnlockGuard {
|
||||
UnlockGuard({this.freeTries = 5, this.baseWait = 3000, this.maxWait = 360000});
|
||||
|
||||
final int freeTries;
|
||||
final int baseWait; // 1/100초 (기본 30초)
|
||||
final int maxWait; // 1/100초 (기본 1시간)
|
||||
|
||||
int _fails = 0;
|
||||
int _until = 0;
|
||||
|
||||
int get fails => _fails;
|
||||
|
||||
/// 지금 더 기다려야 하는 시간(1/100초). 0 이면 시도할 수 있다.
|
||||
int waitLeft(int now) => _until > now ? _until - now : 0;
|
||||
|
||||
/// 막히기 전까지 남은 시도 수.
|
||||
int triesLeft() => _fails < freeTries ? freeTries - _fails : 0;
|
||||
|
||||
void success() {
|
||||
_fails = 0;
|
||||
_until = 0;
|
||||
}
|
||||
|
||||
void failure(int now) {
|
||||
_fails++;
|
||||
if (_fails < freeTries) return;
|
||||
final over = _fails - freeTries;
|
||||
var wait = baseWait;
|
||||
for (var i = 0; i < over && wait < maxWait; i++) {
|
||||
wait *= 2;
|
||||
}
|
||||
_until = now + (wait > maxWait ? maxWait : wait);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,3 +6,4 @@ library;
|
|||
|
||||
export 'src/graph.dart';
|
||||
export 'src/relation.dart' show Relation, relate;
|
||||
export 'src/side.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import 'graph.dart';
|
||||
import 'relation.dart';
|
||||
|
||||
/// 가족 모음을 나눌 때의 쪽.
|
||||
enum FamilySide {
|
||||
/// 나와 아주 가까운 가족 (부모·자녀·배우자·형제자매·조카·후손)
|
||||
close,
|
||||
|
||||
/// 아버지 쪽 (친가)
|
||||
paternal,
|
||||
|
||||
/// 어머니 쪽 (외가)
|
||||
maternal,
|
||||
|
||||
/// 배우자 쪽 (처가·시가)
|
||||
inlaw,
|
||||
|
||||
/// 이어졌지만 쪽을 모름 (부모 성별을 모를 때 등)
|
||||
unknown,
|
||||
|
||||
/// 가계도에서 이어지지 않음
|
||||
none,
|
||||
}
|
||||
|
||||
/// [a] 가 본 [b] 의 쪽. 조부모 위로 올라가는 혈족은 처음 올라간 부모의 성별로 친가·외가를 가른다.
|
||||
/// 배우자의 혈족은 처가·시가, 내 혈족의 배우자는 그 혈족의 쪽을 따른다.
|
||||
FamilySide familySide(KinGraph g, String a, String b) {
|
||||
if (!g.has(a) || !g.has(b) || a == b) return FamilySide.none;
|
||||
final blood = bloodPath(g, a, b);
|
||||
if (blood != null) return _bloodSide(g, blood.up, blood.aVia);
|
||||
if (g.spousesOf(a).contains(b)) return FamilySide.close;
|
||||
for (final s in g.spousesOf(a)) {
|
||||
if (bloodPath(g, s, b) != null) return FamilySide.inlaw;
|
||||
}
|
||||
for (final s in g.spousesOf(b)) {
|
||||
final p = bloodPath(g, a, s);
|
||||
if (p != null) return _bloodSide(g, p.up, p.aVia);
|
||||
}
|
||||
return FamilySide.none;
|
||||
}
|
||||
|
||||
FamilySide _bloodSide(KinGraph g, int up, String? via) {
|
||||
if (up <= 1) return FamilySide.close;
|
||||
return switch (via == null ? Gender.unknown : g.person(via)?.gender ?? Gender.unknown) {
|
||||
Gender.male => FamilySide.paternal,
|
||||
Gender.female => FamilySide.maternal,
|
||||
Gender.unknown => FamilySide.unknown,
|
||||
};
|
||||
}
|
||||
|
|
@ -6,7 +6,9 @@
|
|||
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,18 @@
|
|||
/// 기기 주소록의 한 사람.
|
||||
class DeviceContact {
|
||||
const DeviceContact({required this.id, required this.name, this.phones = const [], this.labels = const []});
|
||||
|
||||
final String id; // 기기 주소록의 번호표
|
||||
final String name;
|
||||
final List<String> phones; // 적힌 그대로의 번호들
|
||||
final List<String> labels; // 주소록 그룹(라벨) 이름들
|
||||
}
|
||||
|
||||
/// 기기 주소록 읽기. 권한이 없으면 빈 목록.
|
||||
abstract class ContactsProvider {
|
||||
/// 읽기 권한을 묻는다. 허락받았으면 true.
|
||||
Future<bool> requestPermission();
|
||||
|
||||
/// 번호가 하나 이상 있는 연락처 전부 (이름순).
|
||||
Future<List<DeviceContact>> all();
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/// 지금 바로 띄우는 기기 알림 (긴급 호출 등). 예약 알림([Notifier])과 따로 둔다.
|
||||
abstract class InstantNotifier {
|
||||
Future<void> showNow(String title, String text);
|
||||
}
|
||||
|
|
@ -6,7 +6,9 @@
|
|||
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,34 @@
|
|||
import 'package:flutter_contacts/flutter_contacts.dart' as fc;
|
||||
import 'package:friendly_map/modules/platform/platform.dart';
|
||||
|
||||
/// 안드로이드 주소록 (READ_CONTACTS 권한 필요. 거절되면 빈 목록). 읽기만 하고 고치지 않는다.
|
||||
class AndroidContacts implements ContactsProvider {
|
||||
@override
|
||||
Future<bool> requestPermission() async {
|
||||
try {
|
||||
return await fc.FlutterContacts.requestPermission(readonly: true);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<DeviceContact>> all() async {
|
||||
try {
|
||||
if (!await fc.FlutterContacts.requestPermission(readonly: true)) return const [];
|
||||
final list = await fc.FlutterContacts.getContacts(withProperties: true, withGroups: true);
|
||||
return [
|
||||
for (final c in list)
|
||||
if (c.phones.isNotEmpty)
|
||||
DeviceContact(
|
||||
id: c.id,
|
||||
name: c.displayName.trim().isEmpty ? c.phones.first.number : c.displayName.trim(),
|
||||
phones: [for (final p in c.phones) p.number],
|
||||
labels: [for (final g in c.groups) if (g.name.trim().isNotEmpty) g.name.trim()],
|
||||
),
|
||||
];
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ class CardWalletScreen extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
heroTag: null,
|
||||
onPressed: () => pushPage(context, const TemplateGalleryScreen()),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('명함 추가'),
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import 'package:friendly_map/modules/storage/storage.dart';
|
|||
import '../app_scope.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/async_view.dart';
|
||||
import '../widgets/career_panel.dart';
|
||||
import '../widgets/common.dart';
|
||||
import 'add_friend_screen.dart';
|
||||
import 'chat_room_screen.dart';
|
||||
import 'tasks_screen.dart';
|
||||
|
||||
/// 채팅 목록.
|
||||
class ChatListScreen extends StatelessWidget {
|
||||
|
|
@ -20,6 +22,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()),
|
||||
|
|
@ -28,6 +31,7 @@ class ChatListScreen extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
heroTag: null,
|
||||
tooltip: '새 대화',
|
||||
onPressed: () => _newChat(context),
|
||||
child: const Icon(Icons.chat_bubble_outline),
|
||||
|
|
@ -35,10 +39,13 @@ class ChatListScreen extends StatelessWidget {
|
|||
body: AsyncView<List<RoomSummary>>(
|
||||
load: (app) => app.chats.summaries(),
|
||||
builder: (context, rooms) {
|
||||
if (rooms.isEmpty) return const EmptyNote('아직 대화가 없어요.\n오른쪽 아래 버튼으로 친구와 대화를 시작해 보세요.');
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 90),
|
||||
children: [for (final r in rooms) _RoomTile(r)],
|
||||
children: [
|
||||
const CareerNoticesPanel(),
|
||||
if (rooms.isEmpty) const EmptyNote('아직 대화가 없어요.\n오른쪽 아래 버튼으로 친구와 대화를 시작해 보세요.'),
|
||||
for (final r in rooms) _RoomTile(r),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
import 'dart:convert';
|
||||
|
||||
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);
|
||||
|
|
@ -42,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;
|
||||
|
|
@ -66,6 +113,28 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||
KindChip(room.contextKind, CardKinds.label(room.contextKind)),
|
||||
],
|
||||
]),
|
||||
actions: [
|
||||
if (room != null && room.kind == 'direct')
|
||||
IconButton(
|
||||
tooltip: '관계 정하기',
|
||||
icon: const Icon(Icons.hub_outlined),
|
||||
onPressed: () {
|
||||
final ids = (jsonDecode(room.membersJson) as List).whereType<String>();
|
||||
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: [
|
||||
if (d.advice.recommended)
|
||||
|
|
@ -91,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(
|
||||
|
|
@ -128,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(
|
||||
|
|
@ -137,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)),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:friendly_map/modules/app/app.dart';
|
||||
|
||||
import '../app_scope.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/pin_dialog.dart';
|
||||
|
||||
/// 휴대폰 주소록에서 사람을 골라(한 명·여러 명·거른 사람 모두) 인맥과 그룹에 넣는다.
|
||||
/// [groupId] 를 주면 그 그룹이 미리 골라져 있다.
|
||||
class ContactImportScreen extends StatefulWidget {
|
||||
const ContactImportScreen({super.key, this.groupId});
|
||||
|
||||
final String? groupId;
|
||||
|
||||
@override
|
||||
State<ContactImportScreen> createState() => _ContactImportScreenState();
|
||||
}
|
||||
|
||||
class _ContactImportScreenState extends State<ContactImportScreen> {
|
||||
List<ContactCandidate>? _all;
|
||||
bool _denied = false;
|
||||
String _query = '';
|
||||
String? _label;
|
||||
bool _hideKnown = true;
|
||||
final Set<String> _picked = {}; // 주소록 번호표
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_all == null && !_busy) _load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final svc = AppScope.of(context).app.contacts;
|
||||
_busy = true;
|
||||
final allowed = svc.available && await svc.requestPermission();
|
||||
final list = allowed ? await svc.load() : const <ContactCandidate>[];
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_denied = !allowed;
|
||||
_all = list;
|
||||
_picked.removeWhere((id) => !list.any((c) => c.contact.id == id));
|
||||
});
|
||||
}
|
||||
|
||||
List<ContactCandidate> get _shown =>
|
||||
ContactImportService.filter(_all ?? const [], ContactFilter(query: _query, label: _label, hideKnown: _hideKnown));
|
||||
|
||||
Future<void> _import() async {
|
||||
final app = AppScope.of(context).app;
|
||||
final picked = [for (final c in _all ?? const <ContactCandidate>[]) if (_picked.contains(c.contact.id)) c];
|
||||
final opts = await showModalBottomSheet<_Options>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _OptionsSheet(count: picked.length, preset: widget.groupId),
|
||||
);
|
||||
if (opts == null || !mounted) return;
|
||||
setState(() => _busy = true);
|
||||
final r = await app.contacts.import(picked, groupIds: opts.groups, sendRequest: opts.request);
|
||||
if (!mounted) return;
|
||||
_picked.clear();
|
||||
final parts = [
|
||||
if (r.added > 0) '새로 ${r.added}명',
|
||||
if (r.existing > 0) '이미 있던 ${r.existing}명',
|
||||
if (r.skipped > 0) '건너뜀 ${r.skipped}명',
|
||||
if (r.requested > 0) '친구 요청 ${r.requested}명',
|
||||
];
|
||||
showNote(context, parts.isEmpty ? '가져온 사람이 없어요' : '가져왔어요: ${parts.join(', ')}');
|
||||
await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final svc = AppScope.of(context).app.contacts;
|
||||
final all = _all;
|
||||
Widget body;
|
||||
if (!svc.available) {
|
||||
body = const EmptyNote('이 기기에서는 주소록을 읽을 수 없어요.');
|
||||
} else if (all == null) {
|
||||
body = const Center(child: CircularProgressIndicator());
|
||||
} else if (_denied) {
|
||||
body = Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
const EmptyNote('주소록을 읽을 권한이 없어요.\n휴대폰 설정에서 연락처 권한을 허용해 주세요.'),
|
||||
OutlinedButton(onPressed: _load, child: const Text('다시 묻기')),
|
||||
]);
|
||||
} else {
|
||||
body = _list(all);
|
||||
}
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('주소록에서 가져오기')),
|
||||
body: body,
|
||||
bottomNavigationBar: _picked.isEmpty
|
||||
? null
|
||||
: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton(onPressed: _busy ? null : _import, child: Text('${_picked.length}명 가져오기')),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _list(List<ContactCandidate> all) {
|
||||
final shown = _shown;
|
||||
final pickable = [for (final c in shown) if (c.importable) c.contact.id];
|
||||
final allPicked = pickable.isNotEmpty && pickable.every(_picked.contains);
|
||||
final labels = ContactImportService.labels(all);
|
||||
return Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 6),
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(hintText: '이름 · 초성(ㄱㅁㅅ) · 번호로 찾기', prefixIcon: Icon(Icons.search), isDense: true),
|
||||
onChanged: (v) => setState(() => _query = v),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: ListView(scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: FilterChip(label: const Text('이미 있는 사람 빼기'), selected: _hideKnown, onSelected: (v) => setState(() => _hideKnown = v)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: ChoiceChip(label: const Text('주소록 전체'), selected: _label == null, onSelected: (_) => setState(() => _label = null)),
|
||||
),
|
||||
for (final l in labels)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: ChoiceChip(label: Text(l), selected: _label == l, onSelected: (_) => setState(() => _label = _label == l ? null : l)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 8, 0),
|
||||
child: Row(children: [
|
||||
Expanded(child: Text('보이는 ${shown.length}명 · 고른 ${_picked.length}명', style: const TextStyle(fontSize: 12, color: Pal.muted))),
|
||||
TextButton(
|
||||
onPressed: pickable.isEmpty
|
||||
? null
|
||||
: () => setState(() => allPicked ? _picked.removeAll(pickable) : _picked.addAll(pickable)),
|
||||
child: Text(allPicked ? '보이는 사람 모두 풀기' : '보이는 사람 모두 고르기'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Expanded(
|
||||
child: shown.isEmpty
|
||||
? const EmptyNote('맞는 사람이 없어요.')
|
||||
: ListView.builder(
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (_, i) {
|
||||
final c = shown[i];
|
||||
final note = c.isMe
|
||||
? '내 번호'
|
||||
: c.phone == null
|
||||
? '쓸 수 있는 번호가 없어요'
|
||||
: c.known != null
|
||||
? '${c.phone} · 이미 인맥에 있어요'
|
||||
: c.phone!;
|
||||
return CheckboxListTile(
|
||||
value: _picked.contains(c.contact.id),
|
||||
onChanged: c.importable ? (v) => setState(() => v == true ? _picked.add(c.contact.id) : _picked.remove(c.contact.id)) : null,
|
||||
secondary: Avatar(c.name, size: 36, color: c.known != null ? Pal.school : Pal.accent),
|
||||
title: Text(c.name),
|
||||
subtitle: Text(note, style: const TextStyle(fontSize: 12, color: Pal.muted)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _Options {
|
||||
const _Options(this.groups, this.request);
|
||||
final Set<String> groups;
|
||||
final bool request;
|
||||
}
|
||||
|
||||
/// 가져오기 전에: 넣을 그룹(여러 개)과 친구 요청을 보낼지 고른다.
|
||||
class _OptionsSheet extends StatefulWidget {
|
||||
const _OptionsSheet({required this.count, this.preset});
|
||||
final int count;
|
||||
final String? preset;
|
||||
|
||||
@override
|
||||
State<_OptionsSheet> createState() => _OptionsSheetState();
|
||||
}
|
||||
|
||||
class _OptionsSheetState extends State<_OptionsSheet> {
|
||||
late final Set<String> _groups = {if (widget.preset != null) widget.preset!};
|
||||
bool _request = false;
|
||||
List<GroupView>? _views;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_views ?? _reload();
|
||||
}
|
||||
|
||||
Future<void> _reload() async {
|
||||
final v = await AppScope.of(context).app.groups.all();
|
||||
if (mounted) setState(() => _views = v);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final views = _views;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('${widget.count}명을 어느 그룹에 넣을까요?', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('고르지 않으면 인맥에만 넣어요. 잠긴 그룹은 비밀번호로 연 뒤 고를 수 있어요.', style: TextStyle(fontSize: 12, color: Pal.muted)),
|
||||
const SizedBox(height: 10),
|
||||
if (views == null)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else
|
||||
Wrap(spacing: 6, runSpacing: 6, children: [
|
||||
for (final v in views)
|
||||
FilterChip(
|
||||
avatar: v.group.locked ? Icon(v.open ? Icons.lock_open : Icons.lock, size: 16) : null,
|
||||
label: Text(v.depth > 1 ? v.path : v.group.name),
|
||||
selected: _groups.contains(v.group.id),
|
||||
selectedColor: Pal.ofKind(v.group.kind).withValues(alpha: 0.3),
|
||||
onSelected: (on) async {
|
||||
if (on && !v.open) {
|
||||
if (!await openGroup(context, v)) return;
|
||||
await _reload();
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => on ? _groups.add(v.group.id) : _groups.remove(v.group.id));
|
||||
},
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
ToggleRow(
|
||||
title: '친구 요청도 보내기',
|
||||
subtitle: '끄면 이 기기 인맥에만 넣고 상대에게는 아무것도 가지 않아요',
|
||||
value: _request,
|
||||
onChanged: (v) => setState(() => _request = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: FilledButton(onPressed: () => Navigator.pop(context, _Options(_groups, _request)), child: const Text('가져오기')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
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 '../app_scope.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/async_view.dart';
|
||||
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';
|
||||
|
||||
class _Data {
|
||||
const _Data(this.view, this.children, this.grandchildren, this.members, this.formers);
|
||||
final GroupView? view;
|
||||
final List<GroupView> children;
|
||||
final Map<String, int> grandchildren; // 하위 그룹 id → 그 아래 그룹 수
|
||||
final List<Person>? members; // 닫혀 있으면 null
|
||||
final List<Person> formers; // 이전 구성원 (흐리게)
|
||||
}
|
||||
|
||||
/// 그룹 한 개: 하위 그룹과 구성원 보기·넣기·빼기. 잠긴 그룹이 닫히면 모두 감춘다.
|
||||
class GroupDetailScreen extends StatelessWidget {
|
||||
const GroupDetailScreen({super.key, required this.groupId});
|
||||
|
||||
final String groupId;
|
||||
|
||||
Future<_Data> _load(FriendlyApp a) async {
|
||||
final kids = await a.groups.children(groupId);
|
||||
return _Data(
|
||||
await a.groups.view(groupId),
|
||||
kids,
|
||||
{for (final k in kids) k.group.id: (await a.groups.children(k.group.id)).length},
|
||||
await a.groups.members(groupId),
|
||||
await a.groups.formers(groupId) ?? const [],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addFromPeople(BuildContext context, List<Person> members) async {
|
||||
final app = AppScope.of(context).app;
|
||||
final hidden = await app.groups.hiddenPeople();
|
||||
final all = [for (final p in await app.friends.people()) if (!hidden.contains(p.id)) p];
|
||||
if (!context.mounted) return;
|
||||
final picked = await pickPeople(context, all, already: {for (final m in members) m.id});
|
||||
if (picked == null || !context.mounted) return;
|
||||
final n = await app.groups.addMembers(groupId, picked);
|
||||
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;
|
||||
return AsyncView<_Data>(
|
||||
load: _load,
|
||||
builder: (context, d) {
|
||||
final v = d.view;
|
||||
if (v == null) {
|
||||
return Scaffold(appBar: AppBar(), body: const EmptyNote('지워진 그룹이에요.'));
|
||||
}
|
||||
final g = v.group;
|
||||
final members = d.members;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Flexible(child: Text(g.name, overflow: TextOverflow.ellipsis)),
|
||||
const SizedBox(width: 8),
|
||||
KindChip(g.kind, CardKinds.label(g.kind)),
|
||||
]),
|
||||
if (v.depth > 1) Text(v.path, style: const TextStyle(fontSize: 11, color: Pal.muted)),
|
||||
]),
|
||||
actions: [
|
||||
if (g.locked && members != null)
|
||||
IconButton(tooltip: '지금 잠그기', onPressed: () => app.groups.lock(groupId), icon: const Icon(Icons.lock_outline)),
|
||||
GroupMenuButton(v, onDeleted: () {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
}),
|
||||
],
|
||||
),
|
||||
body: members == null ? _closed(context, v) : _open(context, v, d, members),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _closed(BuildContext context, GroupView v) => Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
const Icon(Icons.lock, size: 48, color: Pal.muted),
|
||||
const SizedBox(height: 12),
|
||||
const Text('잠긴 그룹이에요', style: TextStyle(color: Pal.muted)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: () => openGroup(context, v), child: const Text('비밀번호로 열기')),
|
||||
]),
|
||||
);
|
||||
|
||||
Widget _open(BuildContext context, GroupView v, _Data d, List<Person> members) {
|
||||
final app = AppScope.of(context).app;
|
||||
final g = v.group;
|
||||
return ListView(padding: const EdgeInsets.only(top: 4, bottom: 96), children: [
|
||||
if (d.children.isNotEmpty || v.canAddChild)
|
||||
SectionHeader('하위 그룹',
|
||||
trailing: v.canAddChild
|
||||
? TextButton.icon(onPressed: () => addChildGroup(context, v), icon: const Icon(Icons.add, size: 18), label: const Text('만들기'))
|
||||
: 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}명',
|
||||
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: [
|
||||
Expanded(child: OutlinedButton.icon(onPressed: () => _addFromPeople(context, members), icon: const Icon(Icons.group_add_outlined), label: const Text('인맥에서 넣기'))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => pushPage(context, ContactImportScreen(groupId: groupId)),
|
||||
icon: const Icon(Icons.contact_phone_outlined),
|
||||
label: const Text('주소록에서'),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
if (members.isEmpty) const EmptyNote('아직 아무도 없어요.\n위 버튼으로 사람을 넣어 보세요.'),
|
||||
for (final p in members)
|
||||
ListTile(
|
||||
leading: Avatar(p.name, size: 40, color: Pal.ofKind(g.kind)),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(isLocalPhone(p.phone) ? '번호 없음' : p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)),
|
||||
onTap: () => pushPage(context, PersonScreen(personId: p.id)),
|
||||
trailing: IconButton(
|
||||
tooltip: '그룹에서 빼기',
|
||||
icon: const Icon(Icons.remove_circle_outline, color: Pal.muted),
|
||||
onPressed: () async {
|
||||
final ok = await askConfirm(context, '${p.name} 빼기', '이 그룹에서만 빼고 인맥에는 남아요.', ok: '빼기');
|
||||
if (ok) await app.groups.removeMember(groupId, p.id);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (d.formers.isNotEmpty) SectionHeader('이전 구성원 ${d.formers.length}명'),
|
||||
for (final p in d.formers)
|
||||
Opacity(
|
||||
opacity: 0.45,
|
||||
child: ListTile(
|
||||
leading: Avatar(p.name, size: 40, color: Pal.muted),
|
||||
title: Text(p.name),
|
||||
subtitle: const Text('떠남 · 지금은 이 그룹에 없어요', style: TextStyle(fontSize: 12, color: Pal.muted)),
|
||||
onTap: () => pushPage(context, PersonScreen(personId: p.id)),
|
||||
trailing: IconButton(
|
||||
tooltip: '이전 구성원에서 지우기',
|
||||
icon: const Icon(Icons.close, color: Pal.muted),
|
||||
onPressed: () => app.groups.removeFormer(groupId, p.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:friendly_map/modules/app/app.dart';
|
||||
|
||||
import '../app_scope.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/async_view.dart';
|
||||
import '../widgets/career_panel.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/group_dialogs.dart';
|
||||
import '../widgets/group_menu.dart';
|
||||
import 'contact_import_screen.dart';
|
||||
import 'group_detail_screen.dart';
|
||||
|
||||
class _Data {
|
||||
const _Data(this.groups, this.relock);
|
||||
final List<GroupView> groups; // 펼친 전체 (보이는 것만)
|
||||
final int relock;
|
||||
}
|
||||
|
||||
/// 설정 > 그룹 관리: 맨 위 그룹 목록. 누르면 그 그룹 안(하위 그룹·구성원)으로 들어간다.
|
||||
class GroupsScreen extends StatelessWidget {
|
||||
const GroupsScreen({super.key});
|
||||
|
||||
Future<void> _add(BuildContext context) async {
|
||||
final app = AppScope.of(context).app;
|
||||
final r = await askGroupInfo(context);
|
||||
if (r == null || !context.mounted) return;
|
||||
final g = await app.groups.add(r.$1, r.$2);
|
||||
if (context.mounted && g == null) showNote(context, '이름이 비었거나 같은 이름의 그룹이 있어요');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context).app;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('그룹 관리'),
|
||||
actions: [
|
||||
IconButton(tooltip: '주소록에서 가져오기', onPressed: () => pushPage(context, const ContactImportScreen()), icon: const Icon(Icons.contact_phone_outlined)),
|
||||
IconButton(tooltip: '새 그룹', onPressed: () => _add(context), icon: const Icon(Icons.add)),
|
||||
],
|
||||
),
|
||||
body: AsyncView<_Data>(
|
||||
load: (a) async => _Data(await a.groups.all(), await a.groups.relockMinutes()),
|
||||
builder: (context, d) => ListView(padding: const EdgeInsets.only(top: 4, bottom: 32), children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 0, 20, 10),
|
||||
child: Text('가족·학교 같은 그룹을 정하고 그 아래에 하위 그룹(고등학교, 회사 › 팀 …)을 만들어 사람을 묶어요. '
|
||||
'비밀번호를 건 그룹은 열 때마다 물어보고, 닫혀 있으면 하위 그룹과 인맥 목록에서도 숨겨요. 그룹은 이 기기에만 저장돼요.',
|
||||
style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)),
|
||||
),
|
||||
const CareerNoticesPanel(),
|
||||
for (final v in d.groups)
|
||||
if (v.depth == 1)
|
||||
GroupTile(
|
||||
v,
|
||||
children: d.groups.where((c) => c.group.parent == v.group.id).length,
|
||||
onOpen: () => pushPage(context, GroupDetailScreen(groupId: v.group.id)),
|
||||
),
|
||||
Panel(
|
||||
child: Row(children: [
|
||||
const Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('잠긴 그룹 다시 잠그기'),
|
||||
Text('연 뒤 이 시간이 지나거나 앱을 내리면 다시 잠가요', style: TextStyle(fontSize: 11, color: Pal.muted)),
|
||||
]),
|
||||
),
|
||||
IconButton.outlined(tooltip: '줄이기', onPressed: () => app.groups.setRelockMinutes(d.relock - 1), icon: const Icon(Icons.remove)),
|
||||
SizedBox(width: 56, child: Text('${d.relock}분', textAlign: TextAlign.center, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Pal.accent))),
|
||||
IconButton.outlined(tooltip: '늘리기', onPressed: () => app.groups.setRelockMinutes(d.relock + 1), icon: const Icon(Icons.add)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app_scope.dart';
|
||||
import 'card_wallet_screen.dart';
|
||||
import 'chat_list_screen.dart';
|
||||
import 'discover_screen.dart';
|
||||
|
|
@ -14,9 +15,29 @@ class HomeShell extends StatefulWidget {
|
|||
State<HomeShell> createState() => _HomeShellState();
|
||||
}
|
||||
|
||||
class _HomeShellState extends State<HomeShell> {
|
||||
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
int _index = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 앱을 내리면 연 그룹을 모두 다시 잠근다.
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) {
|
||||
AppScope.of(context).app.groups.lockAll();
|
||||
}
|
||||
}
|
||||
|
||||
static const _tabs = <(IconData, String)>[
|
||||
(Icons.home_outlined, '홈'),
|
||||
(Icons.chat_bubble_outline, '채팅'),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ import '../widgets/common.dart';
|
|||
import 'add_friend_screen.dart';
|
||||
import 'person_screen.dart';
|
||||
|
||||
/// 친구 전체 목록.
|
||||
class _Data {
|
||||
const _Data(this.people, this.hidden);
|
||||
final List<Person> people;
|
||||
final int hidden; // 잠긴 그룹이라 숨긴 사람 수
|
||||
}
|
||||
|
||||
/// 친구 전체 목록. 닫힌 잠긴 그룹의 사람은 보이지 않는다.
|
||||
class PeopleScreen extends StatelessWidget {
|
||||
const PeopleScreen({super.key});
|
||||
|
||||
|
|
@ -18,11 +24,26 @@ class PeopleScreen extends StatelessWidget {
|
|||
title: const Text('인맥'),
|
||||
actions: [IconButton(tooltip: '새 친구', onPressed: () => pushPage(context, const AddFriendScreen()), icon: const Icon(Icons.person_add_alt_1))],
|
||||
),
|
||||
body: AsyncView<List<Person>>(
|
||||
load: (app) => app.friends.people(),
|
||||
builder: (context, list) {
|
||||
if (list.isEmpty) return const EmptyNote('아직 친구가 없어요.\n오른쪽 위 버튼으로 첫 친구를 추가해 보세요.');
|
||||
body: AsyncView<_Data>(
|
||||
load: (app) async {
|
||||
final hidden = await app.groups.hiddenPeople();
|
||||
final all = await app.friends.people();
|
||||
final shown = [for (final p in all) if (!hidden.contains(p.id)) p];
|
||||
return _Data(shown, all.length - shown.length);
|
||||
},
|
||||
builder: (context, d) {
|
||||
final list = d.people;
|
||||
if (list.isEmpty && d.hidden == 0) return const EmptyNote('아직 친구가 없어요.\n오른쪽 위 버튼으로 첫 친구를 추가해 보세요.');
|
||||
return ListView(padding: const EdgeInsets.only(top: 8, bottom: 32), children: [
|
||||
if (d.hidden > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.lock, size: 14, color: Pal.muted),
|
||||
const SizedBox(width: 6),
|
||||
Text('잠긴 그룹의 ${d.hidden}명은 숨겼어요', style: const TextStyle(fontSize: 12, color: Pal.muted)),
|
||||
]),
|
||||
),
|
||||
for (final p in list)
|
||||
ListTile(
|
||||
leading: Avatar(p.name, size: 40),
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import 'package:friendly_map/modules/storage/storage.dart';
|
|||
import '../app_scope.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/async_view.dart';
|
||||
import '../widgets/career_panel.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/labels.dart';
|
||||
import '../widgets/person_summary.dart';
|
||||
import '../widgets/relate_sheet.dart';
|
||||
import 'chat_room_screen.dart';
|
||||
import 'person_facts_screen.dart';
|
||||
import 'infer_screen.dart';
|
||||
|
|
@ -62,6 +64,7 @@ class PersonScreen extends StatelessWidget {
|
|||
if (p == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('찾을 수 없는 사람이에요.'));
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(p.name), actions: [
|
||||
IconButton(tooltip: '관계 정하기', onPressed: () => showRelateSheet(context, p.id, p.name), icon: const Icon(Icons.hub_outlined)),
|
||||
IconButton(
|
||||
tooltip: '정보 고치기',
|
||||
onPressed: () => pushPage(context, PersonFactsScreen(personId: p.id)),
|
||||
|
|
@ -70,6 +73,7 @@ class PersonScreen extends StatelessWidget {
|
|||
]),
|
||||
body: ListView(padding: const EdgeInsets.only(bottom: 32), children: [
|
||||
if (d.info.markDeceased) DeceasedBanner(d.info.facts),
|
||||
CareerNoticesPanel(personId: p.id),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
|
|
@ -127,6 +131,7 @@ class PersonScreen extends StatelessWidget {
|
|||
]),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
PlacesPanel(personId: p.id, name: p.name),
|
||||
RelationPanel(d.info.relations, onEdit: () => pushPage(context, PersonFactsScreen(personId: p.id))),
|
||||
CallPanel(d.info),
|
||||
EventsPanel(d.info.events),
|
||||
|
|
|
|||
|
|
@ -6,13 +6,15 @@ import '../theme.dart';
|
|||
import '../widgets/async_view.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/event_settings_panel.dart';
|
||||
import 'contact_import_screen.dart';
|
||||
import 'groups_screen.dart';
|
||||
import 'held_shares_screen.dart';
|
||||
import 'recovery_entrust_screen.dart';
|
||||
import 'welcome_screen.dart';
|
||||
|
||||
const int _mb = 1024 * 1024;
|
||||
|
||||
/// 저장·보안 설정: 별도 저장 기준, 기기 저장 공간 기준, 복구 코드와 조각 수.
|
||||
/// 저장·보안 설정: 별도 저장 기준, 기기 저장 공간 기준, 그룹·주소록, 복구 코드와 조각 수.
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
|
|
@ -63,6 +65,20 @@ class SettingsScreen extends StatelessWidget {
|
|||
]),
|
||||
),
|
||||
const EventSettingsPanel(),
|
||||
Panel(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('그룹 · 주소록', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('가족·학교 같은 그룹을 정하고 고쳐요. 그룹마다 비밀번호를 걸 수 있어요. 휴대폰 주소록에서 한 명씩 또는 여러 명을 골라 그룹에 넣어요.',
|
||||
style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)),
|
||||
const SizedBox(height: 10),
|
||||
Row(children: [
|
||||
Expanded(child: OutlinedButton(onPressed: () => pushPage(context, const GroupsScreen()), child: const Text('그룹 관리'))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: OutlinedButton(onPressed: () => pushPage(context, const ContactImportScreen()), child: const Text('주소록에서 가져오기'))),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
Panel(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('복구 코드', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
|
||||
|
|
|
|||
|
|
@ -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,60 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:friendly_map/modules/app/app.dart';
|
||||
|
||||
import '../app_scope.dart';
|
||||
import '../theme.dart';
|
||||
import 'async_view.dart';
|
||||
import 'common.dart';
|
||||
|
||||
class _Item {
|
||||
const _Item(this.notice, this.text);
|
||||
final CareerNotice notice;
|
||||
final String text;
|
||||
}
|
||||
|
||||
/// 이직 알림 칸들: "김민수님이 한빛전자에서 누리소프트로 옮겼어요" + 옮기기 / 이전 회사에서 지우기 / 무시.
|
||||
/// [personId] 를 주면 그 사람 것만 보인다. 알림이 없으면 아무것도 그리지 않는다.
|
||||
class CareerNoticesPanel extends StatelessWidget {
|
||||
const CareerNoticesPanel({super.key, this.personId});
|
||||
|
||||
final String? personId;
|
||||
|
||||
Future<void> _accept(BuildContext context, CareerNotice n, bool keepOld) async {
|
||||
final placed = await AppScope.of(context).app.career.accept(n.id, keepOld: keepOld);
|
||||
if (!context.mounted) return;
|
||||
final old = n.from.isEmpty ? '' : (keepOld ? '${n.from}에서는 이전 구성원으로 흐리게 남겼어요. ' : '${n.from}에서 뺐어요. ');
|
||||
showNote(context, '$old${placed == null ? '' : '$placed에 넣었어요.'}'.trim());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context).app;
|
||||
return AsyncView<List<_Item>>(
|
||||
load: (a) async => [for (final n in await a.career.notices(personId: personId)) _Item(n, await a.career.describe(n))],
|
||||
builder: (context, items) => Column(children: [
|
||||
for (final it in items)
|
||||
Panel(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.swap_horiz, color: Pal.company),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(it.text, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
it.notice.left ? '확인하면 이전 회사 그룹에서 흐리게 남겨요.' : '확인하면 이전 회사 그룹에서는 흐리게 남기고 새 회사 그룹으로 옮겨요.',
|
||||
style: const TextStyle(fontSize: 12, color: Pal.muted),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(spacing: 8, runSpacing: 4, children: [
|
||||
FilledButton(onPressed: () => _accept(context, it.notice, true), child: Text(it.notice.left ? '확인' : '확인 · 옮기기')),
|
||||
if (it.notice.from.isNotEmpty)
|
||||
OutlinedButton(onPressed: () => _accept(context, it.notice, false), child: const Text('이전 회사에서 지우기')),
|
||||
TextButton(onPressed: () => app.career.dismiss(it.notice.id), child: const Text('무시')),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:friendly_map/modules/core/core.dart';
|
||||
import 'package:friendly_map/modules/storage/storage.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
import 'common.dart';
|
||||
|
||||
/// 그룹 이름과 종류를 묻는다. 확인하면 (이름, 종류).
|
||||
Future<(String, String)?> askGroupInfo(BuildContext context, {String title = '새 그룹', String name = '', String kind = CardKinds.group}) =>
|
||||
showDialog<(String, String)>(context: context, builder: (_) => _GroupInfo(title: title, name: name, kind: kind));
|
||||
|
||||
class _GroupInfo extends StatefulWidget {
|
||||
const _GroupInfo({required this.title, required this.name, required this.kind});
|
||||
final String title;
|
||||
final String name;
|
||||
final String kind;
|
||||
|
||||
@override
|
||||
State<_GroupInfo> createState() => _GroupInfoState();
|
||||
}
|
||||
|
||||
class _GroupInfoState extends State<_GroupInfo> {
|
||||
late final _ctl = TextEditingController(text: widget.name);
|
||||
late String _kind = widget.kind;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
TextField(
|
||||
controller: _ctl,
|
||||
autofocus: true,
|
||||
maxLength: 20,
|
||||
decoration: const InputDecoration(labelText: '이름 (예: 고등학교 동창)', isDense: true),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text('종류', style: TextStyle(fontSize: 12, color: Pal.muted)),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(spacing: 6, runSpacing: 6, children: [
|
||||
for (final k in CardKinds.all)
|
||||
ChoiceChip(
|
||||
label: Text(CardKinds.label(k)),
|
||||
selected: _kind == k,
|
||||
selectedColor: Pal.ofKind(k).withValues(alpha: 0.3),
|
||||
onSelected: (_) => setState(() => _kind = k),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('취소')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, (_ctl.text, _kind)), child: const Text('확인')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 여러 사람을 고른다. [already] 에 든 사람은 이미 들어 있다고 표시하고 고를 수 없다.
|
||||
Future<Set<String>?> pickPeople(BuildContext context, List<Person> people, {Set<String> already = const {}, String title = '넣을 사람 고르기'}) {
|
||||
return showModalBottomSheet<Set<String>>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (c) => _PeoplePicker(people: people, already: already, title: title),
|
||||
);
|
||||
}
|
||||
|
||||
class _PeoplePicker extends StatefulWidget {
|
||||
const _PeoplePicker({required this.people, required this.already, required this.title});
|
||||
final List<Person> people;
|
||||
final Set<String> already;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<_PeoplePicker> createState() => _PeoplePickerState();
|
||||
}
|
||||
|
||||
class _PeoplePickerState extends State<_PeoplePicker> {
|
||||
final Set<String> _picked = {};
|
||||
String _q = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = [
|
||||
for (final p in widget.people)
|
||||
if (_q.isEmpty || p.name.contains(_q) || p.phone.contains(_q)) p,
|
||||
];
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
child: Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Row(children: [
|
||||
Expanded(child: Text(widget.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700))),
|
||||
FilledButton(
|
||||
onPressed: _picked.isEmpty ? null : () => Navigator.pop(context, _picked),
|
||||
child: Text('${_picked.length}명 넣기'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(hintText: '이름·번호로 찾기', prefixIcon: Icon(Icons.search), isDense: true),
|
||||
onChanged: (v) => setState(() => _q = v.trim()),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: shown.isEmpty
|
||||
? const EmptyNote('넣을 사람이 없어요.')
|
||||
: ListView(children: [
|
||||
for (final p in shown)
|
||||
CheckboxListTile(
|
||||
value: widget.already.contains(p.id) || _picked.contains(p.id),
|
||||
onChanged: widget.already.contains(p.id)
|
||||
? null
|
||||
: (v) => setState(() => v == true ? _picked.add(p.id) : _picked.remove(p.id)),
|
||||
secondary: Avatar(p.name, size: 36),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(widget.already.contains(p.id) ? '이미 들어 있어요' : p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:friendly_map/modules/app/app.dart';
|
||||
|
||||
import '../app_scope.dart';
|
||||
import '../theme.dart';
|
||||
import 'common.dart';
|
||||
import 'group_dialogs.dart';
|
||||
import 'pin_dialog.dart';
|
||||
|
||||
enum _Act { addChild, edit, up, down, setPin, clearPin, lockNow, delete }
|
||||
|
||||
/// 하위 그룹을 만든다 (종류는 부모를 따른다). 만들었으면 true.
|
||||
Future<bool> addChildGroup(BuildContext context, GroupView parent) async {
|
||||
final app = AppScope.of(context).app;
|
||||
if (!await openGroup(context, parent) || !context.mounted) return false;
|
||||
final r = await askGroupInfo(context, title: '${parent.group.name} 아래 새 그룹', kind: parent.group.kind);
|
||||
if (r == null || !context.mounted) return false;
|
||||
final g = await app.groups.add(r.$1, r.$2, parent: parent.group.id);
|
||||
if (context.mounted && g == null) showNote(context, '이름이 비었거나 같은 이름의 그룹이 있어요');
|
||||
return g != null;
|
||||
}
|
||||
|
||||
/// 그룹 한 칸의 "더 보기" 메뉴: 하위 그룹, 이름·종류, 순서, 비밀번호, 잠그기, 지우기.
|
||||
class GroupMenuButton extends StatelessWidget {
|
||||
const GroupMenuButton(this.view, {super.key, this.onDeleted});
|
||||
|
||||
final GroupView view;
|
||||
final VoidCallback? onDeleted;
|
||||
|
||||
Future<void> _act(BuildContext context, _Act a) async {
|
||||
final app = AppScope.of(context).app;
|
||||
final v = view;
|
||||
final id = v.group.id;
|
||||
if (a == _Act.addChild) {
|
||||
await addChildGroup(context, v);
|
||||
return;
|
||||
}
|
||||
// 잠긴 그룹은 무엇을 하든 먼저 연다 (지금 잠그기는 이미 열린 상태에서만 보인다).
|
||||
if (a != _Act.lockNow && !await openGroup(context, v)) return;
|
||||
if (!context.mounted) return;
|
||||
switch (a) {
|
||||
case _Act.addChild:
|
||||
break;
|
||||
case _Act.edit:
|
||||
final r = await askGroupInfo(context, title: '그룹 고치기', name: v.group.name, kind: v.group.kind);
|
||||
if (r == null || !context.mounted) return;
|
||||
if (r.$1.trim() != v.group.name && !await app.groups.rename(id, r.$1)) {
|
||||
if (context.mounted) showNote(context, '이름이 비었거나 같은 이름의 그룹이 있어요');
|
||||
return;
|
||||
}
|
||||
if (r.$2 != v.group.kind) await app.groups.setKind(id, r.$2);
|
||||
case _Act.up:
|
||||
await app.groups.move(id, v.index - 1);
|
||||
case _Act.down:
|
||||
await app.groups.move(id, v.index + 1);
|
||||
case _Act.setPin:
|
||||
final now = await app.groups.view(id);
|
||||
if (now != null && context.mounted) await setGroupPin(context, now);
|
||||
case _Act.clearPin:
|
||||
final now = await app.groups.view(id);
|
||||
if (now != null && context.mounted) await clearGroupPin(context, now);
|
||||
case _Act.lockNow:
|
||||
app.groups.lock(id);
|
||||
case _Act.delete:
|
||||
final ok = await askConfirm(context, '${v.group.name} 지우기', '이 그룹과 하위 그룹을 지워요. 사람은 인맥에 남아요.', ok: '지우기');
|
||||
if (ok && await app.groups.remove(id)) onDeleted?.call();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final v = view;
|
||||
final g = v.group;
|
||||
return PopupMenuButton<_Act>(
|
||||
tooltip: '더 보기',
|
||||
onSelected: (a) => _act(context, a),
|
||||
itemBuilder: (_) => [
|
||||
if (v.canAddChild) const PopupMenuItem(value: _Act.addChild, child: Text('하위 그룹 만들기')),
|
||||
const PopupMenuItem(value: _Act.edit, child: Text('이름·종류 고치기')),
|
||||
if (v.index > 0) const PopupMenuItem(value: _Act.up, child: Text('위로')),
|
||||
if (v.index < v.siblings - 1) const PopupMenuItem(value: _Act.down, child: Text('아래로')),
|
||||
PopupMenuItem(value: _Act.setPin, child: Text(g.locked ? '비밀번호 바꾸기' : '비밀번호 걸기')),
|
||||
if (g.locked) const PopupMenuItem(value: _Act.clearPin, child: Text('비밀번호 풀기')),
|
||||
if (g.locked && v.open) const PopupMenuItem(value: _Act.lockNow, child: Text('지금 잠그기')),
|
||||
const PopupMenuItem(value: _Act.delete, child: Text('지우기', style: TextStyle(color: Pal.danger))),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 그룹 한 칸 (목록에 쓴다). 누르면 [onOpen] (잠겨 있으면 먼저 비밀번호를 묻는다).
|
||||
class GroupTile extends StatelessWidget {
|
||||
const GroupTile(this.view, {super.key, required this.onOpen, this.children = 0});
|
||||
|
||||
final GroupView view;
|
||||
final VoidCallback onOpen;
|
||||
final int children; // 하위 그룹 수
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final v = view;
|
||||
final g = v.group;
|
||||
final parts = <String>[
|
||||
if (!v.open) '잠김' else '${v.totalCount}명',
|
||||
if (v.open && children > 0) '하위 $children개',
|
||||
if (g.locked && v.open) '열림',
|
||||
];
|
||||
return Panel(
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 4, 6),
|
||||
onTap: () async {
|
||||
if (await openGroup(context, v) && context.mounted) onOpen();
|
||||
},
|
||||
child: Row(children: [
|
||||
Icon(g.locked ? (v.open ? Icons.lock_open : Icons.lock) : Icons.folder_outlined, color: Pal.ofKind(g.kind)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(g.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
Text(parts.join(' · '), style: const TextStyle(fontSize: 12, color: Pal.muted)),
|
||||
]),
|
||||
),
|
||||
GroupMenuButton(v),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:friendly_map/modules/app/app.dart';
|
||||
|
||||
import '../app_scope.dart';
|
||||
import '../theme.dart';
|
||||
import 'common.dart';
|
||||
|
||||
/// 비밀번호 칸들을 담은 대화상자. [labels] 마다 한 칸, 확인을 누르면 적은 값들을 돌려준다.
|
||||
class _PinFields extends StatefulWidget {
|
||||
const _PinFields({required this.title, required this.labels, this.note});
|
||||
|
||||
final String title;
|
||||
final List<String> labels;
|
||||
final String? note;
|
||||
|
||||
@override
|
||||
State<_PinFields> createState() => _PinFieldsState();
|
||||
}
|
||||
|
||||
class _PinFieldsState extends State<_PinFields> {
|
||||
late final List<TextEditingController> _ctl = [for (final _ in widget.labels) TextEditingController()];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _ctl) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _done() => Navigator.pop(context, [for (final c in _ctl) c.text]);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (widget.note != null) Text(widget.note!, style: const TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)),
|
||||
for (var i = 0; i < _ctl.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: TextField(
|
||||
controller: _ctl[i],
|
||||
autofocus: i == 0,
|
||||
obscureText: true,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(labelText: widget.labels[i], isDense: true),
|
||||
onSubmitted: (_) => i == _ctl.length - 1 ? _done() : null,
|
||||
),
|
||||
),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('취소')),
|
||||
TextButton(onPressed: _done, child: const Text('확인')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>?> _ask(BuildContext context, String title, List<String> labels, {String? note}) =>
|
||||
showDialog<List<String>>(context: context, builder: (_) => _PinFields(title: title, labels: labels, note: note));
|
||||
|
||||
/// 잠긴 그룹을 연다. 이미 열려 있으면 바로 true. 취소하거나 틀리면 false.
|
||||
Future<bool> openGroup(BuildContext context, GroupView v) async {
|
||||
if (v.open) return true;
|
||||
final app = AppScope.of(context).app;
|
||||
final r = await _ask(context, '${v.group.name} 열기', const ['비밀번호']);
|
||||
if (r == null || !context.mounted) return false;
|
||||
final res = await app.groups.unlock(v.group.id, r.first);
|
||||
if (!context.mounted) return res.ok;
|
||||
switch (res.status) {
|
||||
case UnlockStatus.opened || UnlockStatus.notLocked:
|
||||
return true;
|
||||
case UnlockStatus.wrong:
|
||||
showNote(context, '비밀번호가 맞지 않아요 (남은 횟수 ${res.triesLeft})');
|
||||
case UnlockStatus.waiting:
|
||||
showNote(context, '여러 번 틀려서 ${res.waitSeconds}초 뒤에 다시 해 주세요');
|
||||
case UnlockStatus.missing:
|
||||
showNote(context, '그룹을 찾을 수 없어요');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 비밀번호를 걸거나 바꾼다. 성공하면 true.
|
||||
Future<bool> setGroupPin(BuildContext context, GroupView v) async {
|
||||
final app = AppScope.of(context).app;
|
||||
final change = v.group.locked;
|
||||
final r = await _ask(
|
||||
context,
|
||||
change ? '비밀번호 바꾸기' : '비밀번호 걸기',
|
||||
[if (change) '지금 비밀번호', '새 비밀번호', '새 비밀번호 한 번 더'],
|
||||
note: '4~16자. 잊으면 이 그룹을 열 수 없어요. (그룹을 지우고 다시 만들어야 해요)',
|
||||
);
|
||||
if (r == null || !context.mounted) return false;
|
||||
final cur = change ? r[0] : null;
|
||||
final pin = r[r.length - 2], again = r[r.length - 1];
|
||||
if (pin != again) {
|
||||
showNote(context, '새 비밀번호가 서로 달라요');
|
||||
return false;
|
||||
}
|
||||
final err = await app.groups.setPin(v.group.id, pin, current: cur);
|
||||
if (!context.mounted) return err == null;
|
||||
showNote(context, err ?? (change ? '비밀번호를 바꿨어요' : '비밀번호를 걸었어요. 이제 열 때 물어봐요'));
|
||||
return err == null;
|
||||
}
|
||||
|
||||
/// 비밀번호를 푼다. 성공하면 true.
|
||||
Future<bool> clearGroupPin(BuildContext context, GroupView v) async {
|
||||
final app = AppScope.of(context).app;
|
||||
final r = await _ask(context, '비밀번호 풀기', const ['지금 비밀번호'], note: '풀면 누구나 이 그룹을 볼 수 있어요.');
|
||||
if (r == null || !context.mounted) return false;
|
||||
final err = await app.groups.clearPin(v.group.id, r.first);
|
||||
if (!context.mounted) return err == null;
|
||||
showNote(context, err ?? '비밀번호를 풀었어요');
|
||||
return err == null;
|
||||
}
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
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/kinship/kinship.dart';
|
||||
|
||||
import '../app_scope.dart';
|
||||
import '../theme.dart';
|
||||
import 'async_view.dart';
|
||||
import 'common.dart';
|
||||
import 'group_dialogs.dart';
|
||||
import 'pin_dialog.dart';
|
||||
|
||||
/// "채팅하다 보니 가족이네 / 동문이네 / 같은 팀이네": 이 사람의 관계를 정해 그룹에 넣는다.
|
||||
Future<void> showRelateSheet(BuildContext context, String personId, String name) async {
|
||||
final kind = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (c) => SafeArea(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Align(alignment: Alignment.centerLeft, child: Text('$name님은 나와…', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700))),
|
||||
),
|
||||
for (final (k, icon, text) in const [
|
||||
(CardKinds.family, Icons.family_restroom, '가족 · 친척'),
|
||||
(CardKinds.school, Icons.school_outlined, '학교 동문 (초·중·고·대 …)'),
|
||||
(CardKinds.company, Icons.business_center_outlined, '회사 · 업무 · 팀'),
|
||||
(CardKinds.group, Icons.groups_outlined, '그룹 · 모임 · 여행'),
|
||||
])
|
||||
ListTile(minTileHeight: 56, leading: Icon(icon, color: Pal.ofKind(k)), title: Text(text), onTap: () => Navigator.pop(c, k)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
if (kind == null || !context.mounted) return;
|
||||
if (kind == CardKinds.family) {
|
||||
await showModalBottomSheet<void>(context: context, showDragHandle: true, isScrollControlled: true, builder: (_) => _FamilySheet(personId, name));
|
||||
} else {
|
||||
await showModalBottomSheet<void>(context: context, showDragHandle: true, isScrollControlled: true, builder: (_) => _GroupPickSheet(personId, name, kind));
|
||||
}
|
||||
}
|
||||
|
||||
/// 가족: 누구의 무엇인지 고르면 가계도에 잇고 촌수를 맞춰 친가·외가·처가로 넣는다.
|
||||
class _FamilySheet extends StatefulWidget {
|
||||
const _FamilySheet(this.personId, this.name);
|
||||
final String personId;
|
||||
final String name;
|
||||
|
||||
@override
|
||||
State<_FamilySheet> createState() => _FamilySheetState();
|
||||
}
|
||||
|
||||
class _FamilySheetState extends State<_FamilySheet> {
|
||||
Gender _gender = Gender.unknown;
|
||||
String _to = KinGraph.meId;
|
||||
String _toName = '나';
|
||||
List<KinEntry>? _family;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_family == null) {
|
||||
AppScope.of(context).app.kin.everyone().then((f) {
|
||||
if (mounted) setState(() => _family = [for (final e in f) if (e.person.id != widget.personId) e]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _base(String id, String name) => setState(() {
|
||||
_to = id;
|
||||
_toName = name;
|
||||
});
|
||||
|
||||
Future<void> _place(KinLink? link) async {
|
||||
final app = AppScope.of(context).app;
|
||||
final r = await app.relate.asFamily(widget.personId, link: link, to: _to, gender: _gender);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
if (r == null) {
|
||||
showNote(context, '가계도에 이을 수 없어요 (부모가 이미 둘이거나 돌고 도는 관계예요)');
|
||||
} else {
|
||||
final rel = r.relation;
|
||||
showNote(context, '${r.groupPath}에 넣었어요${rel != null && rel.related ? ' · ${rel.label}' : ''}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final family = _family ?? const <KinEntry>[];
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('${widget.name}님은 $_toName의…', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 10),
|
||||
const Text('누구를 기준으로', style: TextStyle(fontSize: 12, color: Pal.muted)),
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: ListView(scrollDirection: Axis.horizontal, children: [
|
||||
_choice('나', _to == KinGraph.meId, () => _base(KinGraph.meId, '나')),
|
||||
for (final e in family)
|
||||
_choice('${e.person.name} (${e.relation.title.isEmpty ? '가족' : e.relation.title})', _to == e.person.id,
|
||||
() => _base(e.person.id, e.person.name)),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text('성별 (호칭에 써요)', style: TextStyle(fontSize: 12, color: Pal.muted)),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(spacing: 6, children: [
|
||||
for (final (g, t) in const [(Gender.male, '남'), (Gender.female, '여'), (Gender.unknown, '모름')])
|
||||
_choice(t, _gender == g, () => setState(() => _gender = g)),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: [
|
||||
for (final (l, t) in const [(KinLink.parent, '부모'), (KinLink.child, '자녀'), (KinLink.spouse, '배우자'), (KinLink.sibling, '형제자매')])
|
||||
FilledButton.tonal(onPressed: () => _place(l), child: Text(t)),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(onPressed: () => _place(null), child: const Text('촌수 모름 — 친척 모음에 두기')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _choice(String text, bool on, VoidCallback tap) => Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: ChoiceChip(label: Text(text), selected: on, onSelected: (_) => tap()),
|
||||
);
|
||||
}
|
||||
|
||||
/// 학교·회사·모임: 그 종류의 그룹 나무에서 골라 넣는다. 그 자리에서 하위 그룹을 만들 수도 있다.
|
||||
class _GroupPickSheet extends StatelessWidget {
|
||||
const _GroupPickSheet(this.personId, this.name, this.kind);
|
||||
final String personId;
|
||||
final String name;
|
||||
final String kind;
|
||||
|
||||
Future<void> _put(BuildContext context, GroupView v) async {
|
||||
final app = AppScope.of(context).app;
|
||||
if (!await openGroup(context, v) || !context.mounted) return;
|
||||
final r = await app.relate.into(personId, v.group.id);
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
showNote(context, r == null ? '넣지 못했어요' : '${r.groupPath}에 넣었어요');
|
||||
}
|
||||
|
||||
Future<void> _newUnder(BuildContext context, GroupView v) async {
|
||||
final app = AppScope.of(context).app;
|
||||
if (!await openGroup(context, v) || !context.mounted) return;
|
||||
final hint = switch (kind) {
|
||||
CardKinds.school => '예: 가람고 27회',
|
||||
CardKinds.company => '예: 한빛전자, 결제 프로젝트',
|
||||
_ => '예: 등산 모임, 제주 여행',
|
||||
};
|
||||
final r = await askGroupInfo(context, title: '${v.group.name} 아래 새 그룹 ($hint)', kind: v.group.kind);
|
||||
if (r == null || !context.mounted) return;
|
||||
final g = await app.groups.add(r.$1, r.$2, parent: v.group.id);
|
||||
if (!context.mounted) return;
|
||||
if (g == null) {
|
||||
showNote(context, '이름이 비었거나 같은 이름이 있어요 (4단까지 만들 수 있어요)');
|
||||
return;
|
||||
}
|
||||
final placed = await app.relate.into(personId, g.id);
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
showNote(context, placed == null ? '넣지 못했어요' : '${placed.groupPath}에 넣었어요');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.6,
|
||||
child: Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
|
||||
child: Align(alignment: Alignment.centerLeft, child: Text('$name님을 어디에 넣을까요?', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700))),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Align(alignment: Alignment.centerLeft, child: Text('눌러서 넣고, + 로 그 아래 새 그룹을 만들어 바로 넣어요.', style: TextStyle(fontSize: 12, color: Pal.muted))),
|
||||
),
|
||||
Expanded(
|
||||
child: AsyncView<List<GroupView>>(
|
||||
load: (a) async => [for (final v in await a.groups.all()) if (v.group.kind == kind) v],
|
||||
builder: (context, list) => list.isEmpty
|
||||
? const EmptyNote('이 종류의 그룹이 없어요. 설정 > 그룹 관리에서 만들어 주세요.')
|
||||
: ListView(children: [
|
||||
for (final v in list)
|
||||
ListTile(
|
||||
minTileHeight: 52,
|
||||
contentPadding: EdgeInsets.only(left: 12.0 + (v.depth - 1) * 20, right: 4),
|
||||
leading: Icon(v.group.locked ? (v.open ? Icons.lock_open : Icons.lock) : Icons.folder_outlined, color: Pal.ofKind(v.group.kind)),
|
||||
title: Text(v.group.name),
|
||||
subtitle: v.open ? Text('${v.memberCount}명', style: const TextStyle(fontSize: 11, color: Pal.muted)) : null,
|
||||
onTap: () => _put(context, v),
|
||||
trailing: v.canAddChild
|
||||
? IconButton(tooltip: '아래에 새 그룹', icon: const Icon(Icons.add), onPressed: () => _newUnder(context, v))
|
||||
: null,
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 사람 화면: 이 사람이 들어 있는 그룹들과 "관계 정하기" 버튼.
|
||||
class PlacesPanel extends StatelessWidget {
|
||||
const PlacesPanel({super.key, required this.personId, required this.name});
|
||||
final String personId;
|
||||
final String name;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AsyncView<List<String>>(
|
||||
load: (a) => a.relate.placesOf(personId),
|
||||
builder: (context, places) => Panel(
|
||||
onTap: () => showRelateSheet(context, personId, name),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.hub_outlined, color: Pal.accent),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: places.isEmpty
|
||||
? const Text('관계 정하기 — 가족·동문·회사·모임으로 묶어요')
|
||||
: Wrap(spacing: 6, runSpacing: 6, children: [
|
||||
for (final p in places)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: Pal.line, borderRadius: BorderRadius.circular(10)),
|
||||
child: Text(p, style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: Pal.muted),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
38
pubspec.lock
38
pubspec.lock
|
|
@ -53,10 +53,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -150,6 +150,14 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_contacts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_contacts
|
||||
sha256: "388d32cd33f16640ee169570128c933b45f3259bddbfae7a100bb49e5ffea9ae"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.9+2"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
|
@ -428,10 +436,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.20"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -444,10 +452,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.3"
|
||||
version: "1.18.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -745,10 +753,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.2"
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -785,10 +793,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
version: "0.7.11"
|
||||
timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -881,10 +889,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -897,10 +905,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -934,5 +942,5 @@ packages:
|
|||
source: hosted
|
||||
version: "3.1.4"
|
||||
sdks:
|
||||
dart: ">=3.13.0 <4.0.0"
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ dependencies:
|
|||
sensors_plus: any # 발견: 흔들기 감지
|
||||
geolocator: any # 위치: 안드로이드 현재 위치
|
||||
call_log: any # 통화: 안드로이드 통화 기록
|
||||
flutter_contacts: ^1.1.9 # 주소록: 안드로이드 연락처 읽기
|
||||
url_launcher: any # 통화: 전화·문자 화면 열기
|
||||
flutter_local_notifications: ^17.2.4 # 알림: 예약 알림
|
||||
timezone: ^0.9.4 # 알림: 예약 시각 계산
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:friendly_map/modules/app/app.dart';
|
||||
import 'package:friendly_map/modules/core/core.dart';
|
||||
import 'package:friendly_map/modules/storage/storage.dart';
|
||||
import 'package:friendly_map/modules/transport/transport.dart';
|
||||
|
||||
import 'support/app_device.dart';
|
||||
|
||||
Future<void> befriend(AppDevice a, AppDevice b) async {
|
||||
await a.app.friends.add(b.phone, b.name);
|
||||
await AppDevice.settleAll();
|
||||
await b.app.friends.accept(a.phone);
|
||||
await AppDevice.settleAll();
|
||||
}
|
||||
|
||||
void main() {
|
||||
late AppDevice a, b;
|
||||
late Person aOfB;
|
||||
late String cardId;
|
||||
|
||||
/// a 가 회사 명함을 [company] 로 고쳐 다시 보낸다.
|
||||
Future<void> moveTo(String company, {bool former = false}) async {
|
||||
await a.app.cards.edit(cardId, fields: {'name': '김민수', 'company': company, if (former) 'former': 'true'});
|
||||
for (final s in await a.app.cards.needingResend()) {
|
||||
await a.app.cards.resend(s.id);
|
||||
}
|
||||
await AppDevice.settleAll();
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
final hub = MemoryHub();
|
||||
a = await AppDevice.create(hub, '010-1111-2222', '+821011112222', '김민수');
|
||||
b = await AppDevice.create(hub, '010-2222-3333', '+821022223333', '나');
|
||||
await befriend(a, b);
|
||||
final bOfA = (await a.app.friends.people()).single;
|
||||
cardId = (await a.app.cards.mine()).firstWhere((c) => c.kind == CardKinds.company).id;
|
||||
await a.app.cards.edit(cardId, fields: {'name': '김민수', 'company': '한빛전자'});
|
||||
await a.app.cards.shareWith(bOfA.id, [cardId]);
|
||||
await AppDevice.settleAll();
|
||||
aOfB = (await b.app.friends.people()).single;
|
||||
});
|
||||
|
||||
test('처음 받은 회사 명함은 알림이 없고, 회사가 바뀌면 이직 알림이 생긴다', () async {
|
||||
expect(await b.app.career.notices(), isEmpty);
|
||||
await moveTo('한빛전자');
|
||||
expect(await b.app.career.notices(), isEmpty, reason: '회사가 그대로면 알림 없음');
|
||||
await moveTo('누리소프트');
|
||||
final n = (await b.app.career.notices()).single;
|
||||
expect((n.from, n.to), ('한빛전자', '누리소프트'));
|
||||
expect(await b.app.career.describe(n), '김민수님이 한빛전자에서 누리소프트로 옮겼어요');
|
||||
expect(await b.app.career.notices(personId: aOfB.id), hasLength(1));
|
||||
});
|
||||
|
||||
test('확인하면 이전 회사에서는 흐리게 남고 새 회사 아래로 옮겨지며, 연결 고리가 바뀐다', () async {
|
||||
final hb = (await b.app.groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!;
|
||||
final team = (await b.app.groups.add('결제팀', CardKinds.company, parent: hb.id))!;
|
||||
await b.app.relate.into(aOfB.id, team.id);
|
||||
await moveTo('누리소프트');
|
||||
final n = (await b.app.career.notices()).single;
|
||||
expect(await b.app.career.accept(n.id), '회사 › 누리소프트');
|
||||
expect(await b.app.groups.members(team.id), isEmpty);
|
||||
expect((await b.app.groups.formers(team.id))!.single.name, '김민수');
|
||||
final links = {for (final l in await b.app.network.links(aOfB.id)) l.relation: l.detail};
|
||||
expect(links, {'직장 동료': '회사 › 누리소프트', '전 직장 동료': '회사 › 한빛전자 › 결제팀'});
|
||||
expect(await b.app.career.notices(), isEmpty);
|
||||
expect((await b.app.groups.view(team.id))!.memberCount, 0, reason: '이전 구성원은 수에 들지 않는다');
|
||||
});
|
||||
|
||||
test('이전 회사에서 지우기를 고르면 흐리게 남기지 않는다, 이미 있는 새 회사 그룹을 쓴다', () async {
|
||||
final hb = (await b.app.groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!;
|
||||
final nuri = (await b.app.groups.add('누리소프트', CardKinds.company, parent: CardKinds.company))!;
|
||||
await b.app.relate.into(aOfB.id, hb.id);
|
||||
await moveTo('누리소프트');
|
||||
final n = (await b.app.career.notices()).single;
|
||||
await b.app.career.accept(n.id, keepOld: false);
|
||||
expect(await b.app.groups.formers(hb.id), isEmpty);
|
||||
expect((await b.app.groups.members(nuri.id))!.single.id, aOfB.id);
|
||||
expect((await b.app.network.links(aOfB.id)).map((l) => l.relation), ['직장 동료']);
|
||||
});
|
||||
|
||||
test('퇴사(지금은 다니지 않음)는 떠남 알림, 새 회사로 넣지 않는다. 무시하면 알림만 지운다', () async {
|
||||
final hb = (await b.app.groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!;
|
||||
await b.app.relate.into(aOfB.id, hb.id);
|
||||
await moveTo('한빛전자', former: true);
|
||||
final n = (await b.app.career.notices()).single;
|
||||
expect(n.left, isTrue);
|
||||
expect(await b.app.career.describe(n), '김민수님이 한빛전자를 떠났어요');
|
||||
expect(await b.app.career.accept(n.id), isNull);
|
||||
expect((await b.app.groups.formers(hb.id))!.single.id, aOfB.id);
|
||||
await moveTo('새회사');
|
||||
final n2 = (await b.app.career.notices()).single;
|
||||
await b.app.career.dismiss(n2.id);
|
||||
expect(await b.app.career.notices(), isEmpty);
|
||||
expect((await b.app.groups.formers(hb.id))!.length, 1, reason: '무시하면 그룹은 그대로');
|
||||
});
|
||||
|
||||
test('앱을 새로 열면 이미 받은 회사 명함으로 알던 회사를 채운다 (알림 없음)', () async {
|
||||
final fresh = CareerService(b.app.deps.storage, b.app.deps.clock, b.app.groups, b.app.relate, b.app.network, b.app.bus);
|
||||
await fresh.seed();
|
||||
expect(await fresh.notices(), isEmpty);
|
||||
expect(currentCompany({'company': ' 한빛 '}), '한빛');
|
||||
expect(currentCompany({'company': '한빛', 'former': 'true'}), '');
|
||||
expect(withJosa('한빛', '으로', '로'), '한빛으로');
|
||||
expect(withJosa('서울', '으로', '로'), '서울로');
|
||||
expect(withJosa('누리', '으로', '로'), '누리로');
|
||||
expect(withJosa('한빛', '을', '를'), '한빛을');
|
||||
expect(withJosa('ABC', '으로', '로'), 'ABC(으)로');
|
||||
expect(withJosa('ABC', '을', '를'), 'ABC을(를)');
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
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/storage/storage.dart';
|
||||
import 'package:friendly_map/modules/transport/transport.dart';
|
||||
|
||||
import 'support/app_device.dart';
|
||||
|
||||
class FakeContacts implements ContactsProvider {
|
||||
FakeContacts(this.list, {this.allow = true});
|
||||
final List<DeviceContact> list;
|
||||
final bool allow;
|
||||
@override
|
||||
Future<bool> requestPermission() async => allow;
|
||||
@override
|
||||
Future<List<DeviceContact>> all() async => allow ? list : const [];
|
||||
}
|
||||
|
||||
const book = [
|
||||
DeviceContact(id: '1', name: '김민수', phones: ['02-555-1234', '010-3333-4444'], labels: ['가족']),
|
||||
DeviceContact(id: '2', name: '이영희', phones: ['010 5555 6666'], labels: ['고등학교 동창']),
|
||||
DeviceContact(id: '3', name: '박철수', phones: ['+82 10-7777-8888'], labels: ['고등학교 동창', '회사']),
|
||||
DeviceContact(id: '4', name: '번호없음', phones: ['1234']),
|
||||
DeviceContact(id: '5', name: '나 자신', phones: ['010-1111-2222']),
|
||||
DeviceContact(id: '6', name: 'Alice Kim', phones: ['010-9999-0000']),
|
||||
];
|
||||
|
||||
void main() {
|
||||
late MemoryHub hub;
|
||||
late AppDevice me;
|
||||
|
||||
setUp(() async {
|
||||
hub = MemoryHub();
|
||||
me = await AppDevice.create(hub, '010-1111-2222', '+821011112222', '나', contacts: FakeContacts(book));
|
||||
});
|
||||
|
||||
test('번호를 고를 때 휴대폰을 먼저, 읽을 수 없는 번호와 내 번호는 가져올 수 없다', () async {
|
||||
final all = await me.app.contacts.load();
|
||||
ContactCandidate by(String name) => all.firstWhere((c) => c.name == name);
|
||||
expect(by('김민수').phone, '+821033334444');
|
||||
expect(by('번호없음').importable, isFalse);
|
||||
expect(by('나 자신').isMe, isTrue);
|
||||
expect(by('나 자신').importable, isFalse);
|
||||
expect(by('Alice Kim').importable, isTrue);
|
||||
});
|
||||
|
||||
test('거르기: 이름·초성·번호·주소록 그룹·이미 있는 사람 빼기', () async {
|
||||
await me.app.friends.add('+821055556666', '이영희');
|
||||
final all = await me.app.contacts.load();
|
||||
List<String> names(ContactFilter f) => ContactImportService.filter(all, f).map((c) => c.name).toList();
|
||||
expect(names(const ContactFilter(query: '민수')), ['김민수']);
|
||||
expect(names(const ContactFilter(query: 'ㄱㅁㅅ')), ['김민수']);
|
||||
expect(names(const ContactFilter(query: 'alice')), ['Alice Kim']);
|
||||
expect(names(const ContactFilter(query: '7777')), ['박철수']);
|
||||
expect(names(const ContactFilter(query: '77')), isEmpty, reason: '번호는 세 자리 이상부터');
|
||||
expect(names(const ContactFilter(label: '고등학교 동창')), ['박철수', '이영희']);
|
||||
expect(names(const ContactFilter(label: '고등학교 동창', hideKnown: true)), ['박철수']);
|
||||
expect(ContactImportService.labels(all), ['가족', '고등학교 동창', '회사']);
|
||||
});
|
||||
|
||||
test('한 명 가져오기: 인맥에 들어가고 고른 그룹에 들어간다, 친구 요청은 가지 않는다', () async {
|
||||
final all = await me.app.contacts.load();
|
||||
final r = await me.app.contacts.import([all.firstWhere((c) => c.name == '김민수')], groupIds: {CardKinds.family});
|
||||
expect(r.added, 1);
|
||||
expect(r.grouped, 1);
|
||||
expect(r.requested, 0);
|
||||
final p = (await me.app.deps.storage.people.byPhone('+821033334444'))!;
|
||||
expect(p.name, '김민수');
|
||||
expect((await me.app.groups.members(CardKinds.family))!.map((x) => x.id), [p.id]);
|
||||
expect(await me.app.deps.storage.outbox.pending(), isEmpty);
|
||||
});
|
||||
|
||||
test('여러 명을 한꺼번에: 이미 있는 사람은 다시 만들지 않고, 못 가져오는 사람은 건너뛴다', () async {
|
||||
await me.app.friends.add('+821055556666', '이영희');
|
||||
final before = (await me.app.deps.storage.people.all()).length;
|
||||
final all = await me.app.contacts.load();
|
||||
final r = await me.app.contacts.import([...all, ...all], groupIds: {CardKinds.school, CardKinds.company});
|
||||
expect(r.added, 3, reason: '김민수, 박철수, Alice Kim');
|
||||
expect(r.existing, 1, reason: '이영희');
|
||||
expect(r.skipped, 4, reason: '번호없음·나 자신 두 번씩');
|
||||
expect(r.grouped, 8);
|
||||
expect((await me.app.deps.storage.people.all()).length, before + 3);
|
||||
expect((await me.app.groups.members(CardKinds.school))!.length, 4);
|
||||
});
|
||||
|
||||
test('친구 요청도 보내기를 켜면 가져오면서 인사를 보낸다', () async {
|
||||
final friend = await AppDevice.create(hub, '010-3333-4444', '+821033334444', '김민수');
|
||||
final all = await me.app.contacts.load();
|
||||
final r = await me.app.contacts.import([all.firstWhere((c) => c.name == '김민수')], sendRequest: true);
|
||||
expect(r.requested, 1);
|
||||
await AppDevice.settleAll();
|
||||
final reqs = await friend.app.friends.requests();
|
||||
expect(reqs.map((q) => q.id), ['+821011112222']);
|
||||
});
|
||||
|
||||
test('잠긴 그룹이 닫혀 있으면 가져와도 그 그룹에는 들어가지 않는다', () async {
|
||||
await me.app.groups.setPin(CardKinds.family, '1234');
|
||||
me.app.groups.lockAll();
|
||||
final all = await me.app.contacts.load();
|
||||
final r = await me.app.contacts.import([all.firstWhere((c) => c.name == '김민수')], groupIds: {CardKinds.family});
|
||||
expect(r.added, 1);
|
||||
expect(r.grouped, 0);
|
||||
});
|
||||
|
||||
test('주소록 권한이 없거나 주소록이 없는 기기면 빈 목록', () async {
|
||||
final no = await AppDevice.create(hub, '010-2222-3333', '+821022223333', '권한없음', contacts: FakeContacts(book, allow: false));
|
||||
expect(await no.app.contacts.requestPermission(), isFalse);
|
||||
expect(await no.app.contacts.load(), isEmpty);
|
||||
final none = await AppDevice.create(hub, '010-4444-5555', '+821044445555', '주소록없음');
|
||||
expect(none.app.contacts.available, isFalse);
|
||||
expect(await none.app.contacts.load(), isEmpty);
|
||||
});
|
||||
|
||||
test('번호 표준 형태 확인', () {
|
||||
expect(bestPhone(['02-555-1234']), '+8225551234');
|
||||
expect(bestPhone(['없음']), isNull);
|
||||
expect(normalizePhone('010 5555 6666'), '+821055556666');
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
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/transport/transport.dart';
|
||||
|
||||
import 'support/app_device.dart';
|
||||
|
||||
void main() {
|
||||
late AppDevice me;
|
||||
late GroupService groups;
|
||||
|
||||
setUp(() async {
|
||||
me = await AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '나');
|
||||
groups = me.app.groups;
|
||||
});
|
||||
|
||||
/// 시계를 [minutes] 분 앞으로.
|
||||
void later(int minutes) => me.app.deps.clock.offset += minutes * 6000;
|
||||
|
||||
test('그룹 정의: 처음 네 개, 더하고 이름·종류 바꾸고 지우기', () async {
|
||||
expect([for (final v in await groups.all()) if (v.depth == 1) v.group.name], ['가족', '학교', '회사', '그룹·여행']);
|
||||
final g = (await groups.add('등산 모임', CardKinds.group))!;
|
||||
expect(await groups.add('등산 모임', CardKinds.group), isNull);
|
||||
expect(await groups.rename(g.id, '산악회'), isTrue);
|
||||
expect(await groups.setKind(g.id, CardKinds.personal), isTrue);
|
||||
final v = (await groups.view(g.id))!;
|
||||
expect(v.group.name, '산악회');
|
||||
expect(v.group.kind, CardKinds.personal);
|
||||
expect(await groups.remove(g.id), isTrue);
|
||||
expect(await groups.view(g.id), isNull);
|
||||
});
|
||||
|
||||
test('잠그면 닫힌 동안 구성원을 볼 수 없고, 비밀번호로 열면 보인다', () async {
|
||||
final a = (await me.app.friends.add('+821033334444', '김민수'))!;
|
||||
expect(await groups.addMembers(CardKinds.family, [a.id]), 1);
|
||||
expect(await groups.setPin(CardKinds.family, '123'), isNotNull, reason: '너무 짧음');
|
||||
expect(await groups.setPin(CardKinds.family, '2580'), isNull);
|
||||
expect((await groups.view(CardKinds.family))!.open, isTrue, reason: '건 직후에는 열려 있다');
|
||||
|
||||
groups.lock(CardKinds.family);
|
||||
final closed = (await groups.view(CardKinds.family))!;
|
||||
expect(closed.open, isFalse);
|
||||
expect(closed.memberCount, isNull);
|
||||
expect(await groups.members(CardKinds.family), isNull);
|
||||
expect(await groups.hiddenPeople(), {a.id});
|
||||
expect(await groups.groupsOf(a.id), isEmpty);
|
||||
expect(await groups.addMembers(CardKinds.family, ['x']), 0, reason: '닫힌 그룹에는 넣을 수 없다');
|
||||
expect(await groups.rename(CardKinds.family, '우리 집'), isFalse);
|
||||
expect(await groups.remove(CardKinds.family), isFalse);
|
||||
|
||||
final wrong = await groups.unlock(CardKinds.family, '0000');
|
||||
expect(wrong.status, UnlockStatus.wrong);
|
||||
expect(wrong.triesLeft, 4);
|
||||
expect((await groups.unlock(CardKinds.family, '2580')).status, UnlockStatus.opened);
|
||||
expect((await groups.members(CardKinds.family))!.map((p) => p.name), ['김민수']);
|
||||
expect(await groups.hiddenPeople(), isEmpty);
|
||||
});
|
||||
|
||||
test('연 뒤 정한 시간이 지나면 다시 잠긴다', () async {
|
||||
await groups.setPin(CardKinds.school, 'abcd');
|
||||
await groups.setRelockMinutes(3);
|
||||
later(2);
|
||||
expect((await groups.view(CardKinds.school))!.open, isTrue);
|
||||
later(2);
|
||||
expect((await groups.view(CardKinds.school))!.open, isFalse);
|
||||
expect(await groups.relockMinutes(), 3);
|
||||
});
|
||||
|
||||
test('5번 틀리면 기다려야 하고, 기다리는 동안은 맞는 비밀번호도 안 된다', () async {
|
||||
await groups.setPin(CardKinds.company, '9999');
|
||||
groups.lockAll();
|
||||
for (var i = 0; i < 4; i++) {
|
||||
expect((await groups.unlock(CardKinds.company, '1111')).status, UnlockStatus.wrong);
|
||||
}
|
||||
final r = await groups.unlock(CardKinds.company, '1111');
|
||||
expect(r.status, UnlockStatus.waiting);
|
||||
expect(r.waitSeconds, 30);
|
||||
expect((await groups.unlock(CardKinds.company, '9999')).status, UnlockStatus.waiting);
|
||||
later(1);
|
||||
expect((await groups.unlock(CardKinds.company, '9999')).status, UnlockStatus.opened);
|
||||
});
|
||||
|
||||
test('비밀번호 바꾸기·풀기는 지금 비밀번호가 맞아야 한다', () async {
|
||||
await groups.setPin(CardKinds.group, '1234');
|
||||
expect(await groups.setPin(CardKinds.group, '5678', current: '0000'), isNotNull);
|
||||
expect(await groups.setPin(CardKinds.group, '5678', current: '1234'), isNull);
|
||||
expect(await groups.clearPin(CardKinds.group, '1234'), isNotNull);
|
||||
expect(await groups.clearPin(CardKinds.group, '5678'), isNull);
|
||||
groups.lockAll();
|
||||
expect((await groups.view(CardKinds.group))!.open, isTrue, reason: '푼 그룹은 늘 열려 있다');
|
||||
expect((await groups.view(CardKinds.group))!.group.locked, isFalse);
|
||||
});
|
||||
|
||||
test('잠금은 저장되어 앱을 다시 열어도 유지된다 (연 상태는 남지 않음)', () async {
|
||||
await groups.setPin(CardKinds.family, '2468');
|
||||
final again = GroupService(me.app.deps.storage, me.app.deps.clock, me.app.bus);
|
||||
final v = (await again.view(CardKinds.family))!;
|
||||
expect(v.group.locked, isTrue);
|
||||
expect(v.open, isFalse);
|
||||
});
|
||||
|
||||
test('하위 그룹: 열린 부모 아래에만 만들고, 부모가 잠기면 하위도 닫힌다', () async {
|
||||
final hb = (await groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!;
|
||||
final team = (await groups.add('결제팀', CardKinds.company, parent: hb.id))!;
|
||||
final a = (await me.app.friends.add('+821033334444', '김민수'))!;
|
||||
await groups.addMembers(team.id, [a.id]);
|
||||
final v = (await groups.view(CardKinds.company))!;
|
||||
expect(v.totalCount, 1);
|
||||
expect((await groups.view(team.id))!.path, '회사 › 한빛전자 › 결제팀');
|
||||
expect((await groups.children(CardKinds.company)).map((c) => c.group.name), ['한빛전자']);
|
||||
|
||||
await groups.setPin(CardKinds.company, '7777');
|
||||
groups.lockAll();
|
||||
expect((await groups.view(team.id))!.open, isFalse, reason: '부모가 잠기면 하위도 닫힘');
|
||||
expect(await groups.members(team.id), isNull);
|
||||
expect(await groups.children(CardKinds.company), isEmpty);
|
||||
expect(await groups.hiddenPeople(), {a.id});
|
||||
expect(await groups.add('새 팀', CardKinds.company, parent: hb.id), isNull);
|
||||
expect((await groups.all()).any((x) => x.group.id == team.id), isFalse, reason: '닫힌 부모의 하위는 목록에서 뺀다');
|
||||
|
||||
await groups.unlock(CardKinds.company, '7777');
|
||||
expect((await groups.members(team.id))!.single.name, '김민수');
|
||||
expect(await groups.remove(CardKinds.company), isTrue);
|
||||
expect(await groups.view(team.id), isNull, reason: '하위 그룹도 함께 지워진다');
|
||||
expect(await me.app.friends.person(a.id), isNotNull, reason: '사람은 남는다');
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:friendly_map/modules/core/core.dart';
|
||||
import 'package:friendly_map/modules/groups/groups.dart';
|
||||
|
||||
void main() {
|
||||
group('그룹 목록', () {
|
||||
test('처음에는 맨 위에 가족·학교·회사·그룹·여행 네 개', () {
|
||||
final b = GroupBook();
|
||||
expect(b.roots.map((g) => g.name), ['가족', '학교', '회사', '그룹·여행']);
|
||||
expect(b.roots.map((g) => g.kind), [CardKinds.family, CardKinds.school, CardKinds.company, CardKinds.group]);
|
||||
});
|
||||
|
||||
test('처음 하위 그룹: 학교는 초등~대학원, 가족은 친가·외가·처가·시가·촌수 모름', () {
|
||||
final b = GroupBook();
|
||||
expect(b.children(CardKinds.school).map((g) => g.name), ['초등학교', '중학교', '고등학교', '대학교', '대학원']);
|
||||
expect(b.children(CardKinds.family).map((g) => g.name), ['친가', '외가', '처가·시가', '친척(촌수 모름)']);
|
||||
expect(b.byId(Group.unknownRelatives)!.parent, CardKinds.family);
|
||||
expect(b.children(CardKinds.company), isEmpty);
|
||||
expect(b.groups.indexOf(b.byId('school.high')!), greaterThan(b.groups.indexOf(b.byId(CardKinds.school)!)));
|
||||
});
|
||||
|
||||
test('더하기·이름 바꾸기·지우기, 빈 이름과 겹치는 이름은 거절', () {
|
||||
final b = GroupBook();
|
||||
final g = b.add(' 동호회 ', CardKinds.group)!;
|
||||
expect(g.name, '동호회');
|
||||
expect(b.add('', CardKinds.group), isNull);
|
||||
expect(b.add('가족', CardKinds.family), isNull);
|
||||
expect(b.rename(g.id, '등산 모임'), isTrue);
|
||||
expect(b.rename(g.id, '학교'), isFalse);
|
||||
expect(b.rename(g.id, '등산 모임'), isTrue, reason: '자기 이름 그대로는 괜찮다');
|
||||
expect(b.byId(g.id)!.name, '등산 모임');
|
||||
expect(b.remove(g.id), {g.id});
|
||||
expect(b.byId(g.id), isNull);
|
||||
expect(b.remove('없음'), isEmpty);
|
||||
});
|
||||
|
||||
test('이름은 20자까지 자른다', () {
|
||||
final b = GroupBook();
|
||||
expect(b.add('가' * 30, CardKinds.group)!.name.length, 20);
|
||||
});
|
||||
|
||||
test('순서 바꾸기', () {
|
||||
final b = GroupBook();
|
||||
b.move(CardKinds.group, 0);
|
||||
expect(b.groups.first.id, CardKinds.group);
|
||||
b.move(CardKinds.group, 99);
|
||||
expect(b.groups.last.id, CardKinds.group);
|
||||
});
|
||||
|
||||
test('구성원 더하기·빼기, 사람이 든 그룹, 사람 지우기', () {
|
||||
final b = GroupBook();
|
||||
expect(b.addMembers(CardKinds.family, ['a', 'b']), 2);
|
||||
expect(b.addMembers(CardKinds.family, ['b', 'c']), 1, reason: '이미 든 사람은 다시 세지 않는다');
|
||||
b.addMembers(CardKinds.school, ['a']);
|
||||
expect(b.groupsOf('a').map((g) => g.id), [CardKinds.family, CardKinds.school]);
|
||||
expect(b.removeMember(CardKinds.family, 'c'), isTrue);
|
||||
expect(b.removeMember(CardKinds.family, 'c'), isFalse);
|
||||
b.forgetPerson('a');
|
||||
expect(b.groupsOf('a'), isEmpty);
|
||||
expect(b.byId(CardKinds.family)!.members, {'b'});
|
||||
});
|
||||
|
||||
test('잠긴 그룹이 닫혀 있으면 구성원을 숨긴다 (다른 그룹에도 있어도)', () async {
|
||||
final b = GroupBook();
|
||||
b.addMembers(CardKinds.family, ['a', 'b']);
|
||||
b.addMembers(CardKinds.school, ['a', 'c']);
|
||||
b.setLock(CardKinds.family, await Pin.make('1234', iterations: 10));
|
||||
expect(b.hiddenPeople((g) => true), {'a', 'b'});
|
||||
expect(b.hiddenPeople((g) => false), isEmpty);
|
||||
});
|
||||
|
||||
test('글자로 저장했다 되살리면 같고, 깨진 글자는 처음 그룹', () async {
|
||||
final b = GroupBook(relockMinutes: 10);
|
||||
final g = b.add('동호회', CardKinds.group)!;
|
||||
b.addMembers(g.id, ['x']);
|
||||
b.setLock(g.id, await Pin.make('abcd', iterations: 10));
|
||||
final c = GroupBook.fromJson(b.toJson());
|
||||
expect(c.groups.map((x) => x.name), b.groups.map((x) => x.name));
|
||||
expect(c.byId(g.id)!.members, {'x'});
|
||||
expect(c.byId(g.id)!.locked, isTrue);
|
||||
expect(c.relockMinutes, 10);
|
||||
expect(c.add('새 그룹', CardKinds.group)!.id, isNot(g.id), reason: '새 번호표는 겹치지 않는다');
|
||||
expect(GroupBook.fromJson('{깨짐').roots.length, 4);
|
||||
expect(GroupBook.fromJson(null).roots.length, 4);
|
||||
});
|
||||
});
|
||||
|
||||
group('비밀번호', () {
|
||||
test('맞는 비밀번호만 통과, 원문은 남지 않는다', () async {
|
||||
final h = await Pin.make('2580', iterations: 100);
|
||||
expect(await Pin.verify('2580', h), isTrue);
|
||||
expect(await Pin.verify('2581', h), isFalse);
|
||||
expect(h.toJson().toString(), isNot(contains('2580')));
|
||||
final again = await Pin.make('2580', iterations: 100);
|
||||
expect(again.salt, isNot(h.salt), reason: '같은 비밀번호도 소금이 달라 다른 값');
|
||||
});
|
||||
|
||||
test('짧거나 길거나 앞뒤 빈칸이면 안내', () {
|
||||
expect(Pin.problem('123'), isNotNull);
|
||||
expect(Pin.problem('1' * 17), isNotNull);
|
||||
expect(Pin.problem(' 1234'), isNotNull);
|
||||
expect(Pin.problem('1234'), isNull);
|
||||
});
|
||||
|
||||
test('깨진 저장값은 통과하지 않는다', () async {
|
||||
expect(await Pin.verify('1234', const PinHash('!!', '??', 10)), isFalse);
|
||||
expect(PinHash.fromJson({'salt': 1}), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('여러 번 틀리면 기다리기', () {
|
||||
test('5번까지는 바로, 그 뒤로 30초·60초…', () {
|
||||
final g = UnlockGuard();
|
||||
for (var i = 0; i < 4; i++) {
|
||||
g.failure(0);
|
||||
expect(g.waitLeft(0), 0);
|
||||
}
|
||||
expect(g.triesLeft(), 1);
|
||||
g.failure(0);
|
||||
expect(g.waitLeft(0), 3000);
|
||||
expect(g.waitLeft(3000), 0);
|
||||
g.failure(3000);
|
||||
expect(g.waitLeft(3000), 6000);
|
||||
g.success();
|
||||
expect(g.waitLeft(3000), 0);
|
||||
expect(g.triesLeft(), 5);
|
||||
});
|
||||
|
||||
test('기다림은 1시간을 넘지 않는다', () {
|
||||
final g = UnlockGuard();
|
||||
for (var i = 0; i < 40; i++) {
|
||||
g.failure(0);
|
||||
}
|
||||
expect(g.waitLeft(0), 360000);
|
||||
});
|
||||
});
|
||||
|
||||
group('하위 그룹', () {
|
||||
test('회사 > 한빛전자 > 결제팀, 같은 이름은 부모가 다르면 괜찮다', () {
|
||||
final b = GroupBook();
|
||||
final hb = b.add('한빛전자', CardKinds.company, parent: CardKinds.company)!;
|
||||
final pay = b.add('결제팀', CardKinds.company, parent: hb.id)!;
|
||||
final other = b.add('누리소프트', CardKinds.company, parent: CardKinds.company)!;
|
||||
expect(b.add('결제팀', CardKinds.company, parent: other.id), isNotNull);
|
||||
expect(b.add('결제팀', CardKinds.company, parent: hb.id), isNull);
|
||||
expect(b.path(pay.id), '회사 › 한빛전자 › 결제팀');
|
||||
expect(b.depth(pay.id), 3);
|
||||
expect(b.ancestors(pay.id).map((g) => g.id), [hb.id, CardKinds.company]);
|
||||
expect(b.add('없는 부모 아래', CardKinds.group, parent: '없음'), isNull);
|
||||
});
|
||||
|
||||
test('4단까지만 만들 수 있다', () {
|
||||
final b = GroupBook();
|
||||
final a = b.add('2단', CardKinds.group, parent: CardKinds.group)!;
|
||||
final c = b.add('3단', CardKinds.group, parent: a.id)!;
|
||||
final d = b.add('4단', CardKinds.group, parent: c.id)!;
|
||||
expect(b.add('5단', CardKinds.group, parent: d.id), isNull);
|
||||
});
|
||||
|
||||
test('하위까지 합친 구성원, 지우면 하위 그룹도 함께', () {
|
||||
final b = GroupBook();
|
||||
b.addMembers(CardKinds.school, ['a']);
|
||||
b.addMembers('school.high', ['b', 'a']);
|
||||
b.addMembers('school.university', ['c']);
|
||||
expect(b.membersDeep(CardKinds.school), {'a', 'b', 'c'});
|
||||
expect(b.remove(CardKinds.school), containsAll([CardKinds.school, 'school.high', 'school.graduate']));
|
||||
expect(b.byId('school.high'), isNull);
|
||||
expect(b.roots.length, 3);
|
||||
});
|
||||
|
||||
test('잠긴 부모가 닫히면 하위 그룹 사람도 숨긴다', () async {
|
||||
final b = GroupBook();
|
||||
b.addMembers('family.maternal', ['m']);
|
||||
b.setLock(CardKinds.family, await Pin.make('1234', iterations: 10));
|
||||
expect(b.hiddenPeople((g) => true), {'m'});
|
||||
});
|
||||
|
||||
test('같은 부모 아래에서만 순서가 바뀐다', () {
|
||||
final b = GroupBook();
|
||||
b.move('school.graduate', 0);
|
||||
expect(b.children(CardKinds.school).first.id, 'school.graduate');
|
||||
expect(b.roots.first.id, CardKinds.family);
|
||||
});
|
||||
|
||||
test('저장하면 나무 모양이 유지되고, 없는 부모·돌고 도는 부모는 맨 위로', () {
|
||||
final b = GroupBook();
|
||||
final hb = b.add('한빛전자', CardKinds.company, parent: CardKinds.company)!;
|
||||
final c = GroupBook.fromJson(b.toJson());
|
||||
expect(c.byId(hb.id)!.parent, CardKinds.company);
|
||||
const bad = '{"groups":[{"id":"x","name":"X","parent":"y"},{"id":"y","name":"Y","parent":"x"},{"id":"z","name":"Z","parent":"없음"}]}';
|
||||
final d = GroupBook.fromJson(bad);
|
||||
expect(d.groups.map((g) => g.id).toSet(), {'x', 'y', 'z'});
|
||||
expect(d.byId('z')!.parent, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('이전 구성원: 흐리게 남기고, 다시 넣으면 구성원으로, 잠긴 그룹이 닫히면 숨김, 저장 유지', () async {
|
||||
final b = GroupBook();
|
||||
b.addMembers(CardKinds.company, ['a']);
|
||||
expect(b.markFormer(CardKinds.company, 'a'), isTrue);
|
||||
expect(b.markFormer(CardKinds.company, 'a'), isFalse);
|
||||
expect(b.byId(CardKinds.company)!.members, isEmpty);
|
||||
expect(b.byId(CardKinds.company)!.formers, {'a'});
|
||||
expect(GroupBook.fromJson(b.toJson()).byId(CardKinds.company)!.formers, {'a'});
|
||||
b.setLock(CardKinds.company, await Pin.make('1234', iterations: 10));
|
||||
expect(b.hiddenPeople((g) => true), {'a'});
|
||||
b.addMembers(CardKinds.company, ['a']);
|
||||
expect(b.byId(CardKinds.company)!.formers, isEmpty);
|
||||
b.markFormer(CardKinds.company, 'a');
|
||||
expect(b.removeFormer(CardKinds.company, 'a'), isTrue);
|
||||
b.addMembers(CardKinds.school, ['z']);
|
||||
b.markFormer(CardKinds.school, 'z');
|
||||
b.forgetPerson('z');
|
||||
expect(b.byId(CardKinds.school)!.formers, isEmpty);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:friendly_map/modules/kinship/kinship.dart';
|
||||
|
||||
const M = Gender.male, F = Gender.female;
|
||||
|
||||
void main() {
|
||||
KinGraph tree() {
|
||||
final g = KinGraph();
|
||||
for (final (id, gen) in [('me', M), ('dad', M), ('mom', F), ('gf', M), ('mgf', M), ('uncle', M), ('cousin', F), ('sis', F), ('wife', F), ('wifeDad', M), ('auntInLaw', F), ('son', M), ('x', M), ('p', Gender.unknown), ('pp', M)]) {
|
||||
g.put(KinPerson(id, gender: gen));
|
||||
}
|
||||
g.addParent('me', 'dad');
|
||||
g.addParent('me', 'mom');
|
||||
g.addParent('sis', 'dad');
|
||||
g.addParent('dad', 'gf');
|
||||
g.addParent('mom', 'mgf');
|
||||
g.addParent('uncle', 'gf');
|
||||
g.addParent('cousin', 'uncle');
|
||||
g.addSpouse('uncle', 'auntInLaw');
|
||||
g.addSpouse('me', 'wife');
|
||||
g.addParent('wife', 'wifeDad');
|
||||
g.addParent('son', 'me');
|
||||
return g;
|
||||
}
|
||||
|
||||
test('가까운 가족: 부모·형제·배우자·자녀', () {
|
||||
final g = tree();
|
||||
for (final id in ['dad', 'mom', 'sis', 'wife', 'son']) {
|
||||
expect(familySide(g, 'me', id), FamilySide.close, reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('아버지 쪽은 친가, 어머니 쪽은 외가', () {
|
||||
final g = tree();
|
||||
expect(familySide(g, 'me', 'gf'), FamilySide.paternal);
|
||||
expect(familySide(g, 'me', 'uncle'), FamilySide.paternal);
|
||||
expect(familySide(g, 'me', 'cousin'), FamilySide.paternal);
|
||||
expect(familySide(g, 'me', 'mgf'), FamilySide.maternal);
|
||||
});
|
||||
|
||||
test('배우자 쪽은 처가·시가, 내 혈족의 배우자는 그 혈족의 쪽', () {
|
||||
final g = tree();
|
||||
expect(familySide(g, 'me', 'wifeDad'), FamilySide.inlaw);
|
||||
expect(familySide(g, 'me', 'auntInLaw'), FamilySide.paternal);
|
||||
});
|
||||
|
||||
test('부모 성별을 모르면 쪽을 모름, 이어지지 않으면 none', () {
|
||||
final g = tree();
|
||||
g.addParent('x', 'p');
|
||||
g.addParent('p', 'pp');
|
||||
expect(familySide(g, 'x', 'pp'), FamilySide.unknown);
|
||||
expect(familySide(g, 'me', 'x'), FamilySide.none);
|
||||
expect(familySide(g, 'me', 'me'), FamilySide.none);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
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/groups/groups.dart';
|
||||
import 'package:friendly_map/modules/kinship/kinship.dart';
|
||||
import 'package:friendly_map/modules/storage/storage.dart';
|
||||
import 'package:friendly_map/modules/transport/transport.dart';
|
||||
import 'package:friendly_map/modules/visibility/visibility.dart';
|
||||
|
||||
import 'support/app_device.dart';
|
||||
|
||||
void main() {
|
||||
late AppDevice me;
|
||||
late RelateService relate;
|
||||
var n = 0;
|
||||
|
||||
setUp(() async {
|
||||
me = await AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '나');
|
||||
relate = me.app.relate;
|
||||
await me.app.kin.setMe(gender: Gender.male, birthYear: 1990);
|
||||
});
|
||||
|
||||
Future<Person> person(String name) async => (await me.app.friends.add('+8210${(5000 + n++).toString().padLeft(4, '0')}0000', name))!;
|
||||
Future<List<String>> membersOf(String groupId) async => [for (final p in (await me.app.groups.members(groupId))!) p.name];
|
||||
Future<List<Link>> linksOf(Person p) => me.app.network.links(p.id);
|
||||
|
||||
group('가족', () {
|
||||
test('아버지는 가족 그룹, 아버지의 아버지는 친가, 촌수와 호칭이 맞는다', () async {
|
||||
final dad = await person('아빠');
|
||||
final r = (await relate.asFamily(dad.id, link: KinLink.parent, gender: Gender.male))!;
|
||||
expect(r.groupId, CardKinds.family);
|
||||
expect(r.relation!.title, '아버지');
|
||||
expect(r.relation!.chon, 1);
|
||||
final gp = await person('할배');
|
||||
final r2 = (await relate.asFamily(gp.id, link: KinLink.parent, to: dad.id, gender: Gender.male))!;
|
||||
expect(r2.groupId, 'family.paternal');
|
||||
expect(r2.groupPath, '가족 › 친가');
|
||||
expect(r2.relation!.title, contains('할아버지'));
|
||||
expect(r2.relation!.chon, 2);
|
||||
expect(await membersOf('family.paternal'), ['할배']);
|
||||
final links = await linksOf(dad);
|
||||
expect(links.map((l) => '${l.relation}:${l.detail}'), ['가족:아버지']);
|
||||
});
|
||||
|
||||
test('어머니의 형제(부모를 몰라도)는 외가 외삼촌 3촌, 이름 모를 부모가 생긴다', () async {
|
||||
final mom = await person('엄마');
|
||||
await relate.asFamily(mom.id, link: KinLink.parent, gender: Gender.female);
|
||||
final uncle = await person('외삼촌');
|
||||
final r = (await relate.asFamily(uncle.id, link: KinLink.sibling, to: mom.id, gender: Gender.male))!;
|
||||
expect(r.groupId, 'family.maternal');
|
||||
expect(r.relation!.title, '외삼촌');
|
||||
expect(r.relation!.chon, 3);
|
||||
final g = await me.app.kin.graph();
|
||||
final shared = g.parentsOf(mom.id);
|
||||
expect(shared, g.parentsOf(uncle.id));
|
||||
expect((await me.app.friends.person(shared.single))!.name, RelateService.placeholderParent);
|
||||
});
|
||||
|
||||
test('아내는 가족, 아내의 아버지는 처가·시가 장인', () async {
|
||||
final wife = await person('아내');
|
||||
expect((await relate.asFamily(wife.id, link: KinLink.spouse, gender: Gender.female))!.groupId, CardKinds.family);
|
||||
final fil = await person('장인어른');
|
||||
final r = (await relate.asFamily(fil.id, link: KinLink.parent, to: wife.id, gender: Gender.male))!;
|
||||
expect(r.groupId, 'family.inlaw');
|
||||
expect(r.relation!.title, '장인');
|
||||
});
|
||||
|
||||
test('촌수를 모르면 친척 모음에, 나중에 이으면 모음에서 빠진다', () async {
|
||||
final x = await person('먼 친척');
|
||||
final r = (await relate.asFamily(x.id))!;
|
||||
expect(r.groupId, Group.unknownRelatives);
|
||||
expect(await membersOf(Group.unknownRelatives), ['먼 친척']);
|
||||
expect((await linksOf(x)).single.detail, '촌수 모름');
|
||||
await relate.asFamily(x.id, link: KinLink.child, gender: Gender.female);
|
||||
expect(await membersOf(Group.unknownRelatives), isEmpty);
|
||||
expect((await linksOf(x)).single.detail, '딸');
|
||||
});
|
||||
|
||||
test('가계도에 이을 수 없으면 null (부모가 이미 둘)', () async {
|
||||
await relate.asFamily((await person('부1')).id, link: KinLink.parent, gender: Gender.male);
|
||||
await relate.asFamily((await person('부2')).id, link: KinLink.parent, gender: Gender.female);
|
||||
expect(await relate.asFamily((await person('부3')).id, link: KinLink.parent), isNull);
|
||||
});
|
||||
|
||||
test('쪽을 모르면(부모 성별 모름) 가족 그룹에 둔다', () async {
|
||||
final p = await person('부모');
|
||||
await relate.asFamily(p.id, link: KinLink.parent);
|
||||
final gp = await person('조부모');
|
||||
expect((await relate.asFamily(gp.id, link: KinLink.parent, to: p.id))!.groupId, CardKinds.family);
|
||||
});
|
||||
});
|
||||
|
||||
group('학교 · 회사 · 모임', () {
|
||||
test('고등학교에 넣으면 동문 연결 고리가 생기고, 공개 범위가 동문으로 적용된다', () async {
|
||||
final a = await person('이영희');
|
||||
final r = (await relate.into(a.id, 'school.high'))!;
|
||||
expect(r.groupPath, '학교 › 고등학교');
|
||||
expect((await linksOf(a)).map((l) => '${l.relation}:${l.detail}'), ['동문:학교 › 고등학교']);
|
||||
expect(VAudience.ofRelation((await linksOf(a)).single.relation), VAudience.school);
|
||||
expect(await relate.placesOf(a.id), ['학교 › 고등학교']);
|
||||
});
|
||||
|
||||
test('회사 › 한빛전자 › 결제팀에 넣으면 직장 동료, 빼면 연결 고리도 지운다', () async {
|
||||
final hb = (await me.app.groups.add('한빛전자', CardKinds.company, parent: CardKinds.company))!;
|
||||
final team = (await me.app.groups.add('결제팀', CardKinds.company, parent: hb.id))!;
|
||||
final b = await person('박철수');
|
||||
await relate.into(b.id, team.id);
|
||||
await relate.into(b.id, hb.id);
|
||||
expect((await linksOf(b)).single.detail, '회사 › 한빛전자', reason: '같은 종류는 연결 고리 하나만, 마지막 근거로');
|
||||
await relate.leave(b.id, team.id);
|
||||
expect(await linksOf(b), isNotEmpty, reason: '한빛전자에 아직 있다');
|
||||
await relate.leave(b.id, hb.id);
|
||||
expect(await linksOf(b), isEmpty);
|
||||
});
|
||||
|
||||
test('닫힌 잠긴 그룹에는 넣을 수 없다', () async {
|
||||
await me.app.groups.setPin(CardKinds.group, '1234');
|
||||
me.app.groups.lockAll();
|
||||
expect(await relate.into((await person('누구')).id, CardKinds.group), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -26,7 +26,9 @@ class AppDevice {
|
|||
PhoneVerifier? verifier,
|
||||
CallLogProvider? callLog,
|
||||
PhoneActions? phone,
|
||||
Notifier? notifier}) async {
|
||||
Notifier? notifier,
|
||||
ContactsProvider? contacts,
|
||||
InstantNotifier? instant}) async {
|
||||
final key = 'KEY-$name';
|
||||
final app = FriendlyApp(AppDeps(
|
||||
storage: Storage.memory(),
|
||||
|
|
@ -40,6 +42,8 @@ class AppDevice {
|
|||
callLog: callLog,
|
||||
phone: phone,
|
||||
notifier: notifier,
|
||||
contacts: contacts,
|
||||
instant: instant,
|
||||
));
|
||||
final d = AppDevice._(app, normalized, name, key);
|
||||
await app.profile.setUp(rawPhone, name);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:friendly_map/modules/app/app.dart';
|
||||
import 'package:friendly_map/modules/transport/transport.dart';
|
||||
import 'package:friendly_map/modules/ui/src/screens/person_screen.dart';
|
||||
import 'package:friendly_map/modules/ui/ui.dart';
|
||||
|
||||
import 'support/app_device.dart';
|
||||
|
|
@ -47,4 +48,50 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
expect(find.text('내 명함'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('설정에서 그룹 관리를 열면 처음 그룹 네 개가 보이고, 새 그룹을 더하고, 학교 안에 하위 그룹이 보인다', (tester) async {
|
||||
final device = await tester.runAsync(() => AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '이미영', start: false));
|
||||
await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap(keys: true, app: device!.app)));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.descendant(of: find.byType(NavigationBar), matching: find.text('내 정보')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('저장·보안'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(find.text('그룹 관리'), 200, scrollable: find.byType(Scrollable).last);
|
||||
await tester.tap(find.text('그룹 관리'));
|
||||
await tester.pumpAndSettle();
|
||||
for (final n in ['가족', '학교', '회사', '그룹·여행']) {
|
||||
expect(find.text(n), findsOneWidget);
|
||||
}
|
||||
await tester.tap(find.byTooltip('새 그룹'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), '등산 모임');
|
||||
await tester.tap(find.text('확인'));
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('등산 모임'), findsOneWidget);
|
||||
await tester.tap(find.text('학교'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('고등학교'), findsOneWidget);
|
||||
expect(find.text('대학원'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('사람 화면의 관계 정하기로 학교 › 고등학교에 넣으면 들어간 그룹이 보인다', (tester) async {
|
||||
final device = await tester.runAsync(() => AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '이미영', start: false));
|
||||
final app = device!.app;
|
||||
final friend = await tester.runAsync(() => app.friends.add('+821033334444', '김민수'));
|
||||
await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap(keys: true, app: app)));
|
||||
await tester.pumpAndSettle();
|
||||
tester.state<NavigatorState>(find.byType(Navigator).first).push(MaterialPageRoute<void>(builder: (_) => PersonScreen(personId: friend!.id)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('관계 정하기 —'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('관계 정하기'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.textContaining('학교 동문'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('고등학교'));
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('학교 › 고등학교'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
사용: flutter create --platforms=android --org com.friendlymap --project-name friendly_map .
|
||||
python3 tool/android_patch.py
|
||||
권한(인터넷, 카메라, 같은 네트워크 발견), 평문 통신(같은 네트워크 직접 연결), minSdk 23 이상을 맞춘다.
|
||||
권한(인터넷, 카메라, 같은 네트워크 발견, 주소록 읽기), 평문 통신(같은 네트워크 직접 연결), minSdk 23 이상을 맞춘다.
|
||||
여러 번 실행해도 같은 결과가 나온다.
|
||||
"""
|
||||
import pathlib, re, sys
|
||||
|
|
@ -20,6 +20,7 @@ PERMS = [
|
|||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.ACCESS_COARSE_LOCATION",
|
||||
"android.permission.READ_CALL_LOG",
|
||||
"android.permission.READ_CONTACTS",
|
||||
"android.permission.POST_NOTIFICATIONS",
|
||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||
"android.permission.VIBRATE",
|
||||
|
|
|
|||
|
|
@ -18,15 +18,17 @@ ALLOW = {
|
|||
"layout": {"core"},
|
||||
"visibility": {"core"},
|
||||
"kinship": set(),
|
||||
"groups": {"core"},
|
||||
"work": set(),
|
||||
"events": set(),
|
||||
"app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events"},
|
||||
"ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events"},
|
||||
"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"}}
|
||||
# 모듈 폴더 밖(main.dart, composition/)은 조립 코드라서 어느 모듈 입구든 쓸 수 있다.
|
||||
# 플랫폼마다 다른 플러그인은 platform_android 에서만 쓴다 (나중에 iOS 등을 붙이기 쉽게).
|
||||
PLATFORM_ONLY = ("flutter_secure_storage", "sensors_plus", "nsd", "mobile_scanner", "sqflite_sqlcipher", "geolocator", "call_log", "url_launcher", "flutter_local_notifications", "timezone")
|
||||
PLATFORM_ONLY = ("flutter_secure_storage", "sensors_plus", "nsd", "mobile_scanner", "sqflite_sqlcipher", "geolocator", "call_log", "flutter_contacts", "url_launcher", "flutter_local_notifications", "timezone")
|
||||
root = pathlib.Path(__file__).resolve().parent.parent / "lib"
|
||||
bad = 0
|
||||
for f in sorted(root.rglob("*.dart")):
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ cd "$(dirname "$0")/.."
|
|||
declare -A T=(
|
||||
[keys]="keys_test.dart keys_crypto_test.dart shamir_test.dart recovery_shares_test.dart"
|
||||
[visibility]="visibility_test.dart"
|
||||
[kinship]="kinship_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"
|
||||
|
|
@ -16,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"
|
||||
[app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart group_service_test.dart contact_import_test.dart relate_service_test.dart work_service_test.dart career_service_test.dart"
|
||||
[platform_android]="df_parser_test.dart"
|
||||
[ui]="widget_test.dart"
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue