From 413489f49c8e242c7a7d890ee306c68193f4e255 Mon Sep 17 00:00:00 2001 From: diadmin Date: Mon, 26 Oct 2026 14:18:35 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EC=A0=95=EC=9D=98=C2=B7?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=9E=A0=EA=B8=88?= =?UTF-8?q?=EA=B3=BC=20=EC=A3=BC=EC=86=8C=EB=A1=9D=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 8 +- README.md | 13 +- android/app/src/main/AndroidManifest.xml | 1 + lib/composition/android_bootstrap.dart | 1 + lib/modules/app/app.dart | 2 + lib/modules/app/src/app_deps.dart | 2 + .../app/src/contact_import_service.dart | 174 ++++++++++++ lib/modules/app/src/friendly_app.dart | 6 + lib/modules/app/src/group_service.dart | 236 ++++++++++++++++ lib/modules/groups/groups.dart | 10 + lib/modules/groups/src/group.dart | 51 ++++ lib/modules/groups/src/group_book.dart | 163 +++++++++++ lib/modules/groups/src/pin.dart | 68 +++++ lib/modules/groups/src/unlock_guard.dart | 38 +++ lib/modules/platform/platform.dart | 1 + .../platform/src/contacts_provider.dart | 18 ++ .../platform_android/platform_android.dart | 1 + .../src/android_contacts.dart | 34 +++ .../ui/src/screens/card_wallet_screen.dart | 1 + .../ui/src/screens/chat_list_screen.dart | 1 + .../ui/src/screens/contact_import_screen.dart | 261 ++++++++++++++++++ .../ui/src/screens/group_detail_screen.dart | 108 ++++++++ lib/modules/ui/src/screens/groups_screen.dart | 139 ++++++++++ lib/modules/ui/src/screens/home_shell.dart | 23 +- lib/modules/ui/src/screens/people_screen.dart | 31 ++- .../ui/src/screens/settings_screen.dart | 18 +- lib/modules/ui/src/widgets/group_dialogs.dart | 135 +++++++++ lib/modules/ui/src/widgets/pin_dialog.dart | 117 ++++++++ pubspec.lock | 38 ++- pubspec.yaml | 1 + test/contact_import_test.dart | 120 ++++++++ test/group_service_test.dart | 101 +++++++ test/groups_test.dart | 128 +++++++++ test/support/app_device.dart | 4 +- test/widget_test.dart | 23 ++ tool/android_patch.py | 3 +- tool/check_modules.py | 7 +- tool/test_module.sh | 3 +- 38 files changed, 2056 insertions(+), 33 deletions(-) create mode 100644 lib/modules/app/src/contact_import_service.dart create mode 100644 lib/modules/app/src/group_service.dart create mode 100644 lib/modules/groups/groups.dart create mode 100644 lib/modules/groups/src/group.dart create mode 100644 lib/modules/groups/src/group_book.dart create mode 100644 lib/modules/groups/src/pin.dart create mode 100644 lib/modules/groups/src/unlock_guard.dart create mode 100644 lib/modules/platform/src/contacts_provider.dart create mode 100644 lib/modules/platform_android/src/android_contacts.dart create mode 100644 lib/modules/ui/src/screens/contact_import_screen.dart create mode 100644 lib/modules/ui/src/screens/group_detail_screen.dart create mode 100644 lib/modules/ui/src/screens/groups_screen.dart create mode 100644 lib/modules/ui/src/widgets/group_dialogs.dart create mode 100644 lib/modules/ui/src/widgets/pin_dialog.dart create mode 100644 test/contact_import_test.dart create mode 100644 test/group_service_test.dart create mode 100644 test/groups_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 8e5c703..0a7b770 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,17 +25,19 @@ Android 다크모드 인맥 앱(Flutter/Dart). 옛 "Family MAP" + 카카오톡 ## 구조 - `lib/modules/<이름>/<이름>.dart` 가 모듈의 공개 입구(예외: platform_ui.dart, storage_sqlite.dart). 다른 모듈은 입구 파일만 import. -- 모듈: core, platform, platform_android, storage, keys, sharing, sync, transport, discovery, inference, layout, visibility, kinship, events, app, ui. +- 모듈: core, platform, platform_android, storage, keys, sharing, sync, transport, discovery, inference, layout, visibility, kinship, events, groups, app, ui. - 조립은 모듈 밖: `lib/main.dart`, `lib/composition/android_bootstrap.dart`. - 안드로이드 전용 플러그인은 platform_android 에서만 import (PLATFORM_ONLY 목록). - 화면은 `AppScope.of(context).app`(FriendlyApp)의 서비스만 쓴다. ## 상태 (2026-10-26 기준) -- 맥 podman Flutter(stable) 컨테이너에서 `flutter analyze` 문제 없음, `flutter test` 239개 통과(widget_test.dart 포함), 서버(Python) 18개 통과, check_modules.py 통과. +- 맥 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분 걸린다. - 빌드 경고(아직 빌드를 막지는 않음): `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). -- 컨테이너의 `flutter pub get` 은 pubspec.lock 을 낮은 판으로 바꾼다. 검사가 끝나면 `git checkout pubspec.lock` 으로 되돌린다. +- pubspec.lock 은 이 컨테이너(Flutter stable)에서 풀린 판을 기준으로 올린다(2026-10-26 flutter_contacts 추가 때부터). +- 빌드 캐시: `-v fm-gradle:/root/.gradle -v fm-pub:/root/.pub-cache` 를 붙이면 두 번째 빌드부터 빨라진다. - 알려진 한계: 윤달 생일 미지원(기일만 지원), 음력 변환 1951~2049년, 통화 기록 읽기(READ_CALL_LOG)는 Play 스토어 제한, WebRTC·앱 잠금·복구 단어 재확인 알림·공유 이미지/PDF 저장·우편함 요청 서명은 미구현. 문자 인증은 서버에 `--sms-webhook` 연결이 필요. ## 빌드·시험 방법 (Mac Pro 2013, podman) diff --git a/README.md b/README.md index 75d3171..d903e14 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ | 모듈 | 하는 일 | 쓸 수 있는 모듈 | |---|---|---| | core | 모듈끼리 주고받는 약속(키·전송·암호·봉투·명함 항목 이름) | 없음 | -| platform | 기기마다 다른 기능의 약속(보안 저장소, 저장 공간, 센서, QR, 발견, 위치, 통화 기록·전화, 알림) | 없음 | +| platform | 기기마다 다른 기능의 약속(보안 저장소, 저장 공간, 센서, QR, 발견, 위치, 통화 기록·전화, 알림, 주소록) | 없음 | | platform_android | platform 의 안드로이드 구현(플러그인은 여기서만) | core, platform, storage | | storage | 저장소 약속 + 메모리/SQLite 구현 | platform | | keys | 12단어 복구 코드, 키 파생, 둘만 아는 키 암호, 복구 조각 나누기·합치기 | core, platform | @@ -29,8 +29,9 @@ | layout | 홈·명함 틀(템플릿) 순서/보이기/편집 | core | | kinship | 가계도로 촌수·호칭 계산(순수) | 없음 | | events | 생일·기일 일정, 알림 날짜 계획, 통화 기록 요약(순수) | 없음 | +| groups | 그룹 정의(이름·종류·순서·구성원), 비밀번호 잠금, 여러 번 틀리면 기다리기(순수) | core | | 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/composition/android_bootstrap.dart` 의 해당 줄만 바꾼다. @@ -100,3 +101,11 @@ flutter run - `--require-verify` 를 켜면 확인을 마친 기기만 우편함을 열 수 있다 (번호 선점 차단). 켜지 않으면 예전처럼 처음 쓴 기기에 묶인다. - 5분 만료, 번호당 1시간 3번, 5번 오답이면 새로 요청. `--sms-log` 는 시험용(문자를 보내지 않고 기록)이라 실서비스에서 쓰면 안 된다. - 서버 시험: `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` 가 넣는다). diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7046832..8dff6f0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + diff --git a/lib/composition/android_bootstrap.dart b/lib/composition/android_bootstrap.dart index e56138d..8cdb70b 100644 --- a/lib/composition/android_bootstrap.dart +++ b/lib/composition/android_bootstrap.dart @@ -81,6 +81,7 @@ class AndroidBootstrap implements Bootstrap { callLog: AndroidCallLog(), phone: AndroidPhoneActions(), notifier: AndroidNotifier(), + contacts: AndroidContacts(), verifier: LatePhoneVerifier(() async { final me = await storage.settings.loadProfile(); final url = (await storage.settings.getText(mailboxUrlKey))?.trim() ?? ''; diff --git a/lib/modules/app/app.dart b/lib/modules/app/app.dart index 509a31f..dbac7af 100644 --- a/lib/modules/app/app.dart +++ b/lib/modules/app/app.dart @@ -9,12 +9,14 @@ export 'src/call_service.dart'; export 'src/card_service.dart'; export 'src/change_bus.dart'; export 'src/chat_service.dart'; +export 'src/contact_import_service.dart'; export 'src/event_service.dart'; export 'src/facts_service.dart'; export 'src/fields.dart'; export 'src/friend_service.dart' show FriendService, NearbyPerson, NewFriendCheck; export 'src/friendly_app.dart'; export 'src/geo.dart'; +export 'src/group_service.dart'; export 'src/kin_service.dart'; export 'src/layout_service.dart' show LayoutService; export 'src/location_service.dart'; diff --git a/lib/modules/app/src/app_deps.dart b/lib/modules/app/src/app_deps.dart index 23fb666..d40070c 100644 --- a/lib/modules/app/src/app_deps.dart +++ b/lib/modules/app/src/app_deps.dart @@ -18,6 +18,7 @@ class AppDeps { this.callLog, this.phone, this.notifier, + this.contacts, Clock? clock, }) : clock = clock ?? Clock(); @@ -33,5 +34,6 @@ class AppDeps { final CallLogProvider? callLog; final PhoneActions? phone; final Notifier? notifier; + final ContactsProvider? contacts; final Clock clock; } diff --git a/lib/modules/app/src/contact_import_service.dart b/lib/modules/app/src/contact_import_service.dart new file mode 100644 index 0000000..50c4ca0 --- /dev/null +++ b/lib/modules/app/src/contact_import_service.dart @@ -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 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 requestPermission() async => await _provider?.requestPermission() ?? false; + + /// 주소록 전부를 살펴 가져올 수 있는지 표시한다 (이름순). + Future> load() async { + final p = _provider; + if (p == null) return const []; + final me = (await _storage.settings.loadProfile())?.phone; + final out = []; + 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 filter(List all, ContactFilter f) => + [for (final c in all) if (matchesContact(c, f)) c]; + + /// 주소록 그룹(라벨) 이름들 (가나다순). + static List labels(List all) => + {for (final c in all) ...c.contact.labels}.toList()..sort(); + + /// 고른 사람들을 가져와 [groupIds] 그룹에 넣는다. 같은 번호는 한 번만 넣는다. + Future import(List picked, {Set groupIds = const {}, bool sendRequest = false}) async { + var added = 0, existing = 0, skipped = 0, grouped = 0, requested = 0; + final ids = []; + final done = {}; + 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); + } +} diff --git a/lib/modules/app/src/friendly_app.dart b/lib/modules/app/src/friendly_app.dart index 05956dc..ad015b8 100644 --- a/lib/modules/app/src/friendly_app.dart +++ b/lib/modules/app/src/friendly_app.dart @@ -8,9 +8,11 @@ import 'card_service.dart'; import 'change_bus.dart'; import 'call_service.dart'; import 'chat_service.dart'; +import 'contact_import_service.dart'; import 'event_service.dart'; import 'facts_service.dart'; import 'friend_service.dart'; +import 'group_service.dart'; import 'kin_service.dart'; import 'layout_service.dart'; import 'location_service.dart'; @@ -54,6 +56,8 @@ class FriendlyApp { kin = KinService(storage, clock, facts, bus); events = EventService(storage, clock, facts, bus, deps.notifier); calls = CallService(storage, deps.callLog, deps.phone); + groups = GroupService(storage, clock, bus); + contacts = ContactImportService(storage, clock, friends, groups, bus, deps.contacts); } final AppDeps deps; @@ -74,6 +78,8 @@ class FriendlyApp { late final KinService kin; late final EventService events; late final CallService calls; + late final GroupService groups; + late final ContactImportService contacts; StreamSubscription? _events; final Set> _tasks = {}; diff --git a/lib/modules/app/src/group_service.dart b/lib/modules/app/src/group_service.dart new file mode 100644 index 0000000..a2b6dea --- /dev/null +++ b/lib/modules/app/src/group_service.dart @@ -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 _openedAt = {}; + final Map _guards = {}; + + Future _load() async => GroupBook.fromJson(await _storage.settings.getText(_key)); + + Future _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> all() async { + final b = await _load(); + return [for (final g in b.groups) GroupView(g, _isOpen(b, g))]; + } + + Future view(String id) async { + final b = await _load(); + final g = b.byId(id); + return g == null ? null : GroupView(g, _isOpen(b, g)); + } + + /// 새 그룹. 이름이 비었거나 이미 있으면 null. + Future 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 rename(String id, String name) => _edit(id, (b) => b.rename(id, name)); + Future setKind(String id, String kind) => _edit(id, (b) => b.setKind(id, kind)); + Future move(String id, int to) => _edit(id, (b) => b.move(id, to)); + + /// 그룹을 지운다. 잠긴 그룹은 열려 있을 때만 지울 수 있다 (사람은 남는다). + Future 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 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 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 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 _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?> members(String id) async { + final b = await _load(); + final g = b.byId(id); + if (g == null || !_isOpen(b, g)) return null; + final out = []; + 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 addMembers(String id, Iterable 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 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> groupsOf(String person) async { + final b = await _load(); + return [for (final g in b.groupsOf(person)) if (_isOpen(b, g)) g]; + } + + /// 닫힌 잠긴 그룹의 사람들 (인맥 목록 등에서 숨긴다). + Future> hiddenPeople() async { + final b = await _load(); + return b.hiddenPeople((g) => !_isOpen(b, g)); + } + + Future relockMinutes() async => (await _load()).relockMinutes; + + Future setRelockMinutes(int minutes) async { + final b = await _load(); + b.relockMinutes = minutes.clamp(1, 60); + await _save(b); + } + + Future _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; + } +} diff --git a/lib/modules/groups/groups.dart b/lib/modules/groups/groups.dart new file mode 100644 index 0000000..6705670 --- /dev/null +++ b/lib/modules/groups/groups.dart @@ -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'; diff --git a/lib/modules/groups/src/group.dart b/lib/modules/groups/src/group.dart new file mode 100644 index 0000000..2eb784e --- /dev/null +++ b/lib/modules/groups/src/group.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 members; // 사람 id + final PinHash? lock; + + bool get locked => lock != null; + + Group copyWith({String? name, String? kind, Set? 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 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 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), + ]; +} diff --git a/lib/modules/groups/src/group_book.dart b/lib/modules/groups/src/group_book.dart new file mode 100644 index 0000000..b0bc708 --- /dev/null +++ b/lib/modules/groups/src/group_book.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; + +import 'group.dart'; +import 'pin.dart'; + +/// 그룹 목록 전체 (순서·구성원·잠금·다시 잠그는 시간). 글자(JSON)로 저장했다가 되살린다. +class GroupBook { + GroupBook({List? groups, this.relockMinutes = defaultRelockMinutes, int nextId = 1}) + : _groups = [...(groups ?? Group.defaults())], + _next = nextId; + + static const defaultRelockMinutes = 5; + static const maxNameLength = 20; + + final List _groups; + int _next; + + /// 잠긴 그룹을 연 뒤 이 시간(분)이 지나면 다시 잠근다. + int relockMinutes; + + List 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 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 groupsOf(String person) => [for (final g in _groups) if (g.members.contains(person)) g]; + + /// [closed] 에 든(지금 열려 있지 않은) 잠긴 그룹의 구성원. 다른 그룹에도 있어도 잠긴 쪽을 따른다. + Set 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 = []; + final seen = {}; + 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(); + } + } +} diff --git a/lib/modules/groups/src/pin.dart b/lib/modules/groups/src/pin.dart new file mode 100644 index 0000000..9eb0b79 --- /dev/null +++ b/lib/modules/groups/src/pin.dart @@ -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 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 make(String pin, {List? salt, int iterations = defaultIterations}) async { + final s = salt ?? List.generate(16, (_) => _rng.nextInt(256)); + final h = await _derive(pin, s, iterations); + return PinHash(base64Encode(s), base64Encode(h), iterations); + } + + static Future verify(String pin, PinHash stored) async { + final List 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> _derive(String pin, List 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(); + } +} diff --git a/lib/modules/groups/src/unlock_guard.dart b/lib/modules/groups/src/unlock_guard.dart new file mode 100644 index 0000000..814f3e3 --- /dev/null +++ b/lib/modules/groups/src/unlock_guard.dart @@ -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); + } +} diff --git a/lib/modules/platform/platform.dart b/lib/modules/platform/platform.dart index 2d2f727..f986c76 100644 --- a/lib/modules/platform/platform.dart +++ b/lib/modules/platform/platform.dart @@ -6,6 +6,7 @@ library; export 'src/call_log_provider.dart'; +export 'src/contacts_provider.dart'; export 'src/device_storage_probe.dart'; export 'src/lan_discovery.dart'; export 'src/location_provider.dart'; diff --git a/lib/modules/platform/src/contacts_provider.dart b/lib/modules/platform/src/contacts_provider.dart new file mode 100644 index 0000000..205c78f --- /dev/null +++ b/lib/modules/platform/src/contacts_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 phones; // 적힌 그대로의 번호들 + final List labels; // 주소록 그룹(라벨) 이름들 +} + +/// 기기 주소록 읽기. 권한이 없으면 빈 목록. +abstract class ContactsProvider { + /// 읽기 권한을 묻는다. 허락받았으면 true. + Future requestPermission(); + + /// 번호가 하나 이상 있는 연락처 전부 (이름순). + Future> all(); +} diff --git a/lib/modules/platform_android/platform_android.dart b/lib/modules/platform_android/platform_android.dart index 71bc4d8..9fbd959 100644 --- a/lib/modules/platform_android/platform_android.dart +++ b/lib/modules/platform_android/platform_android.dart @@ -6,6 +6,7 @@ library; export 'src/android_call_log.dart'; +export 'src/android_contacts.dart'; export 'src/android_database.dart'; export 'src/android_lan_discovery.dart'; export 'src/android_location_provider.dart'; diff --git a/lib/modules/platform_android/src/android_contacts.dart b/lib/modules/platform_android/src/android_contacts.dart new file mode 100644 index 0000000..7f37747 --- /dev/null +++ b/lib/modules/platform_android/src/android_contacts.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 requestPermission() async { + try { + return await fc.FlutterContacts.requestPermission(readonly: true); + } catch (_) { + return false; + } + } + + @override + Future> 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 []; + } + } +} diff --git a/lib/modules/ui/src/screens/card_wallet_screen.dart b/lib/modules/ui/src/screens/card_wallet_screen.dart index 6039f39..d14daa7 100644 --- a/lib/modules/ui/src/screens/card_wallet_screen.dart +++ b/lib/modules/ui/src/screens/card_wallet_screen.dart @@ -47,6 +47,7 @@ class CardWalletScreen extends StatelessWidget { ], ), floatingActionButton: FloatingActionButton.extended( + heroTag: null, onPressed: () => pushPage(context, const TemplateGalleryScreen()), icon: const Icon(Icons.add), label: const Text('명함 추가'), diff --git a/lib/modules/ui/src/screens/chat_list_screen.dart b/lib/modules/ui/src/screens/chat_list_screen.dart index 4d2db4a..9fddd4b 100644 --- a/lib/modules/ui/src/screens/chat_list_screen.dart +++ b/lib/modules/ui/src/screens/chat_list_screen.dart @@ -28,6 +28,7 @@ class ChatListScreen extends StatelessWidget { ], ), floatingActionButton: FloatingActionButton( + heroTag: null, tooltip: '새 대화', onPressed: () => _newChat(context), child: const Icon(Icons.chat_bubble_outline), diff --git a/lib/modules/ui/src/screens/contact_import_screen.dart b/lib/modules/ui/src/screens/contact_import_screen.dart new file mode 100644 index 0000000..f36893c --- /dev/null +++ b/lib/modules/ui/src/screens/contact_import_screen.dart @@ -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 createState() => _ContactImportScreenState(); +} + +class _ContactImportScreenState extends State { + List? _all; + bool _denied = false; + String _query = ''; + String? _label; + bool _hideKnown = true; + final Set _picked = {}; // 주소록 번호표 + bool _busy = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_all == null && !_busy) _load(); + } + + Future _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 []; + if (!mounted) return; + setState(() { + _busy = false; + _denied = !allowed; + _all = list; + _picked.removeWhere((id) => !list.any((c) => c.contact.id == id)); + }); + } + + List get _shown => + ContactImportService.filter(_all ?? const [], ContactFilter(query: _query, label: _label, hideKnown: _hideKnown)); + + Future _import() async { + final app = AppScope.of(context).app; + final picked = [for (final c in _all ?? const []) 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 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 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 _groups = {if (widget.preset != null) widget.preset!}; + bool _request = false; + List? _views; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _views ?? _reload(); + } + + Future _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('가져오기')), + ), + ]), + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/group_detail_screen.dart b/lib/modules/ui/src/screens/group_detail_screen.dart new file mode 100644 index 0000000..e1f3a80 --- /dev/null +++ b/lib/modules/ui/src/screens/group_detail_screen.dart @@ -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? members; // 닫혀 있으면 null +} + +/// 그룹 한 개: 구성원 보기·넣기·빼기. 잠긴 그룹이 닫히면 구성원을 감춘다. +class GroupDetailScreen extends StatelessWidget { + const GroupDetailScreen({super.key, required this.groupId}); + + final String groupId; + + Future _addFromPeople(BuildContext context, List 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); + }, + ), + ), + ]), + ); + }, + ); + } +} diff --git a/lib/modules/ui/src/screens/groups_screen.dart b/lib/modules/ui/src/screens/groups_screen.dart new file mode 100644 index 0000000..4cb169c --- /dev/null +++ b/lib/modules/ui/src/screens/groups_screen.dart @@ -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 groups; + final int relock; +} + +enum _Act { edit, up, down, setPin, clearPin, lockNow, delete } + +/// 설정 > 그룹 관리: 그룹 정의(이름·종류·순서), 비밀번호 잠금, 지우기. +class GroupsScreen extends StatelessWidget { + const GroupsScreen({super.key}); + + Future _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 _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))), + ], + ), + ]), + ); + } +} diff --git a/lib/modules/ui/src/screens/home_shell.dart b/lib/modules/ui/src/screens/home_shell.dart index 73afdc2..b519660 100644 --- a/lib/modules/ui/src/screens/home_shell.dart +++ b/lib/modules/ui/src/screens/home_shell.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../app_scope.dart'; import 'card_wallet_screen.dart'; import 'chat_list_screen.dart'; import 'discover_screen.dart'; @@ -14,9 +15,29 @@ class HomeShell extends StatefulWidget { State createState() => _HomeShellState(); } -class _HomeShellState extends State { +class _HomeShellState extends State with WidgetsBindingObserver { int _index = 0; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + /// 앱을 내리면 연 그룹을 모두 다시 잠근다. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) { + AppScope.of(context).app.groups.lockAll(); + } + } + static const _tabs = <(IconData, String)>[ (Icons.home_outlined, '홈'), (Icons.chat_bubble_outline, '채팅'), diff --git a/lib/modules/ui/src/screens/people_screen.dart b/lib/modules/ui/src/screens/people_screen.dart index d79a5a2..584647f 100644 --- a/lib/modules/ui/src/screens/people_screen.dart +++ b/lib/modules/ui/src/screens/people_screen.dart @@ -7,7 +7,13 @@ import '../widgets/common.dart'; import 'add_friend_screen.dart'; import 'person_screen.dart'; -/// 친구 전체 목록. +class _Data { + const _Data(this.people, this.hidden); + final List people; + final int hidden; // 잠긴 그룹이라 숨긴 사람 수 +} + +/// 친구 전체 목록. 닫힌 잠긴 그룹의 사람은 보이지 않는다. class PeopleScreen extends StatelessWidget { const PeopleScreen({super.key}); @@ -18,11 +24,26 @@ class PeopleScreen extends StatelessWidget { title: const Text('인맥'), actions: [IconButton(tooltip: '새 친구', onPressed: () => pushPage(context, const AddFriendScreen()), icon: const Icon(Icons.person_add_alt_1))], ), - body: AsyncView>( - load: (app) => app.friends.people(), - builder: (context, list) { - if (list.isEmpty) return const EmptyNote('아직 친구가 없어요.\n오른쪽 위 버튼으로 첫 친구를 추가해 보세요.'); + body: AsyncView<_Data>( + load: (app) async { + final hidden = await app.groups.hiddenPeople(); + final all = await app.friends.people(); + final shown = [for (final p in all) if (!hidden.contains(p.id)) p]; + return _Data(shown, all.length - shown.length); + }, + builder: (context, d) { + final list = d.people; + if (list.isEmpty && d.hidden == 0) return const EmptyNote('아직 친구가 없어요.\n오른쪽 위 버튼으로 첫 친구를 추가해 보세요.'); return ListView(padding: const EdgeInsets.only(top: 8, bottom: 32), children: [ + if (d.hidden > 0) + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Row(children: [ + const Icon(Icons.lock, size: 14, color: Pal.muted), + const SizedBox(width: 6), + Text('잠긴 그룹의 ${d.hidden}명은 숨겼어요', style: const TextStyle(fontSize: 12, color: Pal.muted)), + ]), + ), for (final p in list) ListTile( leading: Avatar(p.name, size: 40), diff --git a/lib/modules/ui/src/screens/settings_screen.dart b/lib/modules/ui/src/screens/settings_screen.dart index eb21656..bcf093b 100644 --- a/lib/modules/ui/src/screens/settings_screen.dart +++ b/lib/modules/ui/src/screens/settings_screen.dart @@ -6,13 +6,15 @@ import '../theme.dart'; import '../widgets/async_view.dart'; import '../widgets/common.dart'; import '../widgets/event_settings_panel.dart'; +import 'contact_import_screen.dart'; +import 'groups_screen.dart'; import 'held_shares_screen.dart'; import 'recovery_entrust_screen.dart'; import 'welcome_screen.dart'; const int _mb = 1024 * 1024; -/// 저장·보안 설정: 별도 저장 기준, 기기 저장 공간 기준, 복구 코드와 조각 수. +/// 저장·보안 설정: 별도 저장 기준, 기기 저장 공간 기준, 그룹·주소록, 복구 코드와 조각 수. class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -63,6 +65,20 @@ class SettingsScreen extends StatelessWidget { ]), ), const EventSettingsPanel(), + Panel( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('그룹 · 주소록', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + const Text('가족·학교 같은 그룹을 정하고 고쳐요. 그룹마다 비밀번호를 걸 수 있어요. 휴대폰 주소록에서 한 명씩 또는 여러 명을 골라 그룹에 넣어요.', + style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)), + const SizedBox(height: 10), + Row(children: [ + Expanded(child: OutlinedButton(onPressed: () => pushPage(context, const GroupsScreen()), child: const Text('그룹 관리'))), + const SizedBox(width: 8), + Expanded(child: OutlinedButton(onPressed: () => pushPage(context, const ContactImportScreen()), child: const Text('주소록에서 가져오기'))), + ]), + ]), + ), Panel( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('복구 코드', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), diff --git a/lib/modules/ui/src/widgets/group_dialogs.dart b/lib/modules/ui/src/widgets/group_dialogs.dart new file mode 100644 index 0000000..d5b8b0e --- /dev/null +++ b/lib/modules/ui/src/widgets/group_dialogs.dart @@ -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?> pickPeople(BuildContext context, List people, {Set already = const {}, String title = '넣을 사람 고르기'}) { + return showModalBottomSheet>( + 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 people; + final Set already; + final String title; + + @override + State<_PeoplePicker> createState() => _PeoplePickerState(); +} + +class _PeoplePickerState extends State<_PeoplePicker> { + final Set _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)), + ), + ]), + ), + ]), + ), + ); + } +} diff --git a/lib/modules/ui/src/widgets/pin_dialog.dart b/lib/modules/ui/src/widgets/pin_dialog.dart new file mode 100644 index 0000000..aabb596 --- /dev/null +++ b/lib/modules/ui/src/widgets/pin_dialog.dart @@ -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 labels; + final String? note; + + @override + State<_PinFields> createState() => _PinFieldsState(); +} + +class _PinFieldsState extends State<_PinFields> { + late final List _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?> _ask(BuildContext context, String title, List labels, {String? note}) => + showDialog>(context: context, builder: (_) => _PinFields(title: title, labels: labels, note: note)); + +/// 잠긴 그룹을 연다. 이미 열려 있으면 바로 true. 취소하거나 틀리면 false. +Future 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 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 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; +} diff --git a/pubspec.lock b/pubspec.lock index 6b97129..b545196 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: clock - sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.1.2" code_assets: dependency: transitive description: @@ -150,6 +150,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_contacts: + dependency: "direct main" + description: + name: flutter_contacts + sha256: "388d32cd33f16640ee169570128c933b45f3259bddbfae7a100bb49e5ffea9ae" + url: "https://pub.dev" + source: hosted + version: "1.1.9+2" flutter_lints: dependency: "direct dev" description: @@ -428,10 +436,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -444,10 +452,10 @@ packages: dependency: transitive description: name: meta - sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.18.3" + version: "1.18.0" mobile_scanner: dependency: "direct main" description: @@ -745,10 +753,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.12.2" + version: "1.12.1" stream_channel: dependency: transitive description: @@ -785,10 +793,10 @@ packages: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" timezone: dependency: "direct main" description: @@ -881,10 +889,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.2.0" vm_service: dependency: transitive description: @@ -897,10 +905,10 @@ packages: dependency: transitive description: name: web - sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.1" win32: dependency: transitive description: @@ -934,5 +942,5 @@ packages: source: hosted version: "3.1.4" sdks: - dart: ">=3.13.0 <4.0.0" + dart: ">=3.12.0 <4.0.0" flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 4b6cc40..d95e984 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: sensors_plus: any # 발견: 흔들기 감지 geolocator: any # 위치: 안드로이드 현재 위치 call_log: any # 통화: 안드로이드 통화 기록 + flutter_contacts: ^1.1.9 # 주소록: 안드로이드 연락처 읽기 url_launcher: any # 통화: 전화·문자 화면 열기 flutter_local_notifications: ^17.2.4 # 알림: 예약 알림 timezone: ^0.9.4 # 알림: 예약 시각 계산 diff --git a/test/contact_import_test.dart b/test/contact_import_test.dart new file mode 100644 index 0000000..0f2a0c8 --- /dev/null +++ b/test/contact_import_test.dart @@ -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 list; + final bool allow; + @override + Future requestPermission() async => allow; + @override + Future> 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 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'); + }); +} diff --git a/test/group_service_test.dart b/test/group_service_test.dart new file mode 100644 index 0000000..1d41bd4 --- /dev/null +++ b/test/group_service_test.dart @@ -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); + }); +} diff --git a/test/groups_test.dart b/test/groups_test.dart new file mode 100644 index 0000000..6d35049 --- /dev/null +++ b/test/groups_test.dart @@ -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); + }); + }); +} diff --git a/test/support/app_device.dart b/test/support/app_device.dart index cc43ed0..33f2c6a 100644 --- a/test/support/app_device.dart +++ b/test/support/app_device.dart @@ -26,7 +26,8 @@ class AppDevice { PhoneVerifier? verifier, CallLogProvider? callLog, PhoneActions? phone, - Notifier? notifier}) async { + Notifier? notifier, + ContactsProvider? contacts}) async { final key = 'KEY-$name'; final app = FriendlyApp(AppDeps( storage: Storage.memory(), @@ -40,6 +41,7 @@ class AppDevice { callLog: callLog, phone: phone, notifier: notifier, + contacts: contacts, )); final d = AppDevice._(app, normalized, name, key); await app.profile.setUp(rawPhone, name); diff --git a/test/widget_test.dart b/test/widget_test.dart index 9b3ecb2..b2cfdbb 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -47,4 +47,27 @@ void main() { await tester.pumpAndSettle(); expect(find.text('내 명함'), findsWidgets); }); + + testWidgets('설정에서 그룹 관리를 열면 처음 그룹 네 개가 보이고, 새 그룹을 더할 수 있다', (tester) async { + final device = await tester.runAsync(() => AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '이미영', start: false)); + await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap(keys: true, app: device!.app))); + await tester.pumpAndSettle(); + await tester.tap(find.descendant(of: find.byType(NavigationBar), matching: find.text('내 정보'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('저장·보안')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('그룹 관리'), 200, scrollable: find.byType(Scrollable).last); + await tester.tap(find.text('그룹 관리')); + await tester.pumpAndSettle(); + for (final n in ['가족', '학교', '회사', '그룹·여행']) { + expect(find.text(n), findsOneWidget); + } + await tester.tap(find.byTooltip('새 그룹')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '등산 모임'); + await tester.tap(find.text('확인')); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pumpAndSettle(); + expect(find.text('등산 모임'), findsOneWidget); + }); } diff --git a/tool/android_patch.py b/tool/android_patch.py index 67bdeb5..6ae2757 100644 --- a/tool/android_patch.py +++ b/tool/android_patch.py @@ -3,7 +3,7 @@ 사용: flutter create --platforms=android --org com.friendlymap --project-name friendly_map . python3 tool/android_patch.py -권한(인터넷, 카메라, 같은 네트워크 발견), 평문 통신(같은 네트워크 직접 연결), minSdk 23 이상을 맞춘다. +권한(인터넷, 카메라, 같은 네트워크 발견, 주소록 읽기), 평문 통신(같은 네트워크 직접 연결), minSdk 23 이상을 맞춘다. 여러 번 실행해도 같은 결과가 나온다. """ import pathlib, re, sys @@ -20,6 +20,7 @@ PERMS = [ "android.permission.ACCESS_FINE_LOCATION", "android.permission.ACCESS_COARSE_LOCATION", "android.permission.READ_CALL_LOG", + "android.permission.READ_CONTACTS", "android.permission.POST_NOTIFICATIONS", "android.permission.RECEIVE_BOOT_COMPLETED", "android.permission.VIBRATE", diff --git a/tool/check_modules.py b/tool/check_modules.py index 8e25742..b44072b 100644 --- a/tool/check_modules.py +++ b/tool/check_modules.py @@ -18,15 +18,16 @@ ALLOW = { "layout": {"core"}, "visibility": {"core"}, "kinship": set(), + "groups": {"core"}, "events": set(), - "app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events"}, - "ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events"}, + "app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups"}, + "ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events", "groups"}, } # 모듈 입구가 둘 이상인 경우: 입구 파일이 모듈 이름.dart 외에 허용되는 것들. EXTRA_BARRELS = {"platform": {"platform_ui.dart"}, "storage": {"storage_sqlite.dart"}} # 모듈 폴더 밖(main.dart, composition/)은 조립 코드라서 어느 모듈 입구든 쓸 수 있다. # 플랫폼마다 다른 플러그인은 platform_android 에서만 쓴다 (나중에 iOS 등을 붙이기 쉽게). -PLATFORM_ONLY = ("flutter_secure_storage", "sensors_plus", "nsd", "mobile_scanner", "sqflite_sqlcipher", "geolocator", "call_log", "url_launcher", "flutter_local_notifications", "timezone") +PLATFORM_ONLY = ("flutter_secure_storage", "sensors_plus", "nsd", "mobile_scanner", "sqflite_sqlcipher", "geolocator", "call_log", "flutter_contacts", "url_launcher", "flutter_local_notifications", "timezone") root = pathlib.Path(__file__).resolve().parent.parent / "lib" bad = 0 for f in sorted(root.rglob("*.dart")): diff --git a/tool/test_module.sh b/tool/test_module.sh index ea8399f..48918c2 100644 --- a/tool/test_module.sh +++ b/tool/test_module.sh @@ -7,6 +7,7 @@ declare -A T=( [keys]="keys_test.dart keys_crypto_test.dart shamir_test.dart recovery_shares_test.dart" [visibility]="visibility_test.dart" [kinship]="kinship_test.dart" + [groups]="groups_test.dart" [events]="events_test.dart lunar_test.dart" [storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart" [transport]="transport_test.dart" @@ -16,7 +17,7 @@ declare -A T=( [inference]="inference_test.dart" [layout]="layout_test.dart" [sharing]="sharing_service_test.dart sharing_status_test.dart" - [app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart" + [app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart group_service_test.dart contact_import_test.dart" [platform_android]="df_parser_test.dart" [ui]="widget_test.dart" )