그룹 정의·비밀번호 잠금과 주소록 가져오기 추가

- 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
diadmin 2026-10-26 14:18:35 +09:00
parent 95141686d0
commit 413489f49c
38 changed files with 2056 additions and 33 deletions

View File

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

View File

@ -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` 가 넣는다).

View File

@ -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"/>

View File

@ -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() ?? '';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('명함 추가'),

View File

@ -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),

View File

@ -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('가져오기')),
),
]),
),
);
}
}

View File

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

View File

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

View File

@ -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, '채팅'),

View File

@ -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),

View File

@ -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)),

View File

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

View File

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

View File

@ -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"

View File

@ -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 # 알림: 예약 시각 계산

View File

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

View File

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

128
test/groups_test.dart Normal file
View File

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

View File

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

View File

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

View File

@ -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",

View File

@ -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")):

View File

@ -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"
) )