그룹 정의·비밀번호 잠금과 주소록 가져오기 추가
- groups 모듈(순수): 그룹 이름·종류·순서·구성원, PBKDF2 비밀번호, 여러 번 틀리면 기다리기 - platform: ContactsProvider 약속, platform_android: flutter_contacts 구현, READ_CONTACTS 권한 - app: GroupService(잠긴 그룹 다시 잠그기 기본 5분), ContactImportService(이름·초성·번호·주소록 라벨로 거르기, 한 명/여러 명 가져오기, 친구 요청은 선택) - ui: 설정 "그룹 · 주소록", 그룹 관리·그룹 안·주소록 가져오기 화면, 닫힌 잠긴 그룹 사람은 인맥 목록에서 숨김, 앱을 내리면 다시 잠금 - 고침: 채팅·명함 칸 FAB 의 Hero 꼬리표 충돌 - 시험 27개 추가 (전체 266개) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
95141686d0
commit
413489f49c
|
|
@ -25,17 +25,19 @@ Android 다크모드 인맥 앱(Flutter/Dart). 옛 "Family MAP" + 카카오톡
|
||||||
|
|
||||||
## 구조
|
## 구조
|
||||||
- `lib/modules/<이름>/<이름>.dart` 가 모듈의 공개 입구(예외: platform_ui.dart, storage_sqlite.dart). 다른 모듈은 입구 파일만 import.
|
- `lib/modules/<이름>/<이름>.dart` 가 모듈의 공개 입구(예외: platform_ui.dart, storage_sqlite.dart). 다른 모듈은 입구 파일만 import.
|
||||||
- 모듈: core, platform, platform_android, storage, keys, sharing, sync, transport, discovery, inference, layout, visibility, kinship, events, app, ui.
|
- 모듈: core, platform, platform_android, storage, keys, sharing, sync, transport, discovery, inference, layout, visibility, kinship, events, groups, app, ui.
|
||||||
- 조립은 모듈 밖: `lib/main.dart`, `lib/composition/android_bootstrap.dart`.
|
- 조립은 모듈 밖: `lib/main.dart`, `lib/composition/android_bootstrap.dart`.
|
||||||
- 안드로이드 전용 플러그인은 platform_android 에서만 import (PLATFORM_ONLY 목록).
|
- 안드로이드 전용 플러그인은 platform_android 에서만 import (PLATFORM_ONLY 목록).
|
||||||
- 화면은 `AppScope.of(context).app`(FriendlyApp)의 서비스만 쓴다.
|
- 화면은 `AppScope.of(context).app`(FriendlyApp)의 서비스만 쓴다.
|
||||||
|
|
||||||
## 상태 (2026-10-26 기준)
|
## 상태 (2026-10-26 기준)
|
||||||
- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 239개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과.
|
- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 266개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과.
|
||||||
|
- 2026-10-26 추가: groups 모듈(그룹 정의·비밀번호 잠금), 주소록 가져오기(flutter_contacts), 설정의 "그룹 · 주소록". 다음은 "채팅에서 시작하는 관계망" 로드맵 1단계(그룹 계층).
|
||||||
- `flutter build apk --debug` 성공(app-debug.apk 약 190MB). 첫 빌드는 SDK 34·35, CMake 를 내려받느라 약 17분 걸린다.
|
- `flutter build apk --debug` 성공(app-debug.apk 약 190MB). 첫 빌드는 SDK 34·35, CMake 를 내려받느라 약 17분 걸린다.
|
||||||
- 빌드 경고(아직 빌드를 막지는 않음): `nsd_android` 가 Kotlin Gradle Plugin 을 직접 적용한다. 앞으로 Flutter 판에서 빌드가 실패할 수 있으니, 지원 판이 나오면 nsd 버전을 올린다. geolocator_android 는 deprecated API 를 쓴다는 Note 만 나온다.
|
- 빌드 경고(아직 빌드를 막지는 않음): `nsd_android` 가 Kotlin Gradle Plugin 을 직접 적용한다. 앞으로 Flutter 판에서 빌드가 실패할 수 있으니, 지원 판이 나오면 nsd 버전을 올린다. geolocator_android 는 deprecated API 를 쓴다는 Note 만 나온다.
|
||||||
- 확인 안 된 곳: 실제 기기에서 화면 실행과 플러그인 동작(geolocator, call_log, url_launcher, flutter_local_notifications, timezone, sqflite_sqlcipher, nsd, mobile_scanner, sensors_plus, flutter_secure_storage).
|
- 확인 안 된 곳: 실제 기기에서 화면 실행과 플러그인 동작(geolocator, call_log, url_launcher, flutter_local_notifications, timezone, sqflite_sqlcipher, nsd, mobile_scanner, sensors_plus, flutter_secure_storage).
|
||||||
- 컨테이너의 `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` 연결이 필요.
|
- 알려진 한계: 윤달 생일 미지원(기일만 지원), 음력 변환 1951~2049년, 통화 기록 읽기(READ_CALL_LOG)는 Play 스토어 제한, WebRTC·앱 잠금·복구 단어 재확인 알림·공유 이미지/PDF 저장·우편함 요청 서명은 미구현. 문자 인증은 서버에 `--sms-webhook` 연결이 필요.
|
||||||
|
|
||||||
## 빌드·시험 방법 (Mac Pro 2013, podman)
|
## 빌드·시험 방법 (Mac Pro 2013, podman)
|
||||||
|
|
|
||||||
13
README.md
13
README.md
|
|
@ -16,7 +16,7 @@
|
||||||
| 모듈 | 하는 일 | 쓸 수 있는 모듈 |
|
| 모듈 | 하는 일 | 쓸 수 있는 모듈 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| core | 모듈끼리 주고받는 약속(키·전송·암호·봉투·명함 항목 이름) | 없음 |
|
| core | 모듈끼리 주고받는 약속(키·전송·암호·봉투·명함 항목 이름) | 없음 |
|
||||||
| platform | 기기마다 다른 기능의 약속(보안 저장소, 저장 공간, 센서, QR, 발견, 위치, 통화 기록·전화, 알림) | 없음 |
|
| platform | 기기마다 다른 기능의 약속(보안 저장소, 저장 공간, 센서, QR, 발견, 위치, 통화 기록·전화, 알림, 주소록) | 없음 |
|
||||||
| platform_android | platform 의 안드로이드 구현(플러그인은 여기서만) | core, platform, storage |
|
| platform_android | platform 의 안드로이드 구현(플러그인은 여기서만) | core, platform, storage |
|
||||||
| storage | 저장소 약속 + 메모리/SQLite 구현 | platform |
|
| storage | 저장소 약속 + 메모리/SQLite 구현 | platform |
|
||||||
| keys | 12단어 복구 코드, 키 파생, 둘만 아는 키 암호, 복구 조각 나누기·합치기 | core, platform |
|
| keys | 12단어 복구 코드, 키 파생, 둘만 아는 키 암호, 복구 조각 나누기·합치기 | core, platform |
|
||||||
|
|
@ -29,8 +29,9 @@
|
||||||
| layout | 홈·명함 틀(템플릿) 순서/보이기/편집 | core |
|
| layout | 홈·명함 틀(템플릿) 순서/보이기/편집 | core |
|
||||||
| kinship | 가계도로 촌수·호칭 계산(순수) | 없음 |
|
| kinship | 가계도로 촌수·호칭 계산(순수) | 없음 |
|
||||||
| events | 생일·기일 일정, 알림 날짜 계획, 통화 기록 요약(순수) | 없음 |
|
| events | 생일·기일 일정, 알림 날짜 계획, 통화 기록 요약(순수) | 없음 |
|
||||||
|
| groups | 그룹 정의(이름·종류·순서·구성원), 비밀번호 잠금, 여러 번 틀리면 기다리기(순수) | core |
|
||||||
| app | 서비스 묶음(`FriendlyApp`). 화면이 쓰는 유일한 내부 입구 | 위 모듈들(키·전송 구현 제외) |
|
| 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 |
|
||||||
|
|
||||||
모듈 폴더 밖의 `lib/main.dart`, `lib/composition/` 이 부품을 고르는 유일한 곳이다.
|
모듈 폴더 밖의 `lib/main.dart`, `lib/composition/` 이 부품을 고르는 유일한 곳이다.
|
||||||
암호 구현·저장소·연결 방식을 바꾸려면 `lib/composition/android_bootstrap.dart` 의 해당 줄만 바꾼다.
|
암호 구현·저장소·연결 방식을 바꾸려면 `lib/composition/android_bootstrap.dart` 의 해당 줄만 바꾼다.
|
||||||
|
|
@ -100,3 +101,11 @@ flutter run
|
||||||
- `--require-verify` 를 켜면 확인을 마친 기기만 우편함을 열 수 있다 (번호 선점 차단). 켜지 않으면 예전처럼 처음 쓴 기기에 묶인다.
|
- `--require-verify` 를 켜면 확인을 마친 기기만 우편함을 열 수 있다 (번호 선점 차단). 켜지 않으면 예전처럼 처음 쓴 기기에 묶인다.
|
||||||
- 5분 만료, 번호당 1시간 3번, 5번 오답이면 새로 요청. `--sms-log` 는 시험용(문자를 보내지 않고 기록)이라 실서비스에서 쓰면 안 된다.
|
- 5분 만료, 번호당 1시간 3번, 5번 오답이면 새로 요청. `--sms-log` 는 시험용(문자를 보내지 않고 기록)이라 실서비스에서 쓰면 안 된다.
|
||||||
- 서버 시험: `cd server && python3 -m unittest test_mailbox test_verify`
|
- 서버 시험: `cd server && python3 -m unittest test_mailbox test_verify`
|
||||||
|
|
||||||
|
## 그룹 · 주소록 가져오기
|
||||||
|
- 설정(내 정보 → 저장·보안) → "그룹 · 주소록". 처음 그룹은 가족·학교·회사·그룹·여행이고, 더하기·이름과 종류 고치기·순서 바꾸기·지우기가 된다(지워도 사람은 인맥에 남는다).
|
||||||
|
- 그룹마다 비밀번호(4~16자)를 걸 수 있다. 잠긴 그룹은 열 때 비밀번호를 묻고, 닫혀 있으면 인맥 목록에서도 그 사람들을 숨긴다. 연 뒤 정한 시간(기본 5분, 1~60분)이 지나거나 앱을 내리면 다시 잠근다.
|
||||||
|
- 5번 틀리면 30초, 그 뒤로 틀릴 때마다 두 배(최대 1시간) 기다려야 한다. 비밀번호는 PBKDF2-SHA256 값만 남긴다. 잊으면 그 그룹은 지우고 다시 만들어야 한다.
|
||||||
|
- "주소록에서 가져오기": 이름·초성(ㄱㅁㅅ)·번호로 찾고, 휴대폰 주소록 그룹(라벨)으로 거르고, 이미 있는 사람은 빼고 볼 수 있다. 한 명씩 또는 보이는 사람을 모두 고른 뒤, 넣을 그룹(여러 개)을 골라 가져온다.
|
||||||
|
- 번호가 여럿이면 휴대폰(010) 번호를 먼저 쓴다. 가져온 사람은 이 기기 인맥에만 들어가고, "친구 요청도 보내기"를 켰을 때만 상대에게 요청이 간다. 주소록은 읽기만 한다.
|
||||||
|
- 그룹은 이 기기에만 저장한다. 안드로이드: `flutter_contacts` 플러그인과 READ_CONTACTS 권한이 필요하다(`tool/android_patch.py` 가 넣는다).
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ class AndroidBootstrap implements Bootstrap {
|
||||||
callLog: AndroidCallLog(),
|
callLog: AndroidCallLog(),
|
||||||
phone: AndroidPhoneActions(),
|
phone: AndroidPhoneActions(),
|
||||||
notifier: AndroidNotifier(),
|
notifier: AndroidNotifier(),
|
||||||
|
contacts: AndroidContacts(),
|
||||||
verifier: LatePhoneVerifier(() async {
|
verifier: LatePhoneVerifier(() async {
|
||||||
final me = await storage.settings.loadProfile();
|
final me = await storage.settings.loadProfile();
|
||||||
final url = (await storage.settings.getText(mailboxUrlKey))?.trim() ?? '';
|
final url = (await storage.settings.getText(mailboxUrlKey))?.trim() ?? '';
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ export 'src/call_service.dart';
|
||||||
export 'src/card_service.dart';
|
export 'src/card_service.dart';
|
||||||
export 'src/change_bus.dart';
|
export 'src/change_bus.dart';
|
||||||
export 'src/chat_service.dart';
|
export 'src/chat_service.dart';
|
||||||
|
export 'src/contact_import_service.dart';
|
||||||
export 'src/event_service.dart';
|
export 'src/event_service.dart';
|
||||||
export 'src/facts_service.dart';
|
export 'src/facts_service.dart';
|
||||||
export 'src/fields.dart';
|
export 'src/fields.dart';
|
||||||
export 'src/friend_service.dart' show FriendService, NearbyPerson, NewFriendCheck;
|
export 'src/friend_service.dart' show FriendService, NearbyPerson, NewFriendCheck;
|
||||||
export 'src/friendly_app.dart';
|
export 'src/friendly_app.dart';
|
||||||
export 'src/geo.dart';
|
export 'src/geo.dart';
|
||||||
|
export 'src/group_service.dart';
|
||||||
export 'src/kin_service.dart';
|
export 'src/kin_service.dart';
|
||||||
export 'src/layout_service.dart' show LayoutService;
|
export 'src/layout_service.dart' show LayoutService;
|
||||||
export 'src/location_service.dart';
|
export 'src/location_service.dart';
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class AppDeps {
|
||||||
this.callLog,
|
this.callLog,
|
||||||
this.phone,
|
this.phone,
|
||||||
this.notifier,
|
this.notifier,
|
||||||
|
this.contacts,
|
||||||
Clock? clock,
|
Clock? clock,
|
||||||
}) : clock = clock ?? Clock();
|
}) : clock = clock ?? Clock();
|
||||||
|
|
||||||
|
|
@ -33,5 +34,6 @@ class AppDeps {
|
||||||
final CallLogProvider? callLog;
|
final CallLogProvider? callLog;
|
||||||
final PhoneActions? phone;
|
final PhoneActions? phone;
|
||||||
final Notifier? notifier;
|
final Notifier? notifier;
|
||||||
|
final ContactsProvider? contacts;
|
||||||
final Clock clock;
|
final Clock clock;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,9 +8,11 @@ import 'card_service.dart';
|
||||||
import 'change_bus.dart';
|
import 'change_bus.dart';
|
||||||
import 'call_service.dart';
|
import 'call_service.dart';
|
||||||
import 'chat_service.dart';
|
import 'chat_service.dart';
|
||||||
|
import 'contact_import_service.dart';
|
||||||
import 'event_service.dart';
|
import 'event_service.dart';
|
||||||
import 'facts_service.dart';
|
import 'facts_service.dart';
|
||||||
import 'friend_service.dart';
|
import 'friend_service.dart';
|
||||||
|
import 'group_service.dart';
|
||||||
import 'kin_service.dart';
|
import 'kin_service.dart';
|
||||||
import 'layout_service.dart';
|
import 'layout_service.dart';
|
||||||
import 'location_service.dart';
|
import 'location_service.dart';
|
||||||
|
|
@ -54,6 +56,8 @@ class FriendlyApp {
|
||||||
kin = KinService(storage, clock, facts, bus);
|
kin = KinService(storage, clock, facts, bus);
|
||||||
events = EventService(storage, clock, facts, bus, deps.notifier);
|
events = EventService(storage, clock, facts, bus, deps.notifier);
|
||||||
calls = CallService(storage, deps.callLog, deps.phone);
|
calls = CallService(storage, deps.callLog, deps.phone);
|
||||||
|
groups = GroupService(storage, clock, bus);
|
||||||
|
contacts = ContactImportService(storage, clock, friends, groups, bus, deps.contacts);
|
||||||
}
|
}
|
||||||
|
|
||||||
final AppDeps deps;
|
final AppDeps deps;
|
||||||
|
|
@ -74,6 +78,8 @@ class FriendlyApp {
|
||||||
late final KinService kin;
|
late final KinService kin;
|
||||||
late final EventService events;
|
late final EventService events;
|
||||||
late final CallService calls;
|
late final CallService calls;
|
||||||
|
late final GroupService groups;
|
||||||
|
late final ContactImportService contacts;
|
||||||
|
|
||||||
StreamSubscription<SyncEvent>? _events;
|
StreamSubscription<SyncEvent>? _events;
|
||||||
final Set<Future<void>> _tasks = {};
|
final Set<Future<void>> _tasks = {};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
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);
|
||||||
|
final Group group;
|
||||||
|
|
||||||
|
/// 지금 구성원을 볼 수 있는지 (잠기지 않았거나, 비밀번호로 연 뒤 시간이 지나지 않음).
|
||||||
|
final bool open;
|
||||||
|
|
||||||
|
int? get memberCount => open ? group.members.length : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 비밀번호로 열기 결과.
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<GroupView>> all() async {
|
||||||
|
final b = await _load();
|
||||||
|
return [for (final g in b.groups) GroupView(g, _isOpen(b, g))];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<GroupView?> view(String id) async {
|
||||||
|
final b = await _load();
|
||||||
|
final g = b.byId(id);
|
||||||
|
return g == null ? null : GroupView(g, _isOpen(b, g));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 새 그룹. 이름이 비었거나 이미 있으면 null.
|
||||||
|
Future<Group?> add(String name, String kind) async {
|
||||||
|
final b = await _load();
|
||||||
|
final g = b.add(name, kind);
|
||||||
|
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;
|
||||||
|
b.remove(id);
|
||||||
|
_openedAt.remove(id);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 구성원을 더한다 (잠긴 그룹은 열려 있을 때만). 새로 들어간 수.
|
||||||
|
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,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,51 @@
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
|
||||||
|
import 'pin.dart';
|
||||||
|
|
||||||
|
/// 사람을 묶는 그룹 하나. [kind] 는 명함 종류(가족·학교·회사·그룹·개인) 중 하나다.
|
||||||
|
/// [lock] 이 있으면 비밀번호를 넣어야 구성원을 볼 수 있다.
|
||||||
|
class Group {
|
||||||
|
const Group({required this.id, required this.name, required this.kind, this.members = const {}, this.lock});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String kind;
|
||||||
|
final Set<String> members; // 사람 id
|
||||||
|
final PinHash? lock;
|
||||||
|
|
||||||
|
bool get locked => lock != null;
|
||||||
|
|
||||||
|
Group copyWith({String? name, String? kind, Set<String>? members}) =>
|
||||||
|
Group(id: id, name: name ?? this.name, kind: kind ?? this.kind, members: members ?? this.members, lock: lock);
|
||||||
|
|
||||||
|
Group withLock(PinHash? lock) => Group(id: id, name: name, kind: kind, members: members, lock: lock);
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'kind': kind,
|
||||||
|
'members': members.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'];
|
||||||
|
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,
|
||||||
|
members: m is List ? {for (final x in m) if (x is String) x} : const {},
|
||||||
|
lock: PinHash.fromJson(j['lock']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 처음 쓸 때 만들어 두는 그룹들.
|
||||||
|
static List<Group> defaults() => const [
|
||||||
|
Group(id: CardKinds.family, name: '가족', kind: CardKinds.family),
|
||||||
|
Group(id: CardKinds.school, name: '학교', kind: CardKinds.school),
|
||||||
|
Group(id: CardKinds.company, name: '회사', kind: CardKinds.company),
|
||||||
|
Group(id: CardKinds.group, name: '그룹·여행', kind: CardKinds.group),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'group.dart';
|
||||||
|
import 'pin.dart';
|
||||||
|
|
||||||
|
/// 그룹 목록 전체 (순서·구성원·잠금·다시 잠그는 시간). 글자(JSON)로 저장했다가 되살린다.
|
||||||
|
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;
|
||||||
|
|
||||||
|
final List<Group> _groups;
|
||||||
|
int _next;
|
||||||
|
|
||||||
|
/// 잠긴 그룹을 연 뒤 이 시간(분)이 지나면 다시 잠근다.
|
||||||
|
int relockMinutes;
|
||||||
|
|
||||||
|
List<Group> get groups => List.unmodifiable(_groups);
|
||||||
|
|
||||||
|
Group? byId(String id) {
|
||||||
|
for (final g in _groups) {
|
||||||
|
if (g.id == id) return g;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이름을 다듬는다. 비었거나 이미 있는 이름이면 null.
|
||||||
|
String? _cleanName(String name, {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.id != except && g.name == cut) return null;
|
||||||
|
}
|
||||||
|
return cut;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 새 그룹을 맨 뒤에 더한다. 이름이 비었거나 겹치면 null.
|
||||||
|
Group? add(String name, String kind) {
|
||||||
|
final n = _cleanName(name);
|
||||||
|
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);
|
||||||
|
_groups.add(g);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool rename(String id, String name) {
|
||||||
|
final i = _index(id);
|
||||||
|
final n = _cleanName(name, except: id);
|
||||||
|
if (i < 0 || 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 그룹을 지운다 (사람은 그대로 남는다).
|
||||||
|
bool remove(String id) {
|
||||||
|
final i = _index(id);
|
||||||
|
if (i < 0) return false;
|
||||||
|
_groups.removeAt(i);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [id] 그룹을 [to] 번째로 옮긴다.
|
||||||
|
bool move(String id, int to) {
|
||||||
|
final i = _index(id);
|
||||||
|
if (i < 0) return false;
|
||||||
|
final g = _groups.removeAt(i);
|
||||||
|
_groups.insert(to.clamp(0, _groups.length), 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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 사람을 지울 때 모든 그룹에서 뺀다.
|
||||||
|
void forgetPerson(String person) {
|
||||||
|
for (var i = 0; i < _groups.length; i++) {
|
||||||
|
if (_groups[i].members.contains(person)) {
|
||||||
|
_groups[i] = _groups[i].copyWith(members: {..._groups[i].members}..remove(person));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이 사람이 들어 있는 그룹들 (목록 순서).
|
||||||
|
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)) ...g.members,
|
||||||
|
};
|
||||||
|
|
||||||
|
int _index(String id) => _groups.indexWhere((g) => g.id == id);
|
||||||
|
|
||||||
|
String toJson() => jsonEncode({
|
||||||
|
'v': 1,
|
||||||
|
'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 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,6 +6,7 @@
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/call_log_provider.dart';
|
export 'src/call_log_provider.dart';
|
||||||
|
export 'src/contacts_provider.dart';
|
||||||
export 'src/device_storage_probe.dart';
|
export 'src/device_storage_probe.dart';
|
||||||
export 'src/lan_discovery.dart';
|
export 'src/lan_discovery.dart';
|
||||||
export 'src/location_provider.dart';
|
export 'src/location_provider.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();
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/android_call_log.dart';
|
export 'src/android_call_log.dart';
|
||||||
|
export 'src/android_contacts.dart';
|
||||||
export 'src/android_database.dart';
|
export 'src/android_database.dart';
|
||||||
export 'src/android_lan_discovery.dart';
|
export 'src/android_lan_discovery.dart';
|
||||||
export 'src/android_location_provider.dart';
|
export 'src/android_location_provider.dart';
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,7 @@ class CardWalletScreen extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
heroTag: null,
|
||||||
onPressed: () => pushPage(context, const TemplateGalleryScreen()),
|
onPressed: () => pushPage(context, const TemplateGalleryScreen()),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('명함 추가'),
|
label: const Text('명함 추가'),
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class ChatListScreen extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
|
heroTag: null,
|
||||||
tooltip: '새 대화',
|
tooltip: '새 대화',
|
||||||
onPressed: () => _newChat(context),
|
onPressed: () => _newChat(context),
|
||||||
child: const Icon(Icons.chat_bubble_outline),
|
child: const Icon(Icons.chat_bubble_outline),
|
||||||
|
|
|
||||||
|
|
@ -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.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,108 @@
|
||||||
|
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/pin_dialog.dart';
|
||||||
|
import 'contact_import_screen.dart';
|
||||||
|
import 'person_screen.dart';
|
||||||
|
|
||||||
|
class _Data {
|
||||||
|
const _Data(this.view, this.members);
|
||||||
|
final GroupView? view;
|
||||||
|
final List<Person>? members; // 닫혀 있으면 null
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 그룹 한 개: 구성원 보기·넣기·빼기. 잠긴 그룹이 닫히면 구성원을 감춘다.
|
||||||
|
class GroupDetailScreen extends StatelessWidget {
|
||||||
|
const GroupDetailScreen({super.key, required this.groupId});
|
||||||
|
|
||||||
|
final String groupId;
|
||||||
|
|
||||||
|
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명을 넣었어요');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final app = AppScope.of(context).app;
|
||||||
|
return AsyncView<_Data>(
|
||||||
|
load: (a) async => _Data(await a.groups.view(groupId), await a.groups.members(groupId)),
|
||||||
|
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: Row(children: [
|
||||||
|
Flexible(child: Text(g.name, overflow: TextOverflow.ellipsis)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
KindChip(g.kind, CardKinds.label(g.kind)),
|
||||||
|
]),
|
||||||
|
actions: [
|
||||||
|
if (g.locked && members != null)
|
||||||
|
IconButton(tooltip: '지금 잠그기', onPressed: () => app.groups.lock(groupId), icon: const Icon(Icons.lock_outline)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: members == null
|
||||||
|
? 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('비밀번호로 열기')),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
: ListView(padding: const EdgeInsets.only(top: 4, bottom: 96), children: [
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:friendly_map/modules/app/app.dart';
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
|
||||||
|
import '../app_scope.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
import '../widgets/async_view.dart';
|
||||||
|
import '../widgets/common.dart';
|
||||||
|
import '../widgets/group_dialogs.dart';
|
||||||
|
import '../widgets/pin_dialog.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _Act { edit, up, down, setPin, clearPin, lockNow, delete }
|
||||||
|
|
||||||
|
/// 설정 > 그룹 관리: 그룹 정의(이름·종류·순서), 비밀번호 잠금, 지우기.
|
||||||
|
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, '이름이 비었거나 같은 이름의 그룹이 있어요');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _act(BuildContext context, GroupView v, int index, _Act a) async {
|
||||||
|
final app = AppScope.of(context).app;
|
||||||
|
final id = v.group.id;
|
||||||
|
// 잠긴 그룹은 무엇을 하든 먼저 연다 (바로 잠그기는 이미 열린 상태에서만 보인다).
|
||||||
|
if (a != _Act.lockNow && !await openGroup(context, v)) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
switch (a) {
|
||||||
|
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, index - 1);
|
||||||
|
case _Act.down:
|
||||||
|
await app.groups.move(id, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)),
|
||||||
|
),
|
||||||
|
for (var i = 0; i < d.groups.length; i++) _tile(context, d.groups[i], i, d.groups.length),
|
||||||
|
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)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tile(BuildContext context, GroupView v, int i, int count) {
|
||||||
|
final g = v.group;
|
||||||
|
final color = Pal.ofKind(g.kind);
|
||||||
|
final sub = !v.open ? '${CardKinds.label(g.kind)} · 잠김' : '${CardKinds.label(g.kind)} · ${v.memberCount}명${g.locked ? ' · 열림' : ''}';
|
||||||
|
return Panel(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 6, 4, 6),
|
||||||
|
onTap: () async {
|
||||||
|
if (await openGroup(context, v) && context.mounted) pushPage(context, GroupDetailScreen(groupId: g.id));
|
||||||
|
},
|
||||||
|
child: Row(children: [
|
||||||
|
Icon(g.locked ? (v.open ? Icons.lock_open : Icons.lock) : Icons.folder_outlined, color: color),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(g.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
|
Text(sub, style: const TextStyle(fontSize: 12, color: Pal.muted)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
PopupMenuButton<_Act>(
|
||||||
|
tooltip: '더 보기',
|
||||||
|
onSelected: (a) => _act(context, v, i, a),
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
const PopupMenuItem(value: _Act.edit, child: Text('이름·종류 고치기')),
|
||||||
|
if (i > 0) const PopupMenuItem(value: _Act.up, child: Text('위로')),
|
||||||
|
if (i < count - 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))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../app_scope.dart';
|
||||||
import 'card_wallet_screen.dart';
|
import 'card_wallet_screen.dart';
|
||||||
import 'chat_list_screen.dart';
|
import 'chat_list_screen.dart';
|
||||||
import 'discover_screen.dart';
|
import 'discover_screen.dart';
|
||||||
|
|
@ -14,9 +15,29 @@ class HomeShell extends StatefulWidget {
|
||||||
State<HomeShell> createState() => _HomeShellState();
|
State<HomeShell> createState() => _HomeShellState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeShellState extends State<HomeShell> {
|
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||||
int _index = 0;
|
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)>[
|
static const _tabs = <(IconData, String)>[
|
||||||
(Icons.home_outlined, '홈'),
|
(Icons.home_outlined, '홈'),
|
||||||
(Icons.chat_bubble_outline, '채팅'),
|
(Icons.chat_bubble_outline, '채팅'),
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,13 @@ import '../widgets/common.dart';
|
||||||
import 'add_friend_screen.dart';
|
import 'add_friend_screen.dart';
|
||||||
import 'person_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 {
|
class PeopleScreen extends StatelessWidget {
|
||||||
const PeopleScreen({super.key});
|
const PeopleScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -18,11 +24,26 @@ class PeopleScreen extends StatelessWidget {
|
||||||
title: const Text('인맥'),
|
title: const Text('인맥'),
|
||||||
actions: [IconButton(tooltip: '새 친구', onPressed: () => pushPage(context, const AddFriendScreen()), icon: const Icon(Icons.person_add_alt_1))],
|
actions: [IconButton(tooltip: '새 친구', onPressed: () => pushPage(context, const AddFriendScreen()), icon: const Icon(Icons.person_add_alt_1))],
|
||||||
),
|
),
|
||||||
body: AsyncView<List<Person>>(
|
body: AsyncView<_Data>(
|
||||||
load: (app) => app.friends.people(),
|
load: (app) async {
|
||||||
builder: (context, list) {
|
final hidden = await app.groups.hiddenPeople();
|
||||||
if (list.isEmpty) return const EmptyNote('아직 친구가 없어요.\n오른쪽 위 버튼으로 첫 친구를 추가해 보세요.');
|
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: [
|
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)
|
for (final p in list)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Avatar(p.name, size: 40),
|
leading: Avatar(p.name, size: 40),
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,15 @@ import '../theme.dart';
|
||||||
import '../widgets/async_view.dart';
|
import '../widgets/async_view.dart';
|
||||||
import '../widgets/common.dart';
|
import '../widgets/common.dart';
|
||||||
import '../widgets/event_settings_panel.dart';
|
import '../widgets/event_settings_panel.dart';
|
||||||
|
import 'contact_import_screen.dart';
|
||||||
|
import 'groups_screen.dart';
|
||||||
import 'held_shares_screen.dart';
|
import 'held_shares_screen.dart';
|
||||||
import 'recovery_entrust_screen.dart';
|
import 'recovery_entrust_screen.dart';
|
||||||
import 'welcome_screen.dart';
|
import 'welcome_screen.dart';
|
||||||
|
|
||||||
const int _mb = 1024 * 1024;
|
const int _mb = 1024 * 1024;
|
||||||
|
|
||||||
/// 저장·보안 설정: 별도 저장 기준, 기기 저장 공간 기준, 복구 코드와 조각 수.
|
/// 저장·보안 설정: 별도 저장 기준, 기기 저장 공간 기준, 그룹·주소록, 복구 코드와 조각 수.
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -63,6 +65,20 @@ class SettingsScreen extends StatelessWidget {
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
const EventSettingsPanel(),
|
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(
|
Panel(
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
const Text('복구 코드', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
|
const Text('복구 코드', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
|
||||||
|
|
|
||||||
|
|
@ -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,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;
|
||||||
|
}
|
||||||
38
pubspec.lock
38
pubspec.lock
|
|
@ -53,10 +53,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: clock
|
name: clock
|
||||||
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.3"
|
version: "1.1.2"
|
||||||
code_assets:
|
code_assets:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -150,6 +150,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -428,10 +436,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.20"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -444,10 +452,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.3"
|
version: "1.18.0"
|
||||||
mobile_scanner:
|
mobile_scanner:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -745,10 +753,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: stack_trace
|
name: stack_trace
|
||||||
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.12.2"
|
version: "1.12.1"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -785,10 +793,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
version: "0.7.11"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -881,10 +889,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
version: "2.2.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -897,10 +905,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: web
|
name: web
|
||||||
sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.1"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -934,5 +942,5 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.4"
|
version: "3.1.4"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.13.0 <4.0.0"
|
dart: ">=3.12.0 <4.0.0"
|
||||||
flutter: ">=3.44.0"
|
flutter: ">=3.44.0"
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ dependencies:
|
||||||
sensors_plus: any # 발견: 흔들기 감지
|
sensors_plus: any # 발견: 흔들기 감지
|
||||||
geolocator: any # 위치: 안드로이드 현재 위치
|
geolocator: any # 위치: 안드로이드 현재 위치
|
||||||
call_log: any # 통화: 안드로이드 통화 기록
|
call_log: any # 통화: 안드로이드 통화 기록
|
||||||
|
flutter_contacts: ^1.1.9 # 주소록: 안드로이드 연락처 읽기
|
||||||
url_launcher: any # 통화: 전화·문자 화면 열기
|
url_launcher: any # 통화: 전화·문자 화면 열기
|
||||||
flutter_local_notifications: ^17.2.4 # 알림: 예약 알림
|
flutter_local_notifications: ^17.2.4 # 알림: 예약 알림
|
||||||
timezone: ^0.9.4 # 알림: 예약 시각 계산
|
timezone: ^0.9.4 # 알림: 예약 시각 계산
|
||||||
|
|
|
||||||
|
|
@ -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,101 @@
|
||||||
|
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((await groups.all()).map((v) => 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
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.groups.map((g) => g.name), ['가족', '학교', '회사', '그룹·여행']);
|
||||||
|
expect(b.groups.map((g) => g.kind), [CardKinds.family, CardKinds.school, CardKinds.company, CardKinds.group]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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), isTrue);
|
||||||
|
expect(b.byId(g.id), isNull);
|
||||||
|
expect(b.remove('없음'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('{깨짐').groups.length, 4);
|
||||||
|
expect(GroupBook.fromJson(null).groups.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,8 @@ class AppDevice {
|
||||||
PhoneVerifier? verifier,
|
PhoneVerifier? verifier,
|
||||||
CallLogProvider? callLog,
|
CallLogProvider? callLog,
|
||||||
PhoneActions? phone,
|
PhoneActions? phone,
|
||||||
Notifier? notifier}) async {
|
Notifier? notifier,
|
||||||
|
ContactsProvider? contacts}) async {
|
||||||
final key = 'KEY-$name';
|
final key = 'KEY-$name';
|
||||||
final app = FriendlyApp(AppDeps(
|
final app = FriendlyApp(AppDeps(
|
||||||
storage: Storage.memory(),
|
storage: Storage.memory(),
|
||||||
|
|
@ -40,6 +41,7 @@ class AppDevice {
|
||||||
callLog: callLog,
|
callLog: callLog,
|
||||||
phone: phone,
|
phone: phone,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
|
contacts: contacts,
|
||||||
));
|
));
|
||||||
final d = AppDevice._(app, normalized, name, key);
|
final d = AppDevice._(app, normalized, name, key);
|
||||||
await app.profile.setUp(rawPhone, name);
|
await app.profile.setUp(rawPhone, name);
|
||||||
|
|
|
||||||
|
|
@ -47,4 +47,27 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('내 명함'), findsWidgets);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
사용: flutter create --platforms=android --org com.friendlymap --project-name friendly_map .
|
사용: flutter create --platforms=android --org com.friendlymap --project-name friendly_map .
|
||||||
python3 tool/android_patch.py
|
python3 tool/android_patch.py
|
||||||
권한(인터넷, 카메라, 같은 네트워크 발견), 평문 통신(같은 네트워크 직접 연결), minSdk 23 이상을 맞춘다.
|
권한(인터넷, 카메라, 같은 네트워크 발견, 주소록 읽기), 평문 통신(같은 네트워크 직접 연결), minSdk 23 이상을 맞춘다.
|
||||||
여러 번 실행해도 같은 결과가 나온다.
|
여러 번 실행해도 같은 결과가 나온다.
|
||||||
"""
|
"""
|
||||||
import pathlib, re, sys
|
import pathlib, re, sys
|
||||||
|
|
@ -20,6 +20,7 @@ PERMS = [
|
||||||
"android.permission.ACCESS_FINE_LOCATION",
|
"android.permission.ACCESS_FINE_LOCATION",
|
||||||
"android.permission.ACCESS_COARSE_LOCATION",
|
"android.permission.ACCESS_COARSE_LOCATION",
|
||||||
"android.permission.READ_CALL_LOG",
|
"android.permission.READ_CALL_LOG",
|
||||||
|
"android.permission.READ_CONTACTS",
|
||||||
"android.permission.POST_NOTIFICATIONS",
|
"android.permission.POST_NOTIFICATIONS",
|
||||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||||
"android.permission.VIBRATE",
|
"android.permission.VIBRATE",
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,16 @@ ALLOW = {
|
||||||
"layout": {"core"},
|
"layout": {"core"},
|
||||||
"visibility": {"core"},
|
"visibility": {"core"},
|
||||||
"kinship": set(),
|
"kinship": set(),
|
||||||
|
"groups": {"core"},
|
||||||
"events": set(),
|
"events": set(),
|
||||||
"app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events"},
|
"app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups"},
|
||||||
"ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events"},
|
"ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups"},
|
||||||
}
|
}
|
||||||
# 모듈 입구가 둘 이상인 경우: 입구 파일이 모듈 이름.dart 외에 허용되는 것들.
|
# 모듈 입구가 둘 이상인 경우: 입구 파일이 모듈 이름.dart 외에 허용되는 것들.
|
||||||
EXTRA_BARRELS = {"platform": {"platform_ui.dart"}, "storage": {"storage_sqlite.dart"}}
|
EXTRA_BARRELS = {"platform": {"platform_ui.dart"}, "storage": {"storage_sqlite.dart"}}
|
||||||
# 모듈 폴더 밖(main.dart, composition/)은 조립 코드라서 어느 모듈 입구든 쓸 수 있다.
|
# 모듈 폴더 밖(main.dart, composition/)은 조립 코드라서 어느 모듈 입구든 쓸 수 있다.
|
||||||
# 플랫폼마다 다른 플러그인은 platform_android 에서만 쓴다 (나중에 iOS 등을 붙이기 쉽게).
|
# 플랫폼마다 다른 플러그인은 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"
|
root = pathlib.Path(__file__).resolve().parent.parent / "lib"
|
||||||
bad = 0
|
bad = 0
|
||||||
for f in sorted(root.rglob("*.dart")):
|
for f in sorted(root.rglob("*.dart")):
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ declare -A T=(
|
||||||
[keys]="keys_test.dart keys_crypto_test.dart shamir_test.dart recovery_shares_test.dart"
|
[keys]="keys_test.dart keys_crypto_test.dart shamir_test.dart recovery_shares_test.dart"
|
||||||
[visibility]="visibility_test.dart"
|
[visibility]="visibility_test.dart"
|
||||||
[kinship]="kinship_test.dart"
|
[kinship]="kinship_test.dart"
|
||||||
|
[groups]="groups_test.dart"
|
||||||
[events]="events_test.dart lunar_test.dart"
|
[events]="events_test.dart lunar_test.dart"
|
||||||
[storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart"
|
[storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart"
|
||||||
[transport]="transport_test.dart"
|
[transport]="transport_test.dart"
|
||||||
|
|
@ -16,7 +17,7 @@ declare -A T=(
|
||||||
[inference]="inference_test.dart"
|
[inference]="inference_test.dart"
|
||||||
[layout]="layout_test.dart"
|
[layout]="layout_test.dart"
|
||||||
[sharing]="sharing_service_test.dart sharing_status_test.dart"
|
[sharing]="sharing_service_test.dart sharing_status_test.dart"
|
||||||
[app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart"
|
[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"
|
||||||
[platform_android]="df_parser_test.dart"
|
[platform_android]="df_parser_test.dart"
|
||||||
[ui]="widget_test.dart"
|
[ui]="widget_test.dart"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue