Friendly Map 첫 커밋: 안드로이드 플랫폼 추가와 analyze·시험 통과
- flutter create --platforms=android 로 android/ 생성, tool/android_patch.py 적용 - android_patch.py: minSdk 를 낮추지 않고 23 미만일 때만 올림 (Flutter 기본 24 유지) - lint 8건 정리, SQLite 계약 시험이 이전 메모리 DB 를 재사용하던 문제 수정 - transport_test: python3 가 없으면 python, py 로 보관함 서버 실행 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
commit
076412dcdb
|
|
@ -0,0 +1,4 @@
|
||||||
|
* text=auto
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.png binary
|
||||||
|
*.jar binary
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.build/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
/coverage/
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
||||||
|
|
||||||
|
# Widget Preview related
|
||||||
|
.widget_preview/
|
||||||
|
|
||||||
|
# 보관함 서버 실행 데이터
|
||||||
|
server/*.sqlite3
|
||||||
|
__pycache__/
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "6a19cca56475dbfba1478ee68d7bd0c2ef891da1"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
- platform: android
|
||||||
|
create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
# Friendly Map
|
||||||
|
|
||||||
|
가족·친구·직장 관계를 한 앱에서. 새 친구가 "어디서 봤지?" 싶을 때 목적별 명함을 주고받아 관계를 유추한다.
|
||||||
|
안드로이드 우선, Flutter. 모든 기기가 서버 겸 클라이언트(P2P)이고, 외부 DB는 암호화된 임시 우편함일 뿐이다.
|
||||||
|
|
||||||
|
## 원칙 (1순위)
|
||||||
|
|
||||||
|
1. 구조가 기능보다 먼저다. 화면과 내부를 분리한다.
|
||||||
|
2. 모듈은 언제든 빼고 끼울 수 있다. 기능을 더하거나 빼도 다른 모듈을 고치지 않는다.
|
||||||
|
3. 파일은 300~400줄, 최대 700줄.
|
||||||
|
4. 모듈별로 시험한다. 시험이 끝난 모듈은 다시 만들지 않고 그 위에 쌓는다.
|
||||||
|
5. 한 번에 한 플랫폼. 지금은 안드로이드. 다른 플랫폼은 `platform_<이름>` 모듈만 새로 붙인다.
|
||||||
|
|
||||||
|
## 모듈과 의존 방향 (`tool/check_modules.py` 가 검사)
|
||||||
|
|
||||||
|
| 모듈 | 하는 일 | 쓸 수 있는 모듈 |
|
||||||
|
|---|---|---|
|
||||||
|
| core | 모듈끼리 주고받는 약속(키·전송·암호·봉투·명함 항목 이름) | 없음 |
|
||||||
|
| platform | 기기마다 다른 기능의 약속(보안 저장소, 저장 공간, 센서, QR, 발견, 위치, 통화 기록·전화, 알림) | 없음 |
|
||||||
|
| platform_android | platform 의 안드로이드 구현(플러그인은 여기서만) | core, platform, storage |
|
||||||
|
| storage | 저장소 약속 + 메모리/SQLite 구현 | platform |
|
||||||
|
| keys | 12단어 복구 코드, 키 파생, 둘만 아는 키 암호, 복구 조각 나누기·합치기 | core, platform |
|
||||||
|
| sharing | 명함·대화방이 누구와 공유되는지 | storage |
|
||||||
|
| sync | 봉투 만들기·반영, 충돌 규칙, 시계 맞추기, 재전송 | core, storage |
|
||||||
|
| transport | 메모리·직접 연결·외부 보관함·늦게 만드는 연결 | core |
|
||||||
|
| discovery | QR 내용, 흔들기 감지, 같은 네트워크 주소록 | platform |
|
||||||
|
| inference | "어디서 봤지?" 유추와 추천 | core |
|
||||||
|
| visibility | 공개 범위 표(정보 × 관계)와 명함 거르기. 순수 계산 | core |
|
||||||
|
| layout | 홈·명함 틀(템플릿) 순서/보이기/편집 | core |
|
||||||
|
| kinship | 가계도로 촌수·호칭 계산(순수) | 없음 |
|
||||||
|
| events | 생일·기일 일정, 알림 날짜 계획, 통화 기록 요약(순수) | 없음 |
|
||||||
|
| app | 서비스 묶음(`FriendlyApp`). 화면이 쓰는 유일한 내부 입구 | 위 모듈들(키·전송 구현 제외) |
|
||||||
|
| ui | 화면. `app` 의 서비스만 쓴다 | app, core, platform, storage, discovery, inference, layout, visibility, kinship, events |
|
||||||
|
|
||||||
|
모듈 폴더 밖의 `lib/main.dart`, `lib/composition/` 이 부품을 고르는 유일한 곳이다.
|
||||||
|
암호 구현·저장소·연결 방식을 바꾸려면 `lib/composition/android_bootstrap.dart` 의 해당 줄만 바꾼다.
|
||||||
|
|
||||||
|
## 시험
|
||||||
|
|
||||||
|
```
|
||||||
|
tool/test_module.sh # 모듈 목록
|
||||||
|
tool/test_module.sh sync # 모듈 하나만
|
||||||
|
flutter test # 전체
|
||||||
|
python3 tool/check_modules.py # 줄 수·import 방향
|
||||||
|
```
|
||||||
|
|
||||||
|
## 시작
|
||||||
|
|
||||||
|
```
|
||||||
|
flutter create --platforms=android --org com.friendlymap --project-name friendly_map .
|
||||||
|
python3 tool/android_patch.py # 권한, 평문 통신, minSdk 23
|
||||||
|
flutter pub get
|
||||||
|
flutter analyze
|
||||||
|
flutter test
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
보관함 서버(선택): `server/README.md`. 서버 주소를 설정 `mailbox_url` 에 넣으면 같은 네트워크 밖에서도 주고받는다.
|
||||||
|
|
||||||
|
## 알려진 한계
|
||||||
|
|
||||||
|
- 보관함의 우편함 토큰은 번호별 처음 쓴 기기에 묶인다(번호 선점 가능). 나중에 문자 인증으로 막는다.
|
||||||
|
- 그룹 대화방은 사람이 나가도 키를 바꾸지 않는다.
|
||||||
|
- 위치는 지도 타일 없이 레이더(내 위치 중심, 방위·거리)로 보여 준다. 지도 SDK는 붙이지 않았다.
|
||||||
|
- 문자 인증은 만들어 뒀지만 문자 발송 주소(`--sms-webhook`)를 정해 서버에 넣어야 켜진다. 켜기 전에는 처음 쓴 기기에 묶이는 방식(번호 선점 가능)이다.
|
||||||
|
|
||||||
|
## 가족 모임 (촌수 · 통화 요약 · 생일/기일 알림)
|
||||||
|
- 홈의 "가족 모임"에서 가족을 한 분씩 추가한다. 번호가 없는 친척도 넣을 수 있다(이 기기 안에서만 쓰이고 친구 요청은 가지 않는다).
|
||||||
|
- "나의 부모/자녀/배우자"를 고르거나 부모·자녀·배우자를 이어 가계도를 만들면, 나와 내 자녀 각각의 눈으로 본 호칭과 촌수가 나온다(예: 내게는 친사촌 형 4촌, 내 아들에게는 당숙 5촌).
|
||||||
|
- 사람을 누르면 관계, 통화 횟수·시간·마지막 통화·통화 잘 되는 시간대, 주제 메모, 다가오는 생일/기일을 요약하고 전화·문자·대화 버튼이 있다.
|
||||||
|
- 돌아가신 날을 적으면 맨 위에 "故" 띠가 뜨고(설정에서 끌 수 있다) 생일 대신 기일을 알린다.
|
||||||
|
- 설정 > "생일·기일 알림"에 `7, 2, 1, 0` 처럼 적으면 그 날짜 전 오전 9시에 알림이 울린다(기본 1일 전·당일).
|
||||||
|
- 음력: 생일·기일마다 "음력"을 켤 수 있다(1951~2049년 변환표 내장, 기일은 윤달도 지원). 제사 알림이 음력 날짜로 잡힌다.
|
||||||
|
- 한계: 통화 "내용"은 기기에 남지 않으므로 주제는 직접 적는다. 통화 기록 읽기(READ_CALL_LOG)는 Play 스토어 심사 제한이 있다.
|
||||||
|
- 안드로이드: `call_log`, `url_launcher`, `flutter_local_notifications`, `timezone` 플러그인과 권한·수신기·desugaring 설정이 필요하다(`tool/android_patch.py` 가 맞춘다).
|
||||||
|
|
||||||
|
## 복구 조각 (친구에게 맡기기)
|
||||||
|
|
||||||
|
- 설정 → 친구에게 맡기기: 12단어를 n조각으로 나눠(필요한 수 k) 친구마다 한 장씩 암호화해 보낸다. 친구 한 명의 조각으로는 아무것도 알 수 없다.
|
||||||
|
- 친구는 설정 → 내가 맡은 조각에서 조각 글자를 본다. 새 폰에서 복구할 때 필요한 수만큼의 조각 글자를 "복구 코드로 되살리기" 칸에 한 줄씩 붙여 넣는다.
|
||||||
|
- 조각 수나 필요한 수를 바꾸면 새로 나눠야 하고, 이전에 맡긴 조각은 새 조각과 섞이지 않는다.
|
||||||
|
|
||||||
|
## 공개 범위
|
||||||
|
|
||||||
|
- 내 정보 → 공개 범위 표(학교 · 회사·직책 · 생일 · 연락처 × 가족 · 회사 · 동문 · 그룹)에서 칸을 눌러 공개/일부/숨김을 정한다.
|
||||||
|
- 명함을 보내거나 "다시 보내기"를 할 때 받는 사람의 관계(연결된 관계 + 보내는 명함 종류)에 맞게 걸러서 보낸다. 관계를 모르는 사람에게는 내가 고른 그대로 간다.
|
||||||
|
- 표를 고쳐도 이미 보낸 명함은 "다시 보내기" 전까지 그대로다.
|
||||||
|
|
||||||
|
## 위치 공유
|
||||||
|
|
||||||
|
- 홈의 "위치" 구역에서 켠다. 기본은 꺼짐.
|
||||||
|
- 켜면 1분마다, 공개 범위 표의 "실시간 위치"가 공개·일부인 관계의 친구에게만 보낸다. 관계를 모르는 친구에게는 보내지 않는다. "일부"는 약 1km 단위로 뭉개서 보낸다.
|
||||||
|
- 위치는 둘만 아는 키로 잠가서 보내고, 받은 쪽에는 마지막 위치 하나만 남는다.
|
||||||
|
- 안드로이드: `geolocator` 플러그인과 위치 권한이 필요하다 (`tool/android_patch.py` 가 권한을 넣는다).
|
||||||
|
|
||||||
|
## 번호 확인 (문자 인증)
|
||||||
|
|
||||||
|
- 내 정보 → "내 번호 확인하기": 문자로 온 6자리를 넣으면 이 기기의 토큰이 내 번호에 묶인다. 폰을 바꾼 진짜 주인은 다시 확인하면 새 기기로 넘어오고, 이전 기기는 막힌다.
|
||||||
|
- 서버: `python3 mailbox_server.py --sms-webhook <주소> --require-verify`. 문자 발송은 `SmsSender` 하나(웹훅은 `{to,text}` 를 POST)라 업체를 바꿔도 서버 나머지는 그대로다.
|
||||||
|
- `--require-verify` 를 켜면 확인을 마친 기기만 우편함을 열 수 있다 (번호 선점 차단). 켜지 않으면 예전처럼 처음 쓴 기기에 묶인다.
|
||||||
|
- 5분 만료, 번호당 1시간 3번, 5번 오답이면 새로 요청. `--sms-log` 는 시험용(문자를 보내지 않고 기록)이라 실서비스에서 쓰면 안 된다.
|
||||||
|
- 서버 시험: `cd server && python3 -m unittest test_mailbox test_verify`
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- android/**
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Remember to never publicly share your keystore.
|
||||||
|
# See https://flutter.dev/to/reference-keystore
|
||||||
|
key.properties
|
||||||
|
**/*.keystore
|
||||||
|
**/*.jks
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.friendlymap.friendly_map"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "com.friendlymap.friendly_map"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
minSdk = flutter.minSdkVersion
|
||||||
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
||||||
|
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
||||||
|
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
|
||||||
|
// flag during build.
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_CALL_LOG"/>
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<application
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:label="friendly_map"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"/>
|
||||||
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.friendlymap.friendly_map
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newBuildDir: Directory =
|
||||||
|
rootProject.layout.buildDirectory
|
||||||
|
.dir("../../build")
|
||||||
|
.get()
|
||||||
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
|
}
|
||||||
|
subprojects {
|
||||||
|
project.evaluationDependsOn(":app")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
|
# This newDsl flag was added by the Flutter template
|
||||||
|
android.newDsl=false
|
||||||
|
# This builtInKotlin flag was added by the Flutter template
|
||||||
|
android.builtInKotlin=false
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
pluginManagement {
|
||||||
|
val flutterSdkPath =
|
||||||
|
run {
|
||||||
|
val properties = java.util.Properties()
|
||||||
|
file("local.properties").inputStream().use { properties.load(it) }
|
||||||
|
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
|
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||||
|
flutterSdkPath
|
||||||
|
}
|
||||||
|
|
||||||
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
|
id("com.android.application") version "9.1.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include(":app")
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:friendly_map/modules/app/app.dart';
|
||||||
|
import 'package:friendly_map/modules/discovery/discovery.dart';
|
||||||
|
import 'package:friendly_map/modules/keys/keys.dart';
|
||||||
|
import 'package:friendly_map/modules/platform_android/platform_android.dart';
|
||||||
|
import 'package:friendly_map/modules/transport/transport.dart';
|
||||||
|
import 'package:friendly_map/modules/ui/ui.dart';
|
||||||
|
|
||||||
|
/// 같은 네트워크 직접 연결이 쓰는 포트.
|
||||||
|
const int lanPort = 48766;
|
||||||
|
|
||||||
|
/// 보관함 서버 주소를 적어 두는 설정 이름. 비어 있으면 같은 네트워크 직접 연결만 쓴다.
|
||||||
|
const String mailboxUrlKey = 'mailbox_url';
|
||||||
|
|
||||||
|
/// 안드로이드용 조립: 어떤 부품(보안 저장소, 암호화 DB, 직접 연결, 보관함)을 쓸지 여기서만 정한다.
|
||||||
|
/// 다른 모듈은 서로의 구현을 모른다. 부품을 바꾸려면 이 파일의 해당 줄만 바꾸면 된다.
|
||||||
|
class AndroidBootstrap implements Bootstrap {
|
||||||
|
AndroidBootstrap() : _keys = LocalKeyProvider(AndroidSecureKeyStore());
|
||||||
|
|
||||||
|
final LocalKeyProvider _keys;
|
||||||
|
Timer? _refresh;
|
||||||
|
Timer? _location;
|
||||||
|
Timer? _events;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> hasKeys() => _keys.isSetUp();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> createKeys() => _keys.setUpNew();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> restoreKeys(String text) async {
|
||||||
|
final tokens = text.split(RegExp(r'\s+')).where(RecoveryShares.looksLikeShare).toList();
|
||||||
|
if (tokens.isEmpty) return _keys.restore(text);
|
||||||
|
final phrase = await RecoveryShares.combine(tokens);
|
||||||
|
return phrase != null && await _keys.restore(phrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> splitRecovery(int n, int k) async {
|
||||||
|
final phrase = await _keys.showPhrase();
|
||||||
|
if (phrase == null) throw StateError('복구 코드가 없습니다');
|
||||||
|
return RecoveryShares.split(phrase, n: n, k: k);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> recoveryPhrase() => _keys.showPhrase();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FriendlyApp> openApp() async {
|
||||||
|
final storage = await openEncryptedStorage(_keys);
|
||||||
|
final identityKeys = IdentityKeys(_keys.derive);
|
||||||
|
final lan = LanDirectory(AndroidLanDiscovery());
|
||||||
|
|
||||||
|
// 내 전화번호는 내 정보를 정한 뒤에야 알 수 있어서, 연결은 앱이 시작될 때 만든다.
|
||||||
|
final transport = LateTransport(() async {
|
||||||
|
final me = await storage.settings.loadProfile();
|
||||||
|
final url = (await storage.settings.getText(mailboxUrlKey))?.trim() ?? '';
|
||||||
|
return CompositeTransport([
|
||||||
|
DirectTransport(
|
||||||
|
port: lanPort,
|
||||||
|
resolve: (phone) async {
|
||||||
|
final p = lan.find(phone);
|
||||||
|
return p == null ? null : DirectAddress(p.host, p.port);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (me != null && url.isNotEmpty) MailboxTransport(baseUrl: url, myPhone: me.phone, token: await _keys.mailboxToken()),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
final app = FriendlyApp(AppDeps(
|
||||||
|
storage: storage,
|
||||||
|
transport: transport,
|
||||||
|
timeSource: transport,
|
||||||
|
cipher: SharedPayloadCipher(identityKeys),
|
||||||
|
identity: LocalIdentityProvider(identityKeys),
|
||||||
|
probe: AndroidStorageProbe(),
|
||||||
|
lan: lan,
|
||||||
|
location: AndroidLocationProvider(),
|
||||||
|
callLog: AndroidCallLog(),
|
||||||
|
phone: AndroidPhoneActions(),
|
||||||
|
notifier: AndroidNotifier(),
|
||||||
|
verifier: LatePhoneVerifier(() async {
|
||||||
|
final me = await storage.settings.loadProfile();
|
||||||
|
final url = (await storage.settings.getText(mailboxUrlKey))?.trim() ?? '';
|
||||||
|
if (me == null || url.isEmpty) return null;
|
||||||
|
return MailboxVerifier(baseUrl: url, myPhone: me.phone, token: await _keys.mailboxToken());
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
..lanPort = lanPort;
|
||||||
|
|
||||||
|
// 보관함에 쌓인 봉투를 주기적으로 가져온다.
|
||||||
|
_refresh?.cancel();
|
||||||
|
_refresh = Timer.periodic(const Duration(seconds: 30), (_) => app.refresh().catchError((Object _) {}));
|
||||||
|
_location?.cancel();
|
||||||
|
_location = Timer.periodic(const Duration(minutes: 1), (_) => app.location.shareNow().catchError((Object _) => 0));
|
||||||
|
_events?.cancel();
|
||||||
|
_events = Timer.periodic(const Duration(hours: 6), (_) => app.events.reschedule().catchError((Object _) => 0));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:friendly_map/composition/android_bootstrap.dart';
|
||||||
|
import 'package:friendly_map/modules/platform_android/platform_android.dart';
|
||||||
|
import 'package:friendly_map/modules/ui/ui.dart';
|
||||||
|
|
||||||
|
/// 앱 시작점: 안드로이드 부품을 골라 화면에 넘겨 준다. 일은 하지 않는다.
|
||||||
|
void main() {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
runApp(FriendlyMapApp(
|
||||||
|
bootstrap: AndroidBootstrap(),
|
||||||
|
qr: AndroidQrScannerFactory(),
|
||||||
|
motion: AndroidMotionSensor(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/// 앱 내부: 화면이 쓰는 서비스 묶음 (친구·명함·대화·인맥·설정·배치)
|
||||||
|
///
|
||||||
|
/// 화면(ui)은 이 모듈만 통해 내부를 쓴다. 저장소·전송·암호는 AppDeps 로 조립하는 쪽에서 끼운다.
|
||||||
|
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/app_deps.dart';
|
||||||
|
export 'src/call_service.dart';
|
||||||
|
export 'src/card_service.dart';
|
||||||
|
export 'src/change_bus.dart';
|
||||||
|
export 'src/chat_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/kin_service.dart';
|
||||||
|
export 'src/layout_service.dart' show LayoutService;
|
||||||
|
export 'src/location_service.dart';
|
||||||
|
export 'src/network_service.dart';
|
||||||
|
export 'src/profile_service.dart';
|
||||||
|
export 'src/recovery_service.dart';
|
||||||
|
export 'src/settings_service.dart';
|
||||||
|
export 'src/verify_service.dart';
|
||||||
|
export 'src/visibility_service.dart';
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
import 'package:friendly_map/modules/discovery/discovery.dart';
|
||||||
|
import 'package:friendly_map/modules/platform/platform.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
|
||||||
|
/// 앱이 쓰는 재료 묶음. 어떤 저장소·연결·암호를 쓸지는 조립하는 쪽(main)이 정한다.
|
||||||
|
class AppDeps {
|
||||||
|
AppDeps({
|
||||||
|
required this.storage,
|
||||||
|
required this.transport,
|
||||||
|
required this.cipher,
|
||||||
|
required this.identity,
|
||||||
|
required this.probe,
|
||||||
|
this.timeSource,
|
||||||
|
this.lan,
|
||||||
|
this.location,
|
||||||
|
this.verifier,
|
||||||
|
this.callLog,
|
||||||
|
this.phone,
|
||||||
|
this.notifier,
|
||||||
|
Clock? clock,
|
||||||
|
}) : clock = clock ?? Clock();
|
||||||
|
|
||||||
|
final Storage storage;
|
||||||
|
final Transport transport;
|
||||||
|
final PayloadCipher cipher;
|
||||||
|
final IdentityProvider identity;
|
||||||
|
final DeviceStorageProbe probe;
|
||||||
|
final TimeSource? timeSource;
|
||||||
|
final LanDirectory? lan;
|
||||||
|
final LocationProvider? location;
|
||||||
|
final PhoneVerifier? verifier;
|
||||||
|
final CallLogProvider? callLog;
|
||||||
|
final PhoneActions? phone;
|
||||||
|
final Notifier? notifier;
|
||||||
|
final Clock clock;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import 'package:friendly_map/modules/events/events.dart';
|
||||||
|
import 'package:friendly_map/modules/platform/platform.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
|
||||||
|
import 'facts_service.dart';
|
||||||
|
|
||||||
|
/// 통화 요약과 바로 걸기.
|
||||||
|
class CallService {
|
||||||
|
CallService(this._storage, this._log, this._actions);
|
||||||
|
|
||||||
|
final Storage _storage;
|
||||||
|
final CallLogProvider? _log;
|
||||||
|
final PhoneActions? _actions;
|
||||||
|
|
||||||
|
/// 이 기기에서 통화 기록을 읽을 수 있는 부품이 있는지.
|
||||||
|
bool get canReadLog => _log != null;
|
||||||
|
bool get canCall => _actions != null;
|
||||||
|
|
||||||
|
/// 번호가 없는 친척(기기 안 전용)은 전화·문자를 할 수 없다.
|
||||||
|
Future<bool> reachable(String personId) async {
|
||||||
|
final p = await _storage.people.get(personId);
|
||||||
|
return p != null && !isLocalPhone(p.phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CallSummary> summary(String personId) async {
|
||||||
|
final log = _log;
|
||||||
|
final p = await _storage.people.get(personId);
|
||||||
|
if (log == null || p == null || isLocalPhone(p.phone)) return CallSummary.empty;
|
||||||
|
final entries = await log.forNumber(p.phone);
|
||||||
|
return summarizeCalls([
|
||||||
|
for (final e in entries)
|
||||||
|
CallRecord(e.at, e.seconds, switch (e.kind) {
|
||||||
|
CallKind.incoming => CallDirection.incoming,
|
||||||
|
CallKind.outgoing => CallDirection.outgoing,
|
||||||
|
CallKind.missed => CallDirection.missed,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> call(String personId) async {
|
||||||
|
final p = await _storage.people.get(personId);
|
||||||
|
final a = _actions;
|
||||||
|
if (a == null || p == null || isLocalPhone(p.phone)) return false;
|
||||||
|
return a.dial(p.phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> sms(String personId) async {
|
||||||
|
final p = await _storage.people.get(personId);
|
||||||
|
final a = _actions;
|
||||||
|
if (a == null || p == null || isLocalPhone(p.phone)) return false;
|
||||||
|
return a.sms(p.phone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
import 'package:friendly_map/modules/sharing/sharing.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
import 'package:friendly_map/modules/sync/sync.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
import 'fields.dart';
|
||||||
|
import 'layout_service.dart';
|
||||||
|
|
||||||
|
/// 내 명함: 만들기·고치기·지우기·순서, 공유와 "다시 보내기".
|
||||||
|
class CardService {
|
||||||
|
CardService(this._storage, this._clock, this._share, this._sync, this._layout, this._bus);
|
||||||
|
|
||||||
|
static const _orderKey = 'card_order';
|
||||||
|
final Storage _storage;
|
||||||
|
final Clock _clock;
|
||||||
|
final ShareService _share;
|
||||||
|
final SyncEngine _sync;
|
||||||
|
final LayoutService _layout;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
|
||||||
|
/// 내 명함들 (사용자가 정한 순서대로).
|
||||||
|
Future<List<MyCard>> mine() async {
|
||||||
|
final cards = await _storage.myCards.all();
|
||||||
|
final order = await _order();
|
||||||
|
cards.sort((a, b) {
|
||||||
|
final ia = order.indexOf(a.id), ib = order.indexOf(b.id);
|
||||||
|
if (ia != ib) return (ia < 0 ? 1 << 30 : ia).compareTo(ib < 0 ? 1 << 30 : ib);
|
||||||
|
return a.updatedAt.compareTo(b.updatedAt);
|
||||||
|
});
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> _order() async {
|
||||||
|
final t = await _storage.settings.getText(_orderKey);
|
||||||
|
if (t == null) return [];
|
||||||
|
try {
|
||||||
|
return (jsonDecode(t) as List).cast<String>();
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> move(String cardId, int to) async {
|
||||||
|
final ids = [for (final c in await mine()) c.id];
|
||||||
|
if (!ids.remove(cardId)) return;
|
||||||
|
ids.insert(to.clamp(0, ids.length), cardId);
|
||||||
|
await _storage.settings.putText(_orderKey, jsonEncode(ids), _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 명함 틀로 새 명함을 만든다 (내 이름은 미리 채운다).
|
||||||
|
Future<MyCard?> createFromTemplate(String templateId) async {
|
||||||
|
final t = (await _layout.templates()).where((e) => e.id == templateId).firstOrNull;
|
||||||
|
final me = await _storage.settings.loadProfile();
|
||||||
|
if (t == null) return null;
|
||||||
|
final card = MyCard(
|
||||||
|
id: newId(),
|
||||||
|
kind: t.kind,
|
||||||
|
title: t.title,
|
||||||
|
fieldsJson: encodeFields({
|
||||||
|
for (final f in t.fields) f.key: f.key == CardKeys.name ? (me?.name ?? '') : '',
|
||||||
|
}),
|
||||||
|
templateId: t.id,
|
||||||
|
updatedAt: _clock.now(),
|
||||||
|
);
|
||||||
|
await _storage.myCards.put(card);
|
||||||
|
_bus.notify();
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<MyCard?> edit(String cardId, {String? title, Map<String, String>? fields, bool? shareDefault}) async {
|
||||||
|
final c = await _storage.myCards.get(cardId);
|
||||||
|
if (c == null) return null;
|
||||||
|
final next = MyCard(
|
||||||
|
id: c.id,
|
||||||
|
kind: c.kind,
|
||||||
|
title: title ?? c.title,
|
||||||
|
fieldsJson: fields == null ? c.fieldsJson : encodeFields(fields),
|
||||||
|
templateId: c.templateId,
|
||||||
|
shareDefault: shareDefault ?? c.shareDefault,
|
||||||
|
// 같은 1/100초 안에 다시 고쳐도 이전 공유보다 늦은 시각이 되게 한다 (다시 보내기 판단 근거).
|
||||||
|
updatedAt: _clock.now() > c.updatedAt ? _clock.now() : c.updatedAt + 1,
|
||||||
|
);
|
||||||
|
await _storage.myCards.put(next);
|
||||||
|
_bus.notify();
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 명함을 지운다. 이미 상대에게 간 사본은 남고, 이후 공유는 끝난다.
|
||||||
|
Future<void> delete(String cardId) async {
|
||||||
|
for (final s in await _storage.shares.activeFor(cardId)) {
|
||||||
|
await _share.stop(s.id);
|
||||||
|
}
|
||||||
|
await _storage.myCards.remove(cardId);
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 선택한 명함들을 상대 한 명에게 공유하고 바로 보낸다. 처음 보낸 개수를 돌려준다.
|
||||||
|
Future<int> shareWith(String personId, List<String> cardIds) async {
|
||||||
|
var count = 0;
|
||||||
|
for (final id in cardIds) {
|
||||||
|
final already = (await _storage.shares.activeFor(id)).any((s) => s.peerId == personId);
|
||||||
|
final record = await _share.shareCard(id, personId);
|
||||||
|
if (record == null || already) continue;
|
||||||
|
await _sync.sendCard(record);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
_bus.notify();
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 공유를 끝낸다 (이미 간 사본은 상대에게 남는다).
|
||||||
|
Future<void> stopSharing(String shareId) async {
|
||||||
|
await _share.stop(shareId);
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이 명함을 지금 공유 중인 곳들.
|
||||||
|
Future<List<ShareRecord>> sharesOf(String cardId) => _storage.shares.activeFor(cardId);
|
||||||
|
|
||||||
|
/// 고쳐서 다시 보내야 하는 공유들.
|
||||||
|
Future<List<ShareRecord>> needingResend() => _share.needingResend();
|
||||||
|
|
||||||
|
/// 사용자가 "다시 보내기"를 눌렀을 때만 고친 내용이 상대에게 간다.
|
||||||
|
Future<bool> resend(String shareId) async {
|
||||||
|
final updated = await _share.resend(shareId);
|
||||||
|
if (updated == null) return false;
|
||||||
|
await _sync.sendCard(updated);
|
||||||
|
_bus.notify();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 공유 상태 (공유 안 함 / 최신 / 다시 보내기 필요).
|
||||||
|
Future<ShareStatus> statusOf(ShareRecord share) async {
|
||||||
|
final card = await _storage.myCards.get(share.targetId);
|
||||||
|
return shareStatusOf(share, card?.updatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// "무언가 바뀌었다"를 화면에 알리는 통로. 화면은 이것을 듣고 다시 읽는다.
|
||||||
|
class ChangeBus {
|
||||||
|
final StreamController<void> _ctl = StreamController<void>.broadcast();
|
||||||
|
|
||||||
|
Stream<void> get changes => _ctl.stream;
|
||||||
|
|
||||||
|
void notify() {
|
||||||
|
if (!_ctl.isClosed) _ctl.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() => _ctl.close();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:friendly_map/modules/sharing/sharing.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
import 'package:friendly_map/modules/sync/sync.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
|
||||||
|
/// 채팅 목록 한 줄.
|
||||||
|
class RoomSummary {
|
||||||
|
const RoomSummary(this.room, this.last);
|
||||||
|
final ChatRoom room;
|
||||||
|
final Message? last;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 대화: 방 열기(맥락 규칙), 보내기, 읽기.
|
||||||
|
class ChatService {
|
||||||
|
ChatService(this._storage, this._clock, this._share, this._sync, this._bus);
|
||||||
|
|
||||||
|
static const me = 'me'; // 내가 보낸 대화의 보낸 사람 표시
|
||||||
|
final Storage _storage;
|
||||||
|
final Clock _clock;
|
||||||
|
final ShareService _share;
|
||||||
|
final SyncEngine _sync;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
|
||||||
|
Future<List<RoomSummary>> summaries() async {
|
||||||
|
final out = <RoomSummary>[];
|
||||||
|
for (final r in await _storage.rooms.recent()) {
|
||||||
|
final page = await _storage.messages.page(r.id, limit: 1);
|
||||||
|
out.add(RoomSummary(r, page.isEmpty ? null : page.last));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Message>> messages(String roomId, {int? before, int limit = 50}) =>
|
||||||
|
_storage.messages.page(roomId, before: before, limit: limit);
|
||||||
|
|
||||||
|
/// 이 상대와 [contextKind] 맥락의 1:1 방을 연다 (없으면 만든다).
|
||||||
|
/// 개인 대화는 명함 없이 되고, 그 밖의 맥락은 그 종류의 명함을 이 상대에게 공유해야 한다. 안 되면 null.
|
||||||
|
Future<ChatRoom?> openDirect(String personId, String contextKind) async {
|
||||||
|
final peer = await _storage.people.get(personId);
|
||||||
|
if (peer == null) return null;
|
||||||
|
for (final r in await _storage.rooms.all()) {
|
||||||
|
if (r.kind == 'direct' && r.contextKind == contextKind && r.membersJson == jsonEncode([personId])) return r;
|
||||||
|
}
|
||||||
|
if (!await _share.canStartContextRoom(contextKind, personId)) return null;
|
||||||
|
return _create(peer.name, 'direct', contextKind, [personId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 여러 명이 있는 방을 만든다. 모두가 그 맥락 조건을 채워야 한다. 안 되면 null.
|
||||||
|
Future<ChatRoom?> createGroup(String title, List<String> personIds, String contextKind) async {
|
||||||
|
if (personIds.length < 2 || title.trim().isEmpty) return null;
|
||||||
|
for (final id in personIds) {
|
||||||
|
if (!await _share.canStartContextRoom(contextKind, id)) return null;
|
||||||
|
}
|
||||||
|
return _create(title.trim(), 'group', contextKind, personIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ChatRoom> _create(String title, String kind, String ctx, List<String> ids) async {
|
||||||
|
final room = ChatRoom(
|
||||||
|
id: newId(),
|
||||||
|
title: title,
|
||||||
|
kind: kind,
|
||||||
|
contextKind: ctx,
|
||||||
|
membersJson: jsonEncode(ids),
|
||||||
|
updatedAt: _clock.now(),
|
||||||
|
);
|
||||||
|
await _storage.rooms.put(room);
|
||||||
|
for (final id in ids) {
|
||||||
|
final share = await _share.shareRoom(room.id, id);
|
||||||
|
if (share != null) await _sync.sendRoom(share);
|
||||||
|
}
|
||||||
|
_bus.notify();
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 대화를 저장하고 방을 공유 중인 모든 상대에게 보낸다.
|
||||||
|
Future<Message?> send(String roomId, String text) async {
|
||||||
|
final room = await _storage.rooms.get(roomId);
|
||||||
|
final body = text.trim();
|
||||||
|
if (room == null || body.isEmpty) return null;
|
||||||
|
var now = _clock.now();
|
||||||
|
// 내가 이미 본 마지막 대화보다 뒤에 오도록 한다 (같은 1/100초에 오간 대화의 순서가 뒤바뀌지 않게).
|
||||||
|
final last = await _storage.messages.page(roomId, limit: 1);
|
||||||
|
if (last.isNotEmpty && last.last.sentAt >= now) now = last.last.sentAt + 1;
|
||||||
|
final m = Message(id: newId(), roomId: roomId, senderId: me, body: body, sentAt: now, updatedAt: now);
|
||||||
|
await _storage.messages.put(m);
|
||||||
|
await _storage.rooms.put(ChatRoom(
|
||||||
|
id: room.id,
|
||||||
|
title: room.title,
|
||||||
|
kind: room.kind,
|
||||||
|
contextKind: room.contextKind,
|
||||||
|
membersJson: room.membersJson,
|
||||||
|
archived: room.archived,
|
||||||
|
updatedAt: now,
|
||||||
|
));
|
||||||
|
await _sync.sendMessage(m);
|
||||||
|
_bus.notify();
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import 'package:friendly_map/modules/events/events.dart';
|
||||||
|
import 'package:friendly_map/modules/platform/platform.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
import 'facts_service.dart';
|
||||||
|
|
||||||
|
/// 다가오는 일정 한 건 (사람 이름 포함).
|
||||||
|
class EventView {
|
||||||
|
const EventView(this.name, this.upcoming);
|
||||||
|
final String name;
|
||||||
|
final UpcomingEvent upcoming;
|
||||||
|
String get text => upcoming.event.label(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 생일·기일 일정과 알림 설정.
|
||||||
|
class EventService {
|
||||||
|
EventService(this._storage, this._clock, this._facts, this._bus, this._notifier);
|
||||||
|
|
||||||
|
static const _leadKey = 'event.lead_days';
|
||||||
|
static const _showKey = 'event.show_deceased';
|
||||||
|
static const _defaultLead = [1, 0];
|
||||||
|
|
||||||
|
final Storage _storage;
|
||||||
|
final Clock _clock;
|
||||||
|
final FactsService _facts;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
final Notifier? _notifier;
|
||||||
|
|
||||||
|
bool get canNotify => _notifier != null;
|
||||||
|
|
||||||
|
/// 미리 알릴 날들 (큰 수부터). 설정에 적은 "1, 2" 처럼 쓴다. 0 은 당일.
|
||||||
|
Future<List<int>> leadDays() async {
|
||||||
|
final raw = await _storage.settings.getText(_leadKey);
|
||||||
|
return raw == null ? _defaultLead : parseLeadDays(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setLeadDays(String text) async {
|
||||||
|
await _storage.settings.putText(_leadKey, leadDaysText(parseLeadDays(text)), _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
await reschedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 돌아가신 분 표시를 보일지 (기본 보임).
|
||||||
|
Future<bool> showDeceased() async => (await _storage.settings.getText(_showKey)) != 'false';
|
||||||
|
|
||||||
|
Future<void> setShowDeceased(bool on) async {
|
||||||
|
await _storage.settings.putText(_showKey, on ? 'true' : 'false', _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _today() {
|
||||||
|
final n = DateTime.fromMillisecondsSinceEpoch(_clock.now() * 10);
|
||||||
|
return DateTime(n.year, n.month, n.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FamilyEvent> _eventsOf(String id, PersonFacts f) => [
|
||||||
|
if (f.hasBirthday && !f.deceased)
|
||||||
|
FamilyEvent(personId: id, kind: EventKind.birthday, month: f.birthMonth!, day: f.birthDay!, year: f.birthYear, lunar: f.birthLunar),
|
||||||
|
if (f.deceased)
|
||||||
|
FamilyEvent(personId: id, kind: EventKind.memorial, month: f.deceasedMonth!, day: f.deceasedDay!, year: f.deceasedYear, lunar: f.deceasedLunar, leap: f.deceasedLunar && f.deceasedLeap),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 이 사람의 일정들(생일 또는 기일).
|
||||||
|
Future<List<EventView>> forPerson(String personId, {int withinDays = 366}) async {
|
||||||
|
final p = await _storage.people.get(personId);
|
||||||
|
if (p == null) return const [];
|
||||||
|
final list = upcoming(_eventsOf(personId, await _facts.get(personId)), _today(), withinDays: withinDays);
|
||||||
|
return [for (final u in list) EventView(p.name, u)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 모든 사람의 가까운 일정.
|
||||||
|
Future<List<EventView>> soon({int withinDays = 30}) async {
|
||||||
|
final out = <EventView>[];
|
||||||
|
for (final p in await _storage.people.all()) {
|
||||||
|
final f = await _facts.get(p.id);
|
||||||
|
for (final u in upcoming(_eventsOf(p.id, f), _today(), withinDays: withinDays)) {
|
||||||
|
out.add(EventView(p.name, u));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort((a, b) => a.upcoming.daysLeft.compareTo(b.upcoming.daysLeft));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 알림 예약을 현재 설정과 일정으로 새로 건다. 예약한 개수를 돌려준다.
|
||||||
|
Future<int> reschedule() async {
|
||||||
|
final n = _notifier;
|
||||||
|
if (n == null) return 0;
|
||||||
|
final people = {for (final p in await _storage.people.all()) p.id: p.name};
|
||||||
|
final events = <FamilyEvent>[];
|
||||||
|
for (final id in people.keys) {
|
||||||
|
events.addAll(_eventsOf(id, await _facts.get(id)));
|
||||||
|
}
|
||||||
|
final now = DateTime.fromMillisecondsSinceEpoch(_clock.now() * 10);
|
||||||
|
final plans = planReminders(events, now, await leadDays(), (id) => people[id] ?? '');
|
||||||
|
await n.replaceAll([for (final p in plans) ScheduledNotice(p.key, p.fireAt, p.text)]);
|
||||||
|
return plans.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
|
||||||
|
/// 번호가 없는 친척에게 붙이는 가짜 번호 표지 (전화·메시지는 하지 않는다).
|
||||||
|
const localPhonePrefix = 'local:';
|
||||||
|
bool isLocalPhone(String phone) => phone.startsWith(localPhonePrefix);
|
||||||
|
|
||||||
|
/// 한 사람에 대해 내가 적어 둔 사실: 생일, 돌아가신 날, 통화 주제, 메모.
|
||||||
|
class PersonFacts {
|
||||||
|
const PersonFacts({
|
||||||
|
this.birthYear,
|
||||||
|
this.birthMonth,
|
||||||
|
this.birthDay,
|
||||||
|
this.deceasedYear,
|
||||||
|
this.deceasedMonth,
|
||||||
|
this.deceasedDay,
|
||||||
|
this.birthLunar = false,
|
||||||
|
this.deceasedLunar = false,
|
||||||
|
this.deceasedLeap = false,
|
||||||
|
this.topics = const [],
|
||||||
|
this.memo = '',
|
||||||
|
});
|
||||||
|
static const empty = PersonFacts();
|
||||||
|
|
||||||
|
final int? birthYear, birthMonth, birthDay;
|
||||||
|
final int? deceasedYear, deceasedMonth, deceasedDay;
|
||||||
|
|
||||||
|
/// true 면 생일/기일 월·일이 음력.
|
||||||
|
final bool birthLunar, deceasedLunar;
|
||||||
|
|
||||||
|
/// 기일이 음력 윤달일 때.
|
||||||
|
final bool deceasedLeap;
|
||||||
|
|
||||||
|
/// 주로 어떤 일로 통화했는지 (직접 적는다. 통화 기록에는 내용이 없다).
|
||||||
|
final List<String> topics;
|
||||||
|
final String memo;
|
||||||
|
|
||||||
|
bool get hasBirthday => birthMonth != null && birthDay != null;
|
||||||
|
bool get deceased => deceasedMonth != null && deceasedDay != null;
|
||||||
|
|
||||||
|
/// 돌아가신 날 글자 (연도를 모르면 월/일만).
|
||||||
|
String get deceasedText => !deceased
|
||||||
|
? ''
|
||||||
|
: '${deceasedLunar ? (deceasedLeap ? '음력 윤' : '음력 ') : ''}${deceasedYear == null ? '' : '$deceasedYear.'}$deceasedMonth.$deceasedDay';
|
||||||
|
|
||||||
|
PersonFacts copyWith({
|
||||||
|
Object? birthYear = _keep,
|
||||||
|
Object? birthMonth = _keep,
|
||||||
|
Object? birthDay = _keep,
|
||||||
|
Object? deceasedYear = _keep,
|
||||||
|
Object? deceasedMonth = _keep,
|
||||||
|
Object? deceasedDay = _keep,
|
||||||
|
bool? birthLunar,
|
||||||
|
bool? deceasedLunar,
|
||||||
|
bool? deceasedLeap,
|
||||||
|
List<String>? topics,
|
||||||
|
String? memo,
|
||||||
|
}) =>
|
||||||
|
PersonFacts(
|
||||||
|
birthYear: identical(birthYear, _keep) ? this.birthYear : birthYear as int?,
|
||||||
|
birthMonth: identical(birthMonth, _keep) ? this.birthMonth : birthMonth as int?,
|
||||||
|
birthDay: identical(birthDay, _keep) ? this.birthDay : birthDay as int?,
|
||||||
|
deceasedYear: identical(deceasedYear, _keep) ? this.deceasedYear : deceasedYear as int?,
|
||||||
|
deceasedMonth: identical(deceasedMonth, _keep) ? this.deceasedMonth : deceasedMonth as int?,
|
||||||
|
deceasedDay: identical(deceasedDay, _keep) ? this.deceasedDay : deceasedDay as int?,
|
||||||
|
birthLunar: birthLunar ?? this.birthLunar,
|
||||||
|
deceasedLunar: deceasedLunar ?? this.deceasedLunar,
|
||||||
|
deceasedLeap: deceasedLeap ?? this.deceasedLeap,
|
||||||
|
topics: topics ?? this.topics,
|
||||||
|
memo: memo ?? this.memo,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const Object _keep = Object();
|
||||||
|
|
||||||
|
String toJson() => jsonEncode({
|
||||||
|
if (birthYear != null) 'by': birthYear,
|
||||||
|
if (birthMonth != null) 'bm': birthMonth,
|
||||||
|
if (birthDay != null) 'bd': birthDay,
|
||||||
|
if (deceasedYear != null) 'dy': deceasedYear,
|
||||||
|
if (deceasedMonth != null) 'dm': deceasedMonth,
|
||||||
|
if (deceasedDay != null) 'dd': deceasedDay,
|
||||||
|
if (birthLunar) 'bl': true,
|
||||||
|
if (deceasedLunar) 'dl': true,
|
||||||
|
if (deceasedLeap) 'dp': true,
|
||||||
|
if (topics.isNotEmpty) 't': topics,
|
||||||
|
if (memo.isNotEmpty) 'm': memo,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PersonFacts.fromJson(String? text) {
|
||||||
|
if (text == null || text.isEmpty) return empty;
|
||||||
|
try {
|
||||||
|
final m = (jsonDecode(text) as Map).cast<String, Object?>();
|
||||||
|
return PersonFacts(
|
||||||
|
birthYear: m['by'] as int?,
|
||||||
|
birthMonth: m['bm'] as int?,
|
||||||
|
birthDay: m['bd'] as int?,
|
||||||
|
deceasedYear: m['dy'] as int?,
|
||||||
|
deceasedMonth: m['dm'] as int?,
|
||||||
|
deceasedDay: m['dd'] as int?,
|
||||||
|
birthLunar: m['bl'] == true,
|
||||||
|
deceasedLunar: m['dl'] == true,
|
||||||
|
deceasedLeap: m['dp'] == true,
|
||||||
|
topics: [for (final t in (m['t'] as List? ?? const [])) t as String],
|
||||||
|
memo: m['m'] as String? ?? '',
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 사람별 사실(생일·돌아가신 날·주제·메모) 저장. 기기 안에만 있고 보내지 않는다.
|
||||||
|
class FactsService {
|
||||||
|
FactsService(this._storage, this._clock, this._bus);
|
||||||
|
|
||||||
|
final Storage _storage;
|
||||||
|
final Clock _clock;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
|
||||||
|
String _key(String id) => 'facts.$id';
|
||||||
|
|
||||||
|
Future<PersonFacts> get(String personId) async =>
|
||||||
|
PersonFacts.fromJson(await _storage.settings.getText(_key(personId)));
|
||||||
|
|
||||||
|
Future<void> save(String personId, PersonFacts f) async {
|
||||||
|
final ok = _valid(f.birthMonth, f.birthDay, f.birthLunar) && _valid(f.deceasedMonth, f.deceasedDay, f.deceasedLunar);
|
||||||
|
if (!ok) throw ArgumentError('날짜가 올바르지 않아요');
|
||||||
|
await _storage.settings.putText(_key(personId), f.toJson(), _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 돌아가신 날 기록/해제. [month] 가 null 이면 해제.
|
||||||
|
Future<void> setDeceased(String personId, {int? year, int? month, int? day, bool lunar = false, bool leap = false}) async {
|
||||||
|
final cur = await get(personId);
|
||||||
|
await save(personId, cur.copyWith(deceasedYear: year, deceasedMonth: month, deceasedDay: day, deceasedLunar: lunar, deceasedLeap: lunar && leap));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 내 사람들 모두의 사실.
|
||||||
|
Future<Map<String, PersonFacts>> all() async => {
|
||||||
|
for (final p in await _storage.people.all()) p.id: await get(p.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
static bool _valid(int? m, int? d, bool lunar) {
|
||||||
|
if ((m == null) != (d == null)) return false;
|
||||||
|
if (m == null) return true;
|
||||||
|
if (m < 1 || m > 12 || d! < 1) return false;
|
||||||
|
if (lunar) return d <= 30;
|
||||||
|
const days = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||||
|
return d <= days[m - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// 명함 항목(JSON 글자) ↔ 이름-값 지도.
|
||||||
|
Map<String, String> decodeFields(String json) {
|
||||||
|
try {
|
||||||
|
final m = jsonDecode(json) as Map;
|
||||||
|
return {for (final e in m.entries) e.key as String: '${e.value}'};
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String encodeFields(Map<String, String> fields) => jsonEncode(fields);
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
import 'package:friendly_map/modules/discovery/discovery.dart';
|
||||||
|
import 'package:friendly_map/modules/sharing/sharing.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
import 'package:friendly_map/modules/sync/sync.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
|
||||||
|
/// 새 친구 확인 결과. 번호가 올바르지 않으면 [error] 가 채워진다.
|
||||||
|
class NewFriendCheck {
|
||||||
|
const NewFriendCheck.ok(this.phone, this.match) : error = null;
|
||||||
|
const NewFriendCheck.fail(this.error)
|
||||||
|
: phone = null,
|
||||||
|
match = null;
|
||||||
|
|
||||||
|
final String? phone; // 표준 형태 번호
|
||||||
|
final PersonMatch? match; // 이미 아는 사람과 겹치는지
|
||||||
|
final String? error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 같은 네트워크에서 발견한 사람 한 명.
|
||||||
|
class NearbyPerson {
|
||||||
|
const NearbyPerson(this.phone, this.known);
|
||||||
|
final String phone;
|
||||||
|
final Person? known; // 이미 친구면 그 사람
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 설정 저장소에서 "모든 정보 숨기기"를 담는 이름.
|
||||||
|
const hideAllKey = 'hide_all';
|
||||||
|
|
||||||
|
/// 친구 추가, 요청 받기·수락, 명함 교환의 시작.
|
||||||
|
class FriendService {
|
||||||
|
FriendService(this._storage, this._clock, this._sync, this._share, this._identity, this._bus, [this._lan]);
|
||||||
|
|
||||||
|
final Storage _storage;
|
||||||
|
final Clock _clock;
|
||||||
|
final SyncEngine _sync;
|
||||||
|
final ShareService _share;
|
||||||
|
final IdentityProvider _identity;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
final LanDirectory? _lan;
|
||||||
|
|
||||||
|
/// 같은 네트워크에서 지금 보이는 사람들 (나 자신 제외).
|
||||||
|
Future<List<NearbyPerson>> nearby() async {
|
||||||
|
final lan = _lan;
|
||||||
|
if (lan == null) return const [];
|
||||||
|
final me = (await _storage.settings.loadProfile())?.phone;
|
||||||
|
return [
|
||||||
|
for (final p in lan.all)
|
||||||
|
if (p.phone != me) NearbyPerson(p.phone, await _storage.people.byPhone(p.phone)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 근처에서 발견한 사람을 번호만으로 추가한다 (이름은 상대가 답하면 자동으로 채워진다).
|
||||||
|
Future<Person?> addNearby(String phone) => add(phone, phone);
|
||||||
|
|
||||||
|
Future<List<Person>> people() async {
|
||||||
|
final list = await _storage.people.all();
|
||||||
|
list.sort((a, b) => a.name.compareTo(b.name));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Person?> person(String id) => _storage.people.get(id);
|
||||||
|
|
||||||
|
Future<List<FriendRequest>> requests() => _storage.friendRequests.pending();
|
||||||
|
|
||||||
|
/// 1단계: 번호·이름을 확인하고, 이미 아는 사람과 겹치는지 알려 준다. 같은 사람인지는 사용자가 고른다.
|
||||||
|
Future<NewFriendCheck> check(String rawPhone, String name) async {
|
||||||
|
final phone = normalizePhone(rawPhone);
|
||||||
|
if (phone == null) return const NewFriendCheck.fail('전화번호를 확인해 주세요');
|
||||||
|
if (name.trim().isEmpty) return const NewFriendCheck.fail('이름을 입력해 주세요');
|
||||||
|
if ((await _storage.settings.loadProfile())?.phone == phone) {
|
||||||
|
return const NewFriendCheck.fail('내 번호예요');
|
||||||
|
}
|
||||||
|
return NewFriendCheck.ok(phone, await _storage.people.match(phone, name.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 2단계: 등록하고 인사를 보낸다. [samePersonId] 를 주면 그 사람의 번호·이름이 바뀐 것으로 본다.
|
||||||
|
/// [publicKey] 를 알면(QR) 넘긴다.
|
||||||
|
Future<Person?> add(String phone, String name, {String? samePersonId, String? publicKey}) async {
|
||||||
|
final now = _clock.now();
|
||||||
|
Person? p;
|
||||||
|
if (samePersonId != null) {
|
||||||
|
p = await _storage.people.get(samePersonId);
|
||||||
|
if (p == null) return null;
|
||||||
|
if (p.phone != phone && !await _storage.people.changePhone(p.id, phone, now)) return null;
|
||||||
|
if (p.name != name.trim()) await _storage.people.changeName(p.id, name.trim(), now);
|
||||||
|
} else {
|
||||||
|
p = await _storage.people.register(phone, name.trim(), now) ?? await _storage.people.byPhone(phone);
|
||||||
|
}
|
||||||
|
if (p == null) return null;
|
||||||
|
if (publicKey != null) await _storage.people.setPublicKey(p.id, publicKey, now);
|
||||||
|
await _sync.sendHello(phone);
|
||||||
|
_bus.notify();
|
||||||
|
return _storage.people.get(p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// QR 로 읽은 글자로 친구를 추가한다. 우리 앱 QR 이 아니면 null.
|
||||||
|
Future<Person?> addFromQr(String text) async {
|
||||||
|
final q = QrPayload.decode(text);
|
||||||
|
if (q == null) return null;
|
||||||
|
final check = await this.check(q.phone, q.name);
|
||||||
|
if (check.error != null) return null;
|
||||||
|
final m = check.match!;
|
||||||
|
final same = m.kind == MatchKind.same ? m.candidates.first.id : null;
|
||||||
|
return add(q.phone, q.name, samePersonId: same, publicKey: q.publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 내 QR 에 담을 내용.
|
||||||
|
Future<QrPayload?> myQr() async {
|
||||||
|
final me = await _storage.settings.loadProfile();
|
||||||
|
if (me == null) return null;
|
||||||
|
return QrPayload(phone: me.phone, name: me.name, publicKey: await _identity.myPublicKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 받은 친구 요청을 수락한다. 이미 아는 사람이면 [samePersonId] 로 그 사람에 붙인다.
|
||||||
|
Future<Person?> accept(String phone, {String? samePersonId}) async {
|
||||||
|
final req = await _storage.friendRequests.get(phone);
|
||||||
|
if (req == null) return null;
|
||||||
|
final now = _clock.now();
|
||||||
|
Person? p = samePersonId == null ? await _storage.people.byPhone(phone) : await _storage.people.get(samePersonId);
|
||||||
|
if (p == null) {
|
||||||
|
p = await _storage.people.register(phone, req.name, now, publicKey: req.publicKey);
|
||||||
|
} else {
|
||||||
|
if (p.phone != phone) await _storage.people.changePhone(p.id, phone, now);
|
||||||
|
await _storage.people.setPublicKey(p.id, req.publicKey, now);
|
||||||
|
}
|
||||||
|
if (p == null) return null;
|
||||||
|
await _storage.friendRequests.put(req.withStatus('accepted', now));
|
||||||
|
await _sync.sendHello(phone, reply: true);
|
||||||
|
await _afterKeyKnown(p.id);
|
||||||
|
_bus.notify();
|
||||||
|
return _storage.people.get(p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> decline(String phone) async {
|
||||||
|
final req = await _storage.friendRequests.get(phone);
|
||||||
|
if (req == null) return;
|
||||||
|
await _storage.friendRequests.put(req.withStatus('declined', _clock.now()));
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 동기화가 알려 주는 인사를 처리한다 (조립하는 쪽이 이벤트에 연결).
|
||||||
|
Future<void> onHello(SyncEvent e) async {
|
||||||
|
final phone = e.phone, key = e.publicKey, name = e.name;
|
||||||
|
if (phone == null || key == null || name == null) return;
|
||||||
|
final known = await _storage.people.byPhone(phone);
|
||||||
|
// "모든 정보 숨기기"를 켜 두면 모르는 사람의 요청은 받지 않는다.
|
||||||
|
if (known == null && await _storage.settings.getText(hideAllKey) == 'true') return;
|
||||||
|
if (e.isReply) {
|
||||||
|
if (known == null) return;
|
||||||
|
// 근처에서 번호만 보고 추가했던 사람은 이름이 번호로 되어 있으니 상대가 알려 준 이름으로 바꾼다.
|
||||||
|
if (known.name == known.phone && name != known.name) {
|
||||||
|
await _storage.people.changeName(known.id, name, _clock.now());
|
||||||
|
}
|
||||||
|
await _storage.people.setPublicKey(known.id, key, _clock.now());
|
||||||
|
await _afterKeyKnown(known.id);
|
||||||
|
} else if (known != null && (known.publicKey == null || known.publicKey == key)) {
|
||||||
|
// 내가 먼저 추가했던 상대가 인사를 보내옴 → 서로 알게 됨
|
||||||
|
await _storage.people.setPublicKey(known.id, key, _clock.now());
|
||||||
|
await _sync.sendHello(phone, reply: true);
|
||||||
|
await _afterKeyKnown(known.id);
|
||||||
|
} else {
|
||||||
|
await _storage.friendRequests
|
||||||
|
.put(FriendRequest(id: phone, name: name, publicKey: key, receivedAt: _clock.now(), updatedAt: _clock.now()));
|
||||||
|
if ((await _storage.settings.load()).exchangeMode == 'auto') await accept(phone);
|
||||||
|
}
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 서로 키를 알게 된 뒤: 보류된 봉투를 처리하고, 기본 공유 명함을 보낸다.
|
||||||
|
Future<void> _afterKeyKnown(String personId) async {
|
||||||
|
await _sync.retryHeld();
|
||||||
|
for (final c in await _storage.myCards.all()) {
|
||||||
|
if (c.shareDefault) await _share.shareCard(c.id, personId);
|
||||||
|
}
|
||||||
|
// 키를 알기 전에 정해 둔 공유(명함·대화방)도 이제 보낼 수 있다.
|
||||||
|
for (final s in await _storage.shares.activeOf('card')) {
|
||||||
|
if (s.peerId == personId) await _sync.sendCard(s);
|
||||||
|
}
|
||||||
|
for (final s in await _storage.shares.activeOf('room')) {
|
||||||
|
if (s.peerId == personId) await _sync.sendRoom(s);
|
||||||
|
}
|
||||||
|
await _sync.flushOutbox();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:friendly_map/modules/sharing/sharing.dart';
|
||||||
|
import 'package:friendly_map/modules/sync/sync.dart';
|
||||||
|
|
||||||
|
import 'app_deps.dart';
|
||||||
|
import 'card_service.dart';
|
||||||
|
import 'change_bus.dart';
|
||||||
|
import 'call_service.dart';
|
||||||
|
import 'chat_service.dart';
|
||||||
|
import 'event_service.dart';
|
||||||
|
import 'facts_service.dart';
|
||||||
|
import 'friend_service.dart';
|
||||||
|
import 'kin_service.dart';
|
||||||
|
import 'layout_service.dart';
|
||||||
|
import 'location_service.dart';
|
||||||
|
import 'network_service.dart';
|
||||||
|
import 'profile_service.dart';
|
||||||
|
import 'recovery_service.dart';
|
||||||
|
import 'settings_service.dart';
|
||||||
|
import 'verify_service.dart';
|
||||||
|
import 'visibility_service.dart';
|
||||||
|
|
||||||
|
/// 앱 내부의 입구. 화면은 이 객체의 서비스들만 쓴다 (저장소·전송·암호를 직접 만지지 않는다).
|
||||||
|
///
|
||||||
|
/// 서비스는 서로를 모르고 이 객체가 재료를 나눠 준다. 서비스 하나를 빼거나 바꿔도 다른 서비스는 그대로다.
|
||||||
|
class FriendlyApp {
|
||||||
|
FriendlyApp(AppDeps deps)
|
||||||
|
: deps = deps,
|
||||||
|
bus = ChangeBus() {
|
||||||
|
final storage = deps.storage;
|
||||||
|
final clock = deps.clock;
|
||||||
|
sync = SyncEngine(
|
||||||
|
storage: storage,
|
||||||
|
transport: deps.transport,
|
||||||
|
cipher: deps.cipher,
|
||||||
|
identity: deps.identity,
|
||||||
|
clock: clock,
|
||||||
|
timeSource: deps.timeSource,
|
||||||
|
);
|
||||||
|
visibility = VisibilityService(storage, clock, bus);
|
||||||
|
final share = ShareService(storage, clock, filter: visibility.contentFor);
|
||||||
|
layout = LayoutService(storage, clock, bus);
|
||||||
|
profile = ProfileService(storage, clock, layout, bus);
|
||||||
|
cards = CardService(storage, clock, share, sync, layout, bus);
|
||||||
|
friends = FriendService(storage, clock, sync, share, deps.identity, bus, deps.lan);
|
||||||
|
chats = ChatService(storage, clock, share, sync, bus);
|
||||||
|
network = NetworkService(storage, clock, bus);
|
||||||
|
settings = SettingsService(storage, clock, deps.probe, bus);
|
||||||
|
recovery = RecoveryService(storage, clock, sync, bus);
|
||||||
|
verify = VerifyService(storage, clock, bus, deps.verifier);
|
||||||
|
location = LocationService(storage, clock, sync, visibility, bus, deps.location);
|
||||||
|
facts = FactsService(storage, clock, bus);
|
||||||
|
kin = KinService(storage, clock, facts, bus);
|
||||||
|
events = EventService(storage, clock, facts, bus, deps.notifier);
|
||||||
|
calls = CallService(storage, deps.callLog, deps.phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
final AppDeps deps;
|
||||||
|
final ChangeBus bus;
|
||||||
|
late final SyncEngine sync;
|
||||||
|
late final LayoutService layout;
|
||||||
|
late final ProfileService profile;
|
||||||
|
late final CardService cards;
|
||||||
|
late final FriendService friends;
|
||||||
|
late final ChatService chats;
|
||||||
|
late final NetworkService network;
|
||||||
|
late final SettingsService settings;
|
||||||
|
late final VisibilityService visibility;
|
||||||
|
late final RecoveryService recovery;
|
||||||
|
late final LocationService location;
|
||||||
|
late final VerifyService verify;
|
||||||
|
late final FactsService facts;
|
||||||
|
late final KinService kin;
|
||||||
|
late final EventService events;
|
||||||
|
late final CallService calls;
|
||||||
|
|
||||||
|
StreamSubscription<SyncEvent>? _events;
|
||||||
|
final Set<Future<void>> _tasks = {};
|
||||||
|
|
||||||
|
/// 화면이 듣는 변경 알림.
|
||||||
|
Stream<void> get changes => bus.changes;
|
||||||
|
|
||||||
|
/// 연결을 열고 동기화를 시작한다. 내 정보를 정한 뒤에 부른다.
|
||||||
|
Future<void> start() async {
|
||||||
|
_events ??= sync.events.listen((e) {
|
||||||
|
if (e.type == SyncEventType.hello) {
|
||||||
|
final f = friends.onHello(e);
|
||||||
|
_tasks.add(f);
|
||||||
|
unawaited(f.whenComplete(() => _tasks.remove(f)));
|
||||||
|
} else {
|
||||||
|
bus.notify();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await sync.start();
|
||||||
|
unawaited(events.reschedule());
|
||||||
|
final me = await profile.me();
|
||||||
|
final lan = deps.lan;
|
||||||
|
if (lan != null && me != null) {
|
||||||
|
await lan.start(myPhone: me.phone, myPort: lanPort);
|
||||||
|
}
|
||||||
|
bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 직접 연결이 열어 둔 포트 (조립하는 쪽이 정해 준다).
|
||||||
|
int lanPort = 0;
|
||||||
|
|
||||||
|
/// 받은 봉투와 그에 따른 처리가 모두 끝날 때까지 기다린다 (시험·종료 직전에 쓴다).
|
||||||
|
Future<void> idle() async {
|
||||||
|
do {
|
||||||
|
await sync.idle();
|
||||||
|
if (_tasks.isNotEmpty) await Future.wait(_tasks.toList());
|
||||||
|
} while (_tasks.isNotEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 보관함에서 새 봉투를 가져오고 못 보낸 것을 다시 보낸다 (화면 당겨서 새로고침, 주기적 호출).
|
||||||
|
Future<void> refresh() async {
|
||||||
|
await sync.refresh();
|
||||||
|
bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
await _events?.cancel();
|
||||||
|
await deps.lan?.stop();
|
||||||
|
await sync.stop();
|
||||||
|
await deps.storage.close();
|
||||||
|
await bus.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:friendly_map/modules/platform/platform.dart';
|
||||||
|
|
||||||
|
/// 두 지점 사이 거리(미터). 하버사인.
|
||||||
|
double distanceMeters(GeoPoint a, GeoPoint b) {
|
||||||
|
const r = 6371000.0;
|
||||||
|
final dLat = _rad(b.lat - a.lat), dLng = _rad(b.lng - a.lng);
|
||||||
|
final h = pow(sin(dLat / 2), 2) + cos(_rad(a.lat)) * cos(_rad(b.lat)) * pow(sin(dLng / 2), 2);
|
||||||
|
return 2 * r * asin(min(1.0, sqrt(h)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [from] 에서 [to] 를 보는 방위(도, 북쪽 0 · 시계 방향).
|
||||||
|
double bearingDegrees(GeoPoint from, GeoPoint to) {
|
||||||
|
final dLng = _rad(to.lng - from.lng);
|
||||||
|
final y = sin(dLng) * cos(_rad(to.lat));
|
||||||
|
final x = cos(_rad(from.lat)) * sin(_rad(to.lat)) - sin(_rad(from.lat)) * cos(_rad(to.lat)) * cos(dLng);
|
||||||
|
return (atan2(y, x) * 180 / pi + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "350 m", "2.4 km" 처럼 읽기 쉽게.
|
||||||
|
String distanceLabel(double meters) => meters < 1000 ? '${meters.round()} m' : '${(meters / 1000).toStringAsFixed(1)} km';
|
||||||
|
|
||||||
|
double _rad(double deg) => deg * pi / 180;
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
import 'package:friendly_map/modules/kinship/kinship.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
import 'facts_service.dart';
|
||||||
|
|
||||||
|
/// "누가 누구와 어떤 사이" 한 줄: [whose] 입장에서 본 [relation].
|
||||||
|
class RelationView {
|
||||||
|
const RelationView(this.whose, this.relation);
|
||||||
|
final String whose; // "나" 또는 내 자식 이름
|
||||||
|
final Relation relation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가족 모임에 나온 한 사람.
|
||||||
|
class KinEntry {
|
||||||
|
const KinEntry(this.person, this.relation);
|
||||||
|
final Person person;
|
||||||
|
final Relation relation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가계도(부모-자식·배우자)를 저장하고 촌수를 알려 준다. 가계도는 이 기기에만 있다.
|
||||||
|
class KinService {
|
||||||
|
KinService(this._storage, this._clock, this._facts, this._bus);
|
||||||
|
|
||||||
|
static const _key = 'kin.graph';
|
||||||
|
final Storage _storage;
|
||||||
|
final Clock _clock;
|
||||||
|
final FactsService _facts;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
|
||||||
|
Future<KinGraph> _load() async {
|
||||||
|
final g = KinGraph.fromJson(await _storage.settings.getText(_key));
|
||||||
|
if (!g.has(KinGraph.meId)) g.put(const KinPerson(KinGraph.meId));
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save(KinGraph g) async {
|
||||||
|
await _storage.settings.putText(_key, g.toJson(), _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 계산용 가계도 (생년은 사람별 사실의 값이 있으면 그걸 쓴다).
|
||||||
|
Future<KinGraph> graph() async {
|
||||||
|
final g = await _load();
|
||||||
|
for (final p in g.people.toList()) {
|
||||||
|
if (p.id == KinGraph.meId || p.birthYear != null) continue;
|
||||||
|
final y = (await _facts.get(p.id)).birthYear;
|
||||||
|
if (y != null) g.put(p.copyWith(birthYear: y));
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 나의 성별·태어난 해.
|
||||||
|
Future<void> setMe({Gender? gender, int? birthYear}) async {
|
||||||
|
final g = await _load();
|
||||||
|
final me = g.person(KinGraph.meId)!;
|
||||||
|
g.put(me.copyWith(gender: gender, birthYear: birthYear));
|
||||||
|
await _save(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<KinPerson> me() async => (await _load()).person(KinGraph.meId)!;
|
||||||
|
|
||||||
|
/// 가족을 한 명 더한다. 번호를 모르면 이 기기 안에서만 쓰는 사람이 된다 (친구 요청은 가지 않는다).
|
||||||
|
Future<Person?> addRelative(String name, {String? phone, Gender gender = Gender.unknown, int? birthYear}) async {
|
||||||
|
final n = name.trim();
|
||||||
|
if (n.isEmpty) return null;
|
||||||
|
final norm = phone == null || phone.trim().isEmpty ? null : normalizePhone(phone);
|
||||||
|
if (phone != null && phone.trim().isNotEmpty && norm == null) return null;
|
||||||
|
final p = await _storage.people.register(norm ?? '$localPhonePrefix${newId()}', n, _clock.now());
|
||||||
|
if (p == null) return null;
|
||||||
|
final g = await _load();
|
||||||
|
g.put(KinPerson(p.id, gender: gender, birthYear: birthYear));
|
||||||
|
await _save(g);
|
||||||
|
if (birthYear != null) {
|
||||||
|
await _facts.save(p.id, (await _facts.get(p.id)).copyWith(birthYear: birthYear));
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이미 있는 사람을 가계도에 올린다.
|
||||||
|
Future<void> include(String personId, {Gender gender = Gender.unknown, int? birthYear}) async {
|
||||||
|
final g = await _load();
|
||||||
|
final cur = g.person(personId);
|
||||||
|
g.put((cur ?? KinPerson(personId)).copyWith(gender: gender == Gender.unknown ? cur?.gender : gender, birthYear: birthYear));
|
||||||
|
await _save(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setProfile(String personId, {Gender? gender, int? birthYear}) async {
|
||||||
|
final g = await _load();
|
||||||
|
final cur = g.person(personId) ?? KinPerson(personId);
|
||||||
|
g.put(cur.copyWith(gender: gender, birthYear: birthYear));
|
||||||
|
await _save(g);
|
||||||
|
if (birthYear != null && personId != KinGraph.meId) {
|
||||||
|
await _facts.save(personId, (await _facts.get(personId)).copyWith(birthYear: birthYear));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [parent] 가 [child] 의 부모. 순환이거나 부모가 이미 둘이면 false.
|
||||||
|
Future<bool> setParent(String child, String parent) async {
|
||||||
|
final g = await _load();
|
||||||
|
for (final id in [child, parent]) {
|
||||||
|
if (!g.has(id)) g.put(KinPerson(id));
|
||||||
|
}
|
||||||
|
final ok = g.addParent(child, parent);
|
||||||
|
if (ok) await _save(g);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> setSpouse(String a, String b) async {
|
||||||
|
final g = await _load();
|
||||||
|
for (final id in [a, b]) {
|
||||||
|
if (!g.has(id)) g.put(KinPerson(id));
|
||||||
|
}
|
||||||
|
final ok = g.addSpouse(a, b);
|
||||||
|
if (ok) await _save(g);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unlinkParent(String child, String parent) async {
|
||||||
|
final g = await _load();
|
||||||
|
g.removeParent(child, parent);
|
||||||
|
await _save(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unlinkSpouse(String a, String b) async {
|
||||||
|
final g = await _load();
|
||||||
|
g.removeSpouse(a, b);
|
||||||
|
await _save(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가계도에서 뺀다 (연락처 사람은 그대로 둔다).
|
||||||
|
Future<void> exclude(String personId) async {
|
||||||
|
final g = await _load();
|
||||||
|
g.removePerson(personId);
|
||||||
|
await _save(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> inTree(String personId) async => (await _load()).has(personId);
|
||||||
|
|
||||||
|
/// 내가 보는 이 사람. 가계도에 없거나 이어진 길이 없으면 related == false.
|
||||||
|
Future<Relation> relationOf(String personId) async => relate(await graph(), KinGraph.meId, personId);
|
||||||
|
|
||||||
|
/// "내 아들 철수에게는 당숙 (5촌)" 처럼, 나와 내 자식들 각각의 눈으로 본 관계.
|
||||||
|
Future<List<RelationView>> explain(String personId) async {
|
||||||
|
final g = await graph();
|
||||||
|
if (!g.has(personId)) return const [];
|
||||||
|
final out = [RelationView('나', relate(g, KinGraph.meId, personId))];
|
||||||
|
for (final c in g.childrenOf(KinGraph.meId)) {
|
||||||
|
if (c == personId) continue;
|
||||||
|
final p = await _storage.people.get(c);
|
||||||
|
out.add(RelationView(p?.name ?? c, relate(g, c, personId)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가족 모임 한눈에: 가계도의 모든 사람과 나와의 관계 (가까운 촌수 순).
|
||||||
|
Future<List<KinEntry>> everyone() async {
|
||||||
|
final g = await graph();
|
||||||
|
final out = <KinEntry>[];
|
||||||
|
for (final k in g.people) {
|
||||||
|
if (k.id == KinGraph.meId) continue;
|
||||||
|
final p = await _storage.people.get(k.id);
|
||||||
|
if (p != null) out.add(KinEntry(p, relate(g, KinGraph.meId, k.id)));
|
||||||
|
}
|
||||||
|
out.sort((a, b) {
|
||||||
|
final x = a.relation.chon ?? 99, y = b.relation.chon ?? 99;
|
||||||
|
return x != y ? x.compareTo(y) : a.person.name.compareTo(b.person.name);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import 'package:friendly_map/modules/layout/layout.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
|
||||||
|
/// 배치 모듈이 쓸 저장 통로를 설정 저장소 위에 놓는다.
|
||||||
|
class SettingsTextStore implements TextStore {
|
||||||
|
SettingsTextStore(this._settings, this._clock);
|
||||||
|
|
||||||
|
final SettingsStore _settings;
|
||||||
|
final Clock _clock;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> read(String key) => _settings.getText(key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(String key, String value) => _settings.putText(key, value, _clock.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 홈 구성과 명함 틀 편집 (롱프레스 편집 모드).
|
||||||
|
class LayoutService {
|
||||||
|
LayoutService(Storage storage, Clock clock, this._bus)
|
||||||
|
: _home = homeLayoutStore(SettingsTextStore(storage.settings, clock)),
|
||||||
|
_templates = cardTemplateStore(SettingsTextStore(storage.settings, clock));
|
||||||
|
|
||||||
|
final OrderedStore<HomeItem> _home;
|
||||||
|
final OrderedStore<CardTemplate> _templates;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
|
||||||
|
Future<List<HomeItem>> home() => _home.load();
|
||||||
|
Future<List<CardTemplate>> templates() => _templates.load();
|
||||||
|
|
||||||
|
Future<void> moveHome(int from, int to) => _do(() => _home.move(from, to));
|
||||||
|
Future<void> removeHome(String id) => _do(() => _home.remove(id));
|
||||||
|
Future<void> editHome(HomeItem item) => _do(() => _home.upsert(item));
|
||||||
|
Future<void> resetHome() => _do(_home.reset);
|
||||||
|
|
||||||
|
Future<void> saveTemplate(CardTemplate t) => _do(() => _templates.upsert(t));
|
||||||
|
Future<void> removeTemplate(String id) => _do(() => _templates.remove(id));
|
||||||
|
Future<void> moveTemplate(int from, int to) => _do(() => _templates.move(from, to));
|
||||||
|
Future<void> resetTemplates() => _do(_templates.reset);
|
||||||
|
|
||||||
|
Future<void> _do(Future<Object?> Function() op) async {
|
||||||
|
await op();
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:friendly_map/modules/platform/platform.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
import 'package:friendly_map/modules/sync/sync.dart';
|
||||||
|
import 'package:friendly_map/modules/visibility/visibility.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
import 'visibility_service.dart';
|
||||||
|
|
||||||
|
/// 친구 한 명의 마지막 위치.
|
||||||
|
class FriendLocation {
|
||||||
|
const FriendLocation(this.person, this.point, this.at);
|
||||||
|
final Person person;
|
||||||
|
final GeoPoint point;
|
||||||
|
final int at; // 1/100초
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 위치 공유. 기본은 꺼짐이고, 켜면 공개 범위 표의 "실시간 위치"가 공개·일부인 관계의 친구에게만 보낸다.
|
||||||
|
/// 관계를 모르는 친구에게는 보내지 않는다. "일부"는 약 1km 단위로 뭉개서 보낸다.
|
||||||
|
class LocationService {
|
||||||
|
LocationService(this._storage, this._clock, this._sync, this._visibility, this._bus, this._provider);
|
||||||
|
|
||||||
|
static const _onKey = 'location_sharing';
|
||||||
|
final Storage _storage;
|
||||||
|
final Clock _clock;
|
||||||
|
final SyncEngine _sync;
|
||||||
|
final VisibilityService _visibility;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
final LocationProvider? _provider;
|
||||||
|
|
||||||
|
/// 이 기기에서 위치를 얻을 수 있는지 (센서·권한 부품이 있는지).
|
||||||
|
bool get available => _provider != null;
|
||||||
|
|
||||||
|
Future<bool> sharingOn() async => (await _storage.settings.getText(_onKey)) == 'true';
|
||||||
|
|
||||||
|
Future<void> setSharing(bool on) async {
|
||||||
|
await _storage.settings.putText(_onKey, on ? 'true' : 'false', _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 내 현재 위치. 얻지 못하면 null.
|
||||||
|
Future<GeoPoint?> mine() async => _provider?.current();
|
||||||
|
|
||||||
|
/// 지금 위치를 받을 수 있는 친구들과 단계(공개/일부).
|
||||||
|
Future<Map<Person, VLevel>> recipients() async {
|
||||||
|
final matrix = await _visibility.matrix();
|
||||||
|
final out = <Person, VLevel>{};
|
||||||
|
for (final p in await _storage.people.all()) {
|
||||||
|
if (p.publicKey == null) continue;
|
||||||
|
final audiences = await _visibility.audiencesOf(p.id);
|
||||||
|
if (audiences.isEmpty) continue; // 관계를 모르면 보내지 않는다
|
||||||
|
final level = matrix.forAudiences(VCategory.location, audiences);
|
||||||
|
if (level != VLevel.hidden) out[p] = level;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 켜져 있을 때 지금 위치를 보낸다. 보낸 친구 수를 돌려준다 (꺼져 있거나 위치를 못 얻으면 0).
|
||||||
|
Future<int> shareNow() async {
|
||||||
|
if (!await sharingOn()) return 0;
|
||||||
|
final here = await mine();
|
||||||
|
if (here == null) return 0;
|
||||||
|
final at = _clock.now();
|
||||||
|
var sent = 0;
|
||||||
|
for (final e in (await recipients()).entries) {
|
||||||
|
final blur = e.value == VLevel.partial;
|
||||||
|
final lat = blur ? _round2(here.lat) : here.lat;
|
||||||
|
final lng = blur ? _round2(here.lng) : here.lng;
|
||||||
|
final r = await _sync.sendLocation(e.key, lat, lng, at);
|
||||||
|
if (r == SendResult.sent || r == SendResult.queued) sent++;
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 친구들이 보내 준 마지막 위치 (최근 것부터).
|
||||||
|
Future<List<FriendLocation>> friends() async {
|
||||||
|
final raw = await _storage.settings.getText(friendLocationIndexKey);
|
||||||
|
if (raw == null || raw.isEmpty) return [];
|
||||||
|
final out = <FriendLocation>[];
|
||||||
|
for (final phone in (jsonDecode(raw) as List).cast<String>()) {
|
||||||
|
final t = await _storage.settings.getText(friendLocationKey(phone));
|
||||||
|
final p = await _storage.people.byPhone(phone);
|
||||||
|
if (t == null || t.isEmpty || p == null) continue;
|
||||||
|
final j = (jsonDecode(t) as Map).cast<String, Object?>();
|
||||||
|
out.add(FriendLocation(p, GeoPoint((j['lat'] as num).toDouble(), (j['lng'] as num).toDouble()), j['at'] as int));
|
||||||
|
}
|
||||||
|
out.sort((a, b) => b.at.compareTo(a.at));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _round2(double v) => (v * 100).round() / 100;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
import 'package:friendly_map/modules/inference/inference.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
import 'fields.dart';
|
||||||
|
|
||||||
|
/// 같은 회사·학교·모임 사람들의 묶음.
|
||||||
|
class Cluster {
|
||||||
|
const Cluster(this.title, this.people);
|
||||||
|
final String title;
|
||||||
|
final List<Person> people;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가족 한 명.
|
||||||
|
class FamilyMember {
|
||||||
|
const FamilyMember(this.person, this.relation);
|
||||||
|
final Person person;
|
||||||
|
final String relation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 자동 연동 추천 한 건.
|
||||||
|
class Suggestion {
|
||||||
|
const Suggestion(this.person, this.guess);
|
||||||
|
final Person person;
|
||||||
|
final Guess guess;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 인맥 전체 보기: 어디서 봤지, 연결 고리, 묶음, 가족, 추천.
|
||||||
|
class NetworkService {
|
||||||
|
NetworkService(this._storage, this._clock, this._bus);
|
||||||
|
|
||||||
|
static const _inferer = Inferer();
|
||||||
|
final Storage _storage;
|
||||||
|
final Clock _clock;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
|
||||||
|
Future<List<CardSnapshot>> _mine() async => [
|
||||||
|
for (final c in await _storage.myCards.all()) CardSnapshot(c.kind, decodeFields(c.fieldsJson)),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<List<CardSnapshot>> _theirs(String personId) async => [
|
||||||
|
for (final c in await _storage.receivedCards.byPerson(personId))
|
||||||
|
CardSnapshot(c.kind, decodeFields(c.fieldsJson)),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// "어디서 봤지?" 추측 목록 (신뢰도 높은 순).
|
||||||
|
Future<List<Guess>> guessesFor(String personId) async =>
|
||||||
|
_inferer.infer(mine: await _mine(), theirs: await _theirs(personId));
|
||||||
|
|
||||||
|
/// 이 사람에게서 받은 명함들 (최근 것부터).
|
||||||
|
Future<List<ReceivedCard>> receivedCards(String personId) => _storage.receivedCards.byPerson(personId);
|
||||||
|
|
||||||
|
/// 받은 명함을 내 기기에서 지운다 (상대 기기의 원본은 그대로).
|
||||||
|
Future<void> deleteReceivedCard(String cardId) async {
|
||||||
|
await _storage.receivedCards.remove(cardId);
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Link>> links(String personId) => _storage.links.byPerson(personId);
|
||||||
|
|
||||||
|
/// 추측을 연결 고리로 확정하거나, 직접 관계를 적는다.
|
||||||
|
Future<Link> addLink(String personId, String relation, String detail, {bool confirmed = true}) async {
|
||||||
|
final link = Link(
|
||||||
|
id: newId(),
|
||||||
|
personId: personId,
|
||||||
|
relation: relation,
|
||||||
|
detail: detail,
|
||||||
|
confirmed: confirmed,
|
||||||
|
updatedAt: _clock.now(),
|
||||||
|
);
|
||||||
|
await _storage.links.put(link);
|
||||||
|
_bus.notify();
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeLink(String linkId) async {
|
||||||
|
await _storage.links.remove(linkId);
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이미 아는 사람 중 나와 겹치는데 아직 연결 고리로 확정하지 않은 사람.
|
||||||
|
Future<List<Suggestion>> suggestions() async {
|
||||||
|
final mine = await _mine();
|
||||||
|
final others = <String, List<CardSnapshot>>{};
|
||||||
|
final people = {for (final p in await _storage.people.all()) p.id: p};
|
||||||
|
for (final p in people.values) {
|
||||||
|
if ((await _storage.links.byPerson(p.id)).any((l) => l.confirmed)) continue;
|
||||||
|
final cards = await _theirs(p.id);
|
||||||
|
if (cards.isNotEmpty) others[p.id] = cards;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (final r in _inferer.recommend(mine: mine, others: others)) Suggestion(people[r.personId]!, r.guess),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 종류별 묶음. company → 회사, school → 학교, group → 모임·여행 이름으로 묶는다.
|
||||||
|
Future<List<Cluster>> clusters(String kind) async {
|
||||||
|
final keys = switch (kind) {
|
||||||
|
CardKinds.company => [CardKeys.company],
|
||||||
|
CardKinds.school => [CardKeys.school],
|
||||||
|
CardKinds.group => [CardKeys.group, CardKeys.trip],
|
||||||
|
_ => const <String>[],
|
||||||
|
};
|
||||||
|
final people = {for (final p in await _storage.people.all()) p.id: p};
|
||||||
|
final byName = <String, (String, Map<String, Person>)>{};
|
||||||
|
for (final c in await _storage.receivedCards.all()) {
|
||||||
|
final f = decodeFields(c.fieldsJson);
|
||||||
|
final who = people[c.personId];
|
||||||
|
if (who == null) continue;
|
||||||
|
for (final k in keys) {
|
||||||
|
final v = f[k]?.trim();
|
||||||
|
if (v == null || v.isEmpty) continue;
|
||||||
|
final norm = Inferer.normalize(v);
|
||||||
|
byName.putIfAbsent(norm, () => (v, {})).$2[who.id] = who;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final out = [for (final e in byName.values) Cluster(e.$1, e.$2.values.toList())];
|
||||||
|
out.sort((a, b) => b.people.length.compareTo(a.people.length));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가족: 가족 명함을 받은 사람과 '가족' 으로 확정한 사람.
|
||||||
|
Future<List<FamilyMember>> family() async {
|
||||||
|
final people = {for (final p in await _storage.people.all()) p.id: p};
|
||||||
|
final out = <String, FamilyMember>{};
|
||||||
|
for (final c in await _storage.receivedCards.all()) {
|
||||||
|
if (c.kind != CardKinds.family) continue;
|
||||||
|
final p = people[c.personId];
|
||||||
|
if (p != null) out[p.id] = FamilyMember(p, decodeFields(c.fieldsJson)[CardKeys.relation] ?? '가족');
|
||||||
|
}
|
||||||
|
for (final l in await _storage.links.all()) {
|
||||||
|
final p = people[l.personId];
|
||||||
|
if (p != null && l.relation == '가족') out.putIfAbsent(p.id, () => FamilyMember(p, l.detail.isEmpty ? '가족' : l.detail));
|
||||||
|
}
|
||||||
|
return out.values.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
import 'fields.dart';
|
||||||
|
import 'layout_service.dart';
|
||||||
|
|
||||||
|
/// 내 정보와 첫 설정.
|
||||||
|
class ProfileService {
|
||||||
|
ProfileService(this._storage, this._clock, this._layout, this._bus);
|
||||||
|
|
||||||
|
final Storage _storage;
|
||||||
|
final Clock _clock;
|
||||||
|
final LayoutService _layout;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
|
||||||
|
Future<MyProfile?> me() => _storage.settings.loadProfile();
|
||||||
|
|
||||||
|
Future<bool> isSetUp() async => (await me()) != null;
|
||||||
|
|
||||||
|
/// 처음 시작: 내 번호·이름을 정하고 종류별 기본 명함을 만든다. 번호가 올바르지 않으면 null.
|
||||||
|
Future<MyProfile?> setUp(String rawPhone, String name) async {
|
||||||
|
final phone = normalizePhone(rawPhone);
|
||||||
|
final clean = name.trim();
|
||||||
|
if (phone == null || clean.isEmpty) return null;
|
||||||
|
final p = MyProfile(phone: phone, name: clean);
|
||||||
|
await _storage.settings.saveProfile(p, _clock.now());
|
||||||
|
if ((await _storage.myCards.all()).isEmpty) {
|
||||||
|
for (final t in await _layout.templates()) {
|
||||||
|
await _storage.myCards.put(MyCard(
|
||||||
|
id: newId(),
|
||||||
|
kind: t.kind,
|
||||||
|
title: t.title,
|
||||||
|
fieldsJson: encodeFields({
|
||||||
|
for (final f in t.fields) f.key: f.key == CardKeys.name ? clean : (f.key == CardKeys.phone ? phone : ''),
|
||||||
|
}),
|
||||||
|
templateId: t.id,
|
||||||
|
shareDefault: t.kind == CardKinds.personal,
|
||||||
|
updatedAt: _clock.now(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_bus.notify();
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 내 이름을 바꾼다 (개인 명함의 이름도 함께).
|
||||||
|
Future<void> rename(String name) async {
|
||||||
|
final p = await me();
|
||||||
|
final clean = name.trim();
|
||||||
|
if (p == null || clean.isEmpty) return;
|
||||||
|
await _storage.settings.saveProfile(MyProfile(phone: p.phone, name: clean), _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
import 'package:friendly_map/modules/sync/sync.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
|
||||||
|
/// 내가 나눈 복구 조각 묶음: 조각 글자들, 필요한 수, 누구에게 몇 번째를 맡겼는지.
|
||||||
|
class RecoverySplit {
|
||||||
|
const RecoverySplit({required this.codes, required this.threshold, this.given = const {}});
|
||||||
|
|
||||||
|
final List<String> codes;
|
||||||
|
final int threshold;
|
||||||
|
final Map<String, int> given; // 사람 ID → 조각 번호(0부터)
|
||||||
|
|
||||||
|
int get total => codes.length;
|
||||||
|
int get entrusted => given.length;
|
||||||
|
|
||||||
|
/// 아직 아무에게도 맡기지 않은 조각 번호들.
|
||||||
|
List<int> get free => [for (var i = 0; i < codes.length; i++) if (!given.containsValue(i)) i];
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {'codes': codes, 'k': threshold, 'given': given};
|
||||||
|
|
||||||
|
factory RecoverySplit.fromJson(Map<String, Object?> j) => RecoverySplit(
|
||||||
|
codes: (j['codes'] as List).cast<String>(),
|
||||||
|
threshold: j['k'] as int,
|
||||||
|
given: {for (final e in (j['given'] as Map).entries) e.key as String: e.value as int},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 친구가 나에게 맡긴 조각.
|
||||||
|
class HeldShare {
|
||||||
|
const HeldShare(this.ownerPhone, this.ownerName, this.code);
|
||||||
|
final String ownerPhone;
|
||||||
|
final String ownerName;
|
||||||
|
final String code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 복구 조각 맡기기. 조각 만들기·합치기(키 모듈)는 모르고, 만들어진 조각 글자의 보관과 전달만 한다.
|
||||||
|
class RecoveryService {
|
||||||
|
RecoveryService(this._storage, this._clock, this._sync, this._bus);
|
||||||
|
|
||||||
|
static const _splitKey = 'recovery_split';
|
||||||
|
final Storage _storage;
|
||||||
|
final Clock _clock;
|
||||||
|
final SyncEngine _sync;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
|
||||||
|
/// 지금 나눠 둔 묶음. 없으면 null.
|
||||||
|
Future<RecoverySplit?> split() async {
|
||||||
|
final t = await _storage.settings.getText(_splitKey);
|
||||||
|
if (t == null || t.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
return RecoverySplit.fromJson((jsonDecode(t) as Map).cast<String, Object?>());
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 새로 나눈 조각들을 보관한다. 이전 묶음과 맡긴 기록은 사라진다 (이전 조각은 새 조각과 섞이지 않는다).
|
||||||
|
Future<RecoverySplit> saveSplit(List<String> codes, int threshold) async {
|
||||||
|
final s = RecoverySplit(codes: codes, threshold: threshold);
|
||||||
|
await _write(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [personId] 에게 [index] 번째 조각을 맡긴다. 이미 다른 조각을 맡았으면 그 조각을 새로 보낸다.
|
||||||
|
Future<SendResult> entrust(String personId, int index) async {
|
||||||
|
final s = await split();
|
||||||
|
final peer = await _storage.people.get(personId);
|
||||||
|
if (s == null || peer == null || index < 0 || index >= s.total) return SendResult.noTarget;
|
||||||
|
final used = s.given.entries.where((e) => e.value == index && e.key != personId);
|
||||||
|
if (used.isNotEmpty) return SendResult.noTarget; // 한 조각은 한 사람에게만
|
||||||
|
final result = await _sync.sendRecovery(peer, s.codes[index]);
|
||||||
|
if (result != SendResult.noKey && result != SendResult.noTarget) {
|
||||||
|
await _write(RecoverySplit(codes: s.codes, threshold: s.threshold, given: {...s.given, personId: index}));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 친구들이 나에게 맡긴 조각들.
|
||||||
|
Future<List<HeldShare>> held() async {
|
||||||
|
final raw = await _storage.settings.getText(heldRecoveryIndexKey);
|
||||||
|
if (raw == null || raw.isEmpty) return [];
|
||||||
|
final out = <HeldShare>[];
|
||||||
|
for (final phone in (jsonDecode(raw) as List).cast<String>()) {
|
||||||
|
final code = await _storage.settings.getText(heldRecoveryKey(phone));
|
||||||
|
if (code == null || code.isEmpty) continue;
|
||||||
|
final who = await _storage.people.byPhone(phone);
|
||||||
|
out.add(HeldShare(phone, who?.name ?? phone, code));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _write(RecoverySplit s) async {
|
||||||
|
await _storage.settings.putText(_splitKey, jsonEncode(s.toJson()), _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import 'package:friendly_map/modules/platform/platform.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
import 'friend_service.dart' show hideAllKey;
|
||||||
|
|
||||||
|
/// 설정과 별도 저장(보관) 관리.
|
||||||
|
class SettingsService {
|
||||||
|
SettingsService(this._storage, this._clock, DeviceStorageProbe probe, this._bus)
|
||||||
|
: _archive = ArchiveService(
|
||||||
|
messages: _storage.messages,
|
||||||
|
settings: _storage.settings,
|
||||||
|
clock: _clock,
|
||||||
|
probe: probe,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Storage _storage;
|
||||||
|
final Clock _clock;
|
||||||
|
final ArchiveService _archive;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
|
||||||
|
Future<AppSettings> load() => _storage.settings.load();
|
||||||
|
|
||||||
|
Future<void> save(AppSettings s) async {
|
||||||
|
await _storage.settings.save(s, _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 값 하나만 바꾼다.
|
||||||
|
Future<AppSettings> update({
|
||||||
|
int? separateTextBytes,
|
||||||
|
int? storageUsagePercent,
|
||||||
|
String? exchangeMode,
|
||||||
|
int? recoveryShares,
|
||||||
|
int? recoveryThreshold,
|
||||||
|
}) async {
|
||||||
|
final s = await load();
|
||||||
|
var shares = recoveryShares ?? s.recoveryShares;
|
||||||
|
var need = recoveryThreshold ?? s.recoveryThreshold;
|
||||||
|
shares = shares.clamp(2, 9);
|
||||||
|
need = need.clamp(2, shares);
|
||||||
|
final next = AppSettings(
|
||||||
|
separateTextBytes: (separateTextBytes ?? s.separateTextBytes).clamp(64 * 1024, 512 * 1024 * 1024),
|
||||||
|
storageUsagePercent: (storageUsagePercent ?? s.storageUsagePercent).clamp(50, 95),
|
||||||
|
exchangeMode: exchangeMode ?? s.exchangeMode,
|
||||||
|
recoveryShares: shares,
|
||||||
|
recoveryThreshold: need,
|
||||||
|
);
|
||||||
|
await save(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "모든 정보 숨기기": 켜면 모르는 사람의 친구 요청을 받지 않는다.
|
||||||
|
Future<bool> hideAll() async => (await _storage.settings.getText(hideAllKey)) == 'true';
|
||||||
|
|
||||||
|
Future<void> setHideAll(bool value) async {
|
||||||
|
await _storage.settings.putText(hideAllKey, value ? 'true' : 'false', _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이 방을 별도 저장으로 나누라고 권할 만한지.
|
||||||
|
Future<ArchiveAdvice> adviseRoom(String roomId) => _archive.adviseRoom(roomId);
|
||||||
|
|
||||||
|
/// 별도 저장으로 나눌 수 있는 범위 (처음~끝). 대화가 없으면 null.
|
||||||
|
Future<(int, int)?> selectableSpan(String roomId) => _archive.selectableSpan(roomId);
|
||||||
|
|
||||||
|
Future<int> archiveRange(String roomId, int from, int to) async {
|
||||||
|
final n = await _archive.archiveRange(roomId, from, to);
|
||||||
|
_bus.notify();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> restoreRange(String roomId, int from, int to) async {
|
||||||
|
final n = await _archive.restoreRange(roomId, from, to);
|
||||||
|
_bus.notify();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
|
||||||
|
/// 내 번호 문자 확인. 확인 방식(보관함 서버 등)은 [PhoneVerifier] 약속 뒤에 있어 여기서는 모른다.
|
||||||
|
class VerifyService {
|
||||||
|
VerifyService(this._storage, this._clock, this._bus, this._verifier);
|
||||||
|
|
||||||
|
static const _key = 'phone_verified';
|
||||||
|
final Storage _storage;
|
||||||
|
final Clock _clock;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
final PhoneVerifier? _verifier;
|
||||||
|
|
||||||
|
/// 이 앱에 확인 수단이 붙어 있는지.
|
||||||
|
bool get available => _verifier != null;
|
||||||
|
|
||||||
|
Future<bool> isVerified() async => (await _storage.settings.getText(_key)) == 'true';
|
||||||
|
|
||||||
|
Future<VerifyStart> request() async => await _verifier?.requestCode() ?? VerifyStart.unavailable;
|
||||||
|
|
||||||
|
/// 확인 번호를 넣는다. 맞으면 "확인됨"으로 기록한다.
|
||||||
|
Future<bool> confirm(String code) async {
|
||||||
|
final ok = await _verifier?.confirm(code) ?? false;
|
||||||
|
if (ok) {
|
||||||
|
await _storage.settings.putText(_key, 'true', _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import 'package:friendly_map/modules/storage/storage.dart';
|
||||||
|
import 'package:friendly_map/modules/visibility/visibility.dart';
|
||||||
|
|
||||||
|
import 'change_bus.dart';
|
||||||
|
import 'fields.dart';
|
||||||
|
|
||||||
|
/// 공개 범위: 표를 저장·고치고, 명함을 보낼 때 받는 사람에 맞게 거른다.
|
||||||
|
class VisibilityService {
|
||||||
|
VisibilityService(this._storage, this._clock, this._bus);
|
||||||
|
|
||||||
|
static const _key = 'visibility_matrix';
|
||||||
|
final Storage _storage;
|
||||||
|
final Clock _clock;
|
||||||
|
final ChangeBus _bus;
|
||||||
|
|
||||||
|
Future<VisibilityMatrix> matrix() async => VisibilityMatrix.fromJson(await _storage.settings.getText(_key));
|
||||||
|
|
||||||
|
/// 표의 칸 하나를 다음 단계로 돌린다 (공개 → 일부 → 숨김).
|
||||||
|
Future<VisibilityMatrix> cycle(String category, String audience) async {
|
||||||
|
final m = await matrix();
|
||||||
|
final next = m.with_(category, audience, m.get(category, audience).next);
|
||||||
|
await _storage.settings.putText(_key, next.toJson(), _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reset() async {
|
||||||
|
await _storage.settings.putText(_key, VisibilityMatrix.defaults().toJson(), _clock.now());
|
||||||
|
_bus.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이 사람이 속한 관계 묶음: 연결된 관계에서 찾는다.
|
||||||
|
Future<Set<String>> audiencesOf(String personId) async {
|
||||||
|
final out = <String>{};
|
||||||
|
for (final l in await _storage.links.byPerson(personId)) {
|
||||||
|
final a = VAudience.ofRelation(l.relation);
|
||||||
|
if (a != null) out.add(a);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 명함을 [peerId] 에게 보낼 내용(JSON)으로 만든다. 명함 종류가 뜻하는 관계도 함께 본다
|
||||||
|
/// (예: 회사 명함을 보내면 받는 사람을 회사 관계로 본다).
|
||||||
|
Future<String> contentFor(MyCard card, String peerId) async {
|
||||||
|
final audiences = await audiencesOf(peerId);
|
||||||
|
final byKind = VAudience.ofCardKind(card.kind);
|
||||||
|
if (byKind != null) audiences.add(byKind);
|
||||||
|
final out = applyVisibility(decodeFields(card.fieldsJson), audiences, await matrix());
|
||||||
|
return encodeFields(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
/// 공통 약속: 모듈끼리 주고받는 약속(인터페이스)과 봉투만 둔다. 구현은 두지 않는다.
|
||||||
|
///
|
||||||
|
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/card_keys.dart';
|
||||||
|
export 'src/envelope.dart';
|
||||||
|
export 'src/identity_provider.dart';
|
||||||
|
export 'src/key_provider.dart';
|
||||||
|
export 'src/payload_cipher.dart';
|
||||||
|
export 'src/phone_verifier.dart';
|
||||||
|
export 'src/time_source.dart';
|
||||||
|
export 'src/transport.dart';
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/// 명함 항목(fields JSON)의 이름과 명함 종류. 모듈들이 같은 말을 쓰도록 한곳에 둔다.
|
||||||
|
class CardKinds {
|
||||||
|
static const personal = 'personal';
|
||||||
|
static const company = 'company';
|
||||||
|
static const school = 'school';
|
||||||
|
static const group = 'group';
|
||||||
|
static const family = 'family';
|
||||||
|
static const all = [personal, company, school, group, family];
|
||||||
|
|
||||||
|
static String label(String kind) => switch (kind) {
|
||||||
|
personal => '개인',
|
||||||
|
company => '회사',
|
||||||
|
school => '학교',
|
||||||
|
group => '그룹·여행',
|
||||||
|
family => '가족',
|
||||||
|
_ => kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CardKeys {
|
||||||
|
static const name = 'name';
|
||||||
|
static const phone = 'phone';
|
||||||
|
static const email = 'email';
|
||||||
|
static const birthday = 'birthday';
|
||||||
|
static const company = 'company';
|
||||||
|
static const position = 'position';
|
||||||
|
static const former = 'former'; // 'true' 이면 지금은 다니지 않는 회사
|
||||||
|
static const school = 'school';
|
||||||
|
static const level = 'level'; // 초등 | 중등 | 고등 | 대학 | 대학원
|
||||||
|
static const year = 'year'; // 입학 또는 졸업 연도
|
||||||
|
static const major = 'major';
|
||||||
|
static const group = 'group';
|
||||||
|
static const trip = 'trip';
|
||||||
|
static const relation = 'relation'; // 가족: 어머니, 동생 …
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// 기기 사이를 오가는 봉투. 겉(보낸 사람·받는 사람·종류·단위 ID·시각)은 읽을 수 있고,
|
||||||
|
/// 속([body])은 잠겨 있다. 외부 DB 보관함도 겉만 볼 수 있다.
|
||||||
|
class Envelope {
|
||||||
|
const Envelope({
|
||||||
|
required this.id,
|
||||||
|
required this.from,
|
||||||
|
required this.to,
|
||||||
|
required this.kind,
|
||||||
|
required this.unitId,
|
||||||
|
required this.body,
|
||||||
|
required this.stamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String from; // 보낸 사람 전화번호(표준 형태)
|
||||||
|
final String to; // 받는 사람 전화번호(표준 형태)
|
||||||
|
final String kind; // hello | card.share | chat.room | chat.message
|
||||||
|
final String unitId; // 명함 장 또는 대화방 ID (잠금 키를 정하는 데 쓴다)
|
||||||
|
final String body; // 잠긴 내용 (hello 는 잠그지 않은 JSON)
|
||||||
|
final int stamp; // 보낸 시각 (1/100초)
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'from': from,
|
||||||
|
'to': to,
|
||||||
|
'kind': kind,
|
||||||
|
'unit': unitId,
|
||||||
|
'body': body,
|
||||||
|
'stamp': stamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory Envelope.fromJson(Map<String, Object?> m) => Envelope(
|
||||||
|
id: m['id'] as String,
|
||||||
|
from: m['from'] as String,
|
||||||
|
to: m['to'] as String,
|
||||||
|
kind: m['kind'] as String,
|
||||||
|
unitId: m['unit'] as String,
|
||||||
|
body: m['body'] as String,
|
||||||
|
stamp: m['stamp'] as int,
|
||||||
|
);
|
||||||
|
|
||||||
|
String encode() => jsonEncode(toJson());
|
||||||
|
|
||||||
|
static Envelope decode(String text) =>
|
||||||
|
Envelope.fromJson((jsonDecode(text) as Map).cast<String, Object?>());
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
/// 내 공개 키를 내주는 약속. 상대는 이 값으로 나와 둘만의 암호 키를 만든다.
|
||||||
|
abstract class IdentityProvider {
|
||||||
|
/// 공개 키 (base64). 공개해도 안전하다.
|
||||||
|
Future<String> myPublicKey();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
/// 암호 키를 내주는 약속. 키 모듈은 이 약속을 지키는 하나의 구현일 뿐이라,
|
||||||
|
/// 다른 구현으로 바꾸거나 빼도 이 약속을 쓰는 모듈은 바뀌지 않는다.
|
||||||
|
abstract class KeyProvider {
|
||||||
|
/// 암호화된 저장소를 여는 암호.
|
||||||
|
Future<String> databasePassword();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/// 상대와 둘만 아는 키로 내용을 잠그고 푸는 약속.
|
||||||
|
///
|
||||||
|
/// [context] 는 공유 단위(명함 장·대화방)의 ID 이고, 단위마다 다른 키가 만들어진다.
|
||||||
|
abstract class PayloadCipher {
|
||||||
|
Future<String> seal({
|
||||||
|
required String plain,
|
||||||
|
required String peerPublicKey,
|
||||||
|
required String context,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 풀 수 없거나(키가 다름) 중간에 바뀐 내용이면 null.
|
||||||
|
Future<String?> open({
|
||||||
|
required String sealed,
|
||||||
|
required String peerPublicKey,
|
||||||
|
required String context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
/// 문자 요청 결과.
|
||||||
|
enum VerifyStart { sent, rateLimited, failed, unavailable }
|
||||||
|
|
||||||
|
/// 이 기기가 내 전화번호의 주인인지 문자로 확인하는 약속. 보관함 서버의 방식이든 다른 방식이든 이 약속만 지키면 된다.
|
||||||
|
abstract class PhoneVerifier {
|
||||||
|
/// 내 번호로 확인 번호 문자를 요청한다.
|
||||||
|
Future<VerifyStart> requestCode();
|
||||||
|
|
||||||
|
/// 받은 확인 번호를 넣는다. 맞으면 true.
|
||||||
|
Future<bool> confirm(String code);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
/// 기준 시각을 알려 주는 약속. 기기 시계 오차를 바로잡는 데 쓴다.
|
||||||
|
abstract class TimeSource {
|
||||||
|
/// 기준 시각 (1970년부터 지난 1/100초). 알 수 없으면 null.
|
||||||
|
Future<int?> referenceStamp();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import 'envelope.dart';
|
||||||
|
|
||||||
|
/// 봉투를 주고받는 약속. 직접 연결, 외부 DB 보관함, 메모리(테스트용)가 이 약속을 지킨다.
|
||||||
|
abstract class Transport {
|
||||||
|
Future<void> start();
|
||||||
|
Future<void> stop();
|
||||||
|
|
||||||
|
/// 보내 본다. 상대(또는 보관함)에게 닿았으면 true, 못 보냈으면 false.
|
||||||
|
Future<bool> send(Envelope envelope);
|
||||||
|
|
||||||
|
/// 들어오는 봉투.
|
||||||
|
Stream<Envelope> get incoming;
|
||||||
|
|
||||||
|
/// 보관함에 쌓인 것을 가져와 [incoming] 으로 흘려 보낸다. 가져온 개수를 돌려준다.
|
||||||
|
Future<int> fetchPending();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
/// 발견: 새 친구를 만나는 방법 (QR, 흔들기, 같은 네트워크 주소록)
|
||||||
|
///
|
||||||
|
/// 센서·네트워크는 platform 의 약속으로만 받는다. 이 모듈은 숫자·글자 계산만 해서 어디서나 시험된다.
|
||||||
|
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/lan_directory.dart';
|
||||||
|
export 'src/qr_payload.dart';
|
||||||
|
export 'src/shake_detector.dart';
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:friendly_map/modules/platform/platform.dart';
|
||||||
|
|
||||||
|
/// 같은 네트워크에서 발견된 상대의 주소록. 전화번호로 주소를 찾는다.
|
||||||
|
/// 발견 방식(구현)은 [LanDiscovery] 약속으로만 받아서 플랫폼마다 갈아 끼울 수 있다.
|
||||||
|
class LanDirectory {
|
||||||
|
LanDirectory(this._discovery);
|
||||||
|
|
||||||
|
final LanDiscovery _discovery;
|
||||||
|
final Map<String, LanPeer> _byPhone = {};
|
||||||
|
final StreamController<LanPeer> _seen = StreamController<LanPeer>.broadcast(sync: true);
|
||||||
|
StreamSubscription<LanPeer>? _sub;
|
||||||
|
|
||||||
|
/// 새로 발견되거나 주소가 바뀐 상대.
|
||||||
|
Stream<LanPeer> get seen => _seen.stream;
|
||||||
|
|
||||||
|
Future<void> start({required String myPhone, required int myPort}) async {
|
||||||
|
_sub ??= _discovery.peers.listen((p) {
|
||||||
|
if (p.phone == myPhone) return;
|
||||||
|
if (_byPhone[p.phone] != p) {
|
||||||
|
_byPhone[p.phone] = p;
|
||||||
|
_seen.add(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await _discovery.advertise(phone: myPhone, port: myPort);
|
||||||
|
await _discovery.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
await _sub?.cancel();
|
||||||
|
_sub = null;
|
||||||
|
await _discovery.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
LanPeer? find(String phone) => _byPhone[phone];
|
||||||
|
List<LanPeer> get all => _byPhone.values.toList();
|
||||||
|
|
||||||
|
/// 사라진 상대를 지운다.
|
||||||
|
void forget(String phone) => _byPhone.remove(phone);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// QR 코드에 담는 내 연락 정보. 상대가 읽으면 번호·이름·공개 키를 알게 되어 바로 친구 요청을 보낸다.
|
||||||
|
class QrPayload {
|
||||||
|
const QrPayload({required this.phone, required this.name, required this.publicKey});
|
||||||
|
|
||||||
|
static const _prefix = 'fm1:';
|
||||||
|
|
||||||
|
final String phone; // 표준 형태 전화번호
|
||||||
|
final String name;
|
||||||
|
final String publicKey;
|
||||||
|
|
||||||
|
String encode() =>
|
||||||
|
_prefix + base64Url.encode(utf8.encode(jsonEncode({'p': phone, 'n': name, 'k': publicKey})));
|
||||||
|
|
||||||
|
/// 우리 앱의 QR 이 아니거나 깨졌으면 null.
|
||||||
|
static QrPayload? decode(String text) {
|
||||||
|
if (!text.startsWith(_prefix)) return null;
|
||||||
|
try {
|
||||||
|
final m = jsonDecode(utf8.decode(base64Url.decode(text.substring(_prefix.length)))) as Map;
|
||||||
|
final p = m['p'], n = m['n'], k = m['k'];
|
||||||
|
if (p is! String || n is! String || k is! String || p.isEmpty || k.isEmpty) return null;
|
||||||
|
return QrPayload(phone: p, name: n, publicKey: k);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/// 가속도 값을 계속 넣어 주면 "흔들었다"를 알려 준다. 센서와 상관없이 숫자만 다뤄서 시험하기 쉽다.
|
||||||
|
///
|
||||||
|
/// [window] 안에 [threshold] 를 넘는 순간이 [minPeaks] 번 이상이면 흔든 것으로 본다.
|
||||||
|
/// 한 번 알린 뒤에는 [cooldown] 동안 다시 알리지 않는다.
|
||||||
|
class ShakeDetector {
|
||||||
|
ShakeDetector({
|
||||||
|
this.threshold = 18.0,
|
||||||
|
this.minPeaks = 3,
|
||||||
|
this.window = const Duration(milliseconds: 900),
|
||||||
|
this.cooldown = const Duration(milliseconds: 1500),
|
||||||
|
});
|
||||||
|
|
||||||
|
final double threshold; // m/s² (중력 포함 크기)
|
||||||
|
final int minPeaks;
|
||||||
|
final Duration window;
|
||||||
|
final Duration cooldown;
|
||||||
|
|
||||||
|
final List<int> _peaks = [];
|
||||||
|
int? _lastFire;
|
||||||
|
bool _above = false;
|
||||||
|
|
||||||
|
/// [magnitude] 는 가속도 크기, [atMs] 는 그 시각(밀리초). 흔들었다고 판단되면 true.
|
||||||
|
bool feed(double magnitude, int atMs) {
|
||||||
|
final high = magnitude >= threshold;
|
||||||
|
if (high && !_above) _peaks.add(atMs); // 문턱을 넘는 순간만 센다
|
||||||
|
_above = high;
|
||||||
|
_peaks.removeWhere((t) => atMs - t > window.inMilliseconds);
|
||||||
|
final last = _lastFire;
|
||||||
|
if (_peaks.length >= minPeaks && (last == null || atMs - last >= cooldown.inMilliseconds)) {
|
||||||
|
_lastFire = atMs;
|
||||||
|
_peaks.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export 'src/calls.dart';
|
||||||
|
export 'src/event.dart';
|
||||||
|
export 'src/lunar.dart';
|
||||||
|
export 'src/reminders.dart';
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
enum CallDirection { incoming, outgoing, missed }
|
||||||
|
|
||||||
|
class CallRecord {
|
||||||
|
const CallRecord(this.at, this.seconds, this.direction);
|
||||||
|
final DateTime at;
|
||||||
|
final int seconds;
|
||||||
|
final CallDirection direction;
|
||||||
|
bool get answered => direction != CallDirection.missed && seconds > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 통화 가능 시간대 하나 (예: 19시~21시).
|
||||||
|
class CallWindow {
|
||||||
|
const CallWindow(this.fromHour, this.toHour, this.count);
|
||||||
|
final int fromHour;
|
||||||
|
final int toHour;
|
||||||
|
final int count;
|
||||||
|
String get label => '${_ampm(fromHour)} ~ ${_ampm(toHour)}';
|
||||||
|
static String _ampm(int h) {
|
||||||
|
final x = h % 24;
|
||||||
|
final p = x < 12 ? '오전' : '오후';
|
||||||
|
final v = x % 12 == 0 ? 12 : x % 12;
|
||||||
|
return '$p $v시';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CallSummary {
|
||||||
|
const CallSummary({
|
||||||
|
required this.total,
|
||||||
|
required this.answered,
|
||||||
|
required this.missed,
|
||||||
|
required this.totalSeconds,
|
||||||
|
required this.lastAt,
|
||||||
|
required this.lastDirection,
|
||||||
|
required this.windows,
|
||||||
|
});
|
||||||
|
static const empty = CallSummary(
|
||||||
|
total: 0, answered: 0, missed: 0, totalSeconds: 0, lastAt: null, lastDirection: null, windows: []);
|
||||||
|
|
||||||
|
final int total;
|
||||||
|
final int answered;
|
||||||
|
final int missed;
|
||||||
|
final int totalSeconds;
|
||||||
|
final DateTime? lastAt;
|
||||||
|
final CallDirection? lastDirection;
|
||||||
|
final List<CallWindow> windows;
|
||||||
|
|
||||||
|
int get averageSeconds => answered == 0 ? 0 : totalSeconds ~/ answered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 통화 기록 한 사람 몫을 요약한다. 받은 통화만 "받을 수 있는 시간대" 계산에 쓴다.
|
||||||
|
CallSummary summarizeCalls(Iterable<CallRecord> calls, {int topWindows = 2}) {
|
||||||
|
final list = calls.toList();
|
||||||
|
if (list.isEmpty) return CallSummary.empty;
|
||||||
|
list.sort((a, b) => b.at.compareTo(a.at));
|
||||||
|
var answered = 0, missed = 0, secs = 0;
|
||||||
|
final byHour = List<int>.filled(24, 0);
|
||||||
|
for (final c in list) {
|
||||||
|
if (c.direction == CallDirection.missed) {
|
||||||
|
missed++;
|
||||||
|
} else if (c.answered) {
|
||||||
|
answered++;
|
||||||
|
secs += c.seconds;
|
||||||
|
byHour[c.at.hour]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 연속한 시간을 묶는 대신 가장 많은 시각을 중심으로 한 시간 단위 창을 고른다.
|
||||||
|
final order = List<int>.generate(24, (i) => i)..sort((a, b) => byHour[b].compareTo(byHour[a]));
|
||||||
|
final windows = <CallWindow>[];
|
||||||
|
for (final h in order) {
|
||||||
|
if (byHour[h] == 0 || windows.length >= topWindows) break;
|
||||||
|
windows.add(CallWindow(h, h + 1, byHour[h]));
|
||||||
|
}
|
||||||
|
return CallSummary(
|
||||||
|
total: list.length,
|
||||||
|
answered: answered,
|
||||||
|
missed: missed,
|
||||||
|
totalSeconds: secs,
|
||||||
|
lastAt: list.first.at,
|
||||||
|
lastDirection: list.first.direction,
|
||||||
|
windows: windows,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String durationText(int seconds) {
|
||||||
|
if (seconds < 60) return '$seconds초';
|
||||||
|
final m = seconds ~/ 60;
|
||||||
|
if (m < 60) return '$m분';
|
||||||
|
return '${m ~/ 60}시간 ${m % 60}분';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import 'lunar.dart';
|
||||||
|
|
||||||
|
/// 날짜로 돌아오는 일정(생일·기일 등). 기본은 양력, [lunar] 면 음력 날짜.
|
||||||
|
enum EventKind { birthday, memorial, custom }
|
||||||
|
|
||||||
|
class FamilyEvent {
|
||||||
|
const FamilyEvent({
|
||||||
|
required this.personId,
|
||||||
|
required this.kind,
|
||||||
|
required this.month,
|
||||||
|
required this.day,
|
||||||
|
this.year,
|
||||||
|
this.title,
|
||||||
|
this.lunar = false,
|
||||||
|
this.leap = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String personId;
|
||||||
|
final EventKind kind;
|
||||||
|
final int month;
|
||||||
|
final int day;
|
||||||
|
|
||||||
|
/// 태어난 해·돌아가신 해. 모르면 null.
|
||||||
|
final int? year;
|
||||||
|
final String? title;
|
||||||
|
|
||||||
|
/// true 면 [month]/[day] 가 음력이다 (윤달 아님). 표 밖 연도는 양력처럼 센다.
|
||||||
|
final bool lunar;
|
||||||
|
|
||||||
|
/// 음력 윤달 날짜일 때 true. 그 해에 해당 윤달이 없으면 그 해는 건너뛴다.
|
||||||
|
final bool leap;
|
||||||
|
|
||||||
|
String get key => '$personId:${kind.name}:${lunar ? (leap ? 'LL' : 'L') : 'S'}$month-$day';
|
||||||
|
|
||||||
|
/// 2월 29일은 평년에 2월 28일로 센다.
|
||||||
|
DateTime occurrenceIn(int y) {
|
||||||
|
if (month == 2 && day == 29 && !_leap(y)) return DateTime(y, 2, 28);
|
||||||
|
return DateTime(y, month, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 오늘 포함 다음에 돌아오는 날.
|
||||||
|
DateTime nextFrom(DateTime today) {
|
||||||
|
final t = DateTime(today.year, today.month, today.day);
|
||||||
|
if (lunar) {
|
||||||
|
// 음력 해는 양력 해와 어긋나므로 앞뒤 해까지 보고 오늘 이후 가장 가까운 날을 고른다.
|
||||||
|
DateTime? best;
|
||||||
|
for (final y in [t.year - 1, t.year, t.year + 1]) {
|
||||||
|
final s = lunarToSolar(y, month, day, leap: leap);
|
||||||
|
if (s != null && !s.isBefore(t) && (best == null || s.isBefore(best))) best = s;
|
||||||
|
}
|
||||||
|
if (best != null) return best;
|
||||||
|
if (leap) {
|
||||||
|
// 윤달이 앞으로 멀리 있으면 표 안에서 다음 윤달 해를 찾는다.
|
||||||
|
for (var y = t.year + 2; y <= lunarLastYear; y++) {
|
||||||
|
final s = lunarToSolar(y, month, day, leap: true);
|
||||||
|
if (s != null) return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final a = occurrenceIn(t.year);
|
||||||
|
return a.isBefore(t) ? occurrenceIn(t.year + 1) : a;
|
||||||
|
}
|
||||||
|
|
||||||
|
String label(String name) {
|
||||||
|
if (title != null && title!.isNotEmpty) return '$name $title';
|
||||||
|
return switch (kind) {
|
||||||
|
EventKind.birthday => '$name 생일${lunar ? (leap ? '(음력 윤달)' : '(음력)') : ''}',
|
||||||
|
EventKind.memorial => '$name 기일${lunar ? (leap ? '(음력 윤달)' : '(음력)') : ''}',
|
||||||
|
EventKind.custom => name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _leap(int y) => (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpcomingEvent {
|
||||||
|
const UpcomingEvent(this.event, this.date, this.daysLeft, this.years);
|
||||||
|
final FamilyEvent event;
|
||||||
|
final DateTime date;
|
||||||
|
final int daysLeft;
|
||||||
|
|
||||||
|
/// 생일이면 나이, 기일이면 몇 주기. 해를 모르면 null.
|
||||||
|
final int? years;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [withinDays] 안에 다가오는 일정을 가까운 순으로.
|
||||||
|
List<UpcomingEvent> upcoming(Iterable<FamilyEvent> events, DateTime today, {int withinDays = 30}) {
|
||||||
|
final t = DateTime(today.year, today.month, today.day);
|
||||||
|
final out = <UpcomingEvent>[];
|
||||||
|
for (final e in events) {
|
||||||
|
final d = e.nextFrom(t);
|
||||||
|
final left = d.difference(t).inDays;
|
||||||
|
if (left > withinDays) continue;
|
||||||
|
out.add(UpcomingEvent(e, d, left, e.year == null ? null : d.year - e.year!));
|
||||||
|
}
|
||||||
|
out.sort((a, b) => a.daysLeft.compareTo(b.daysLeft));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "오늘", "내일", "3일 후".
|
||||||
|
String dayWord(int left) => left == 0 ? '오늘' : (left == 1 ? '내일' : '$left일 후');
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/// 음력 ↔ 양력 변환 (1951~2049년). 표는 공개 한국 음양력 데이터로 만든 것이다.
|
||||||
|
///
|
||||||
|
/// 한 해마다 "연:설날(월일):윤달(0=없음):달 길이 비트(1=30일)" 이다.
|
||||||
|
/// 비트 순서는 달 순서이며, 윤달은 같은 번호 달 바로 뒤에 들어간다.
|
||||||
|
class LunarDate {
|
||||||
|
const LunarDate(this.year, this.month, this.day, {this.leap = false});
|
||||||
|
final int year, month, day;
|
||||||
|
final bool leap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is LunarDate && other.year == year && other.month == month && other.day == day && other.leap == leap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(year, month, day, leap);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int lunarFirstYear = 1951;
|
||||||
|
const int lunarLastYear = 2049;
|
||||||
|
|
||||||
|
class _Year {
|
||||||
|
_Year(this.newYear, this.leapMonth, this.bits);
|
||||||
|
final DateTime newYear;
|
||||||
|
final int leapMonth;
|
||||||
|
final int bits;
|
||||||
|
|
||||||
|
/// 달 목록: (달 번호, 윤달인지, 길이).
|
||||||
|
List<(int, bool, int)> get months {
|
||||||
|
final out = <(int, bool, int)>[];
|
||||||
|
var i = 0;
|
||||||
|
for (var m = 1; m <= 12; m++) {
|
||||||
|
out.add((m, false, (bits >> i++ & 1) == 1 ? 30 : 29));
|
||||||
|
if (m == leapMonth) out.add((m, true, (bits >> i++ & 1) == 1 ? 30 : 29));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<int, _Year>? _years;
|
||||||
|
|
||||||
|
Map<int, _Year> _table() {
|
||||||
|
final m = <int, _Year>{};
|
||||||
|
for (final e in _raw.split(',')) {
|
||||||
|
final p = e.split(':');
|
||||||
|
final md = int.parse(p[1]);
|
||||||
|
final y = int.parse(p[0]);
|
||||||
|
m[y] = _Year(DateTime(y, md ~/ 100, md - (md ~/ 100) * 100), int.parse(p[2]), int.parse(p[3]));
|
||||||
|
}
|
||||||
|
return _years ??= m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 음력 → 양력. 표 밖이거나 없는 날이면 null. 그 달에 [day] 일이 없으면(30일 → 29일 달) 마지막 날로 본다.
|
||||||
|
DateTime? lunarToSolar(int year, int month, int day, {bool leap = false}) {
|
||||||
|
final y = _table()[year];
|
||||||
|
if (y == null || month < 1 || month > 12 || day < 1 || day > 30) return null;
|
||||||
|
var offset = 0;
|
||||||
|
for (final (m, l, len) in y.months) {
|
||||||
|
if (m == month && l == leap) {
|
||||||
|
final d = day > len ? len : day;
|
||||||
|
return DateTime(y.newYear.year, y.newYear.month, y.newYear.day + offset + d - 1);
|
||||||
|
}
|
||||||
|
offset += len;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 양력 → 음력. 표 밖이면 null.
|
||||||
|
LunarDate? solarToLunar(DateTime date) {
|
||||||
|
final d = DateTime.utc(date.year, date.month, date.day);
|
||||||
|
for (final year in [date.year, date.year - 1]) {
|
||||||
|
final y = _table()[year];
|
||||||
|
if (y == null) continue;
|
||||||
|
final start = DateTime.utc(y.newYear.year, y.newYear.month, y.newYear.day);
|
||||||
|
if (d.isBefore(start)) continue;
|
||||||
|
var left = d.difference(start).inDays;
|
||||||
|
for (final (m, l, len) in y.months) {
|
||||||
|
if (left < len) return LunarDate(year, m, left + 1, leap: l);
|
||||||
|
left -= len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String _raw = '1951:206:0:2733,1952:127:5:5482,1953:214:0:3506,1954:204:0:3492,1955:124:3:7497,1956:212:0:3402,1957:131:8:6805,1958:219:0:2710,1959:208:0:1366,1960:128:6:2741,1961:215:0:2773,1962:205:0:1746,1963:125:4:3749,1964:213:0:3749,1965:202:0:3658,1966:122:3:3222,1967:209:0:2715,1968:130:7:5462,1969:217:0:1386,1970:206:0:2905,1971:127:5:5970,1972:215:0:1874,1973:203:0:1829,1974:123:4:5707,1975:211:0:2635,1976:131:8:4779,1977:218:0:685,1978:207:0:1387,1979:128:6:2921,1980:216:0:3497,1981:205:0:3474,1982:125:4:6949,1983:213:0:3365,1984:202:10:6733,1985:220:0:2646,1986:209:0:694,1987:129:6:5549,1988:218:0:1748,1989:206:0:3497,1990:127:5:7570,1991:215:0:3730,1992:204:0:3366,1993:123:3:2646,1994:210:0:2647,1995:131:8:4790,1996:219:0:2906,1997:208:0:1748,1998:128:5:3785,1999:216:0:1865,2000:205:0:1683,2001:124:4:5415,2002:212:0:1323,2003:201:0:2651,2004:122:2:5466,2005:209:0:874,2006:129:7:6997,2007:218:0:2980,2008:207:0:2889,2009:126:5:6803,2010:214:0:2709,2011:203:0:1325,2012:123:3:2653,2013:210:0:2733,2014:131:9:5546,2015:219:0:1490,2016:208:0:3493,2017:128:5:7498,2018:216:0:3402,2019:205:0:2709,2020:125:4:5421,2021:212:0:1366,2022:201:0:2741,2023:122:2:5546,2024:210:0:1746,2025:129:6:3749,2026:217:0:3749,2027:207:0:3658,2028:127:5:3222,2029:213:0:3227,2030:203:0:1370,2031:123:3:2773,2032:211:0:2921,2033:131:11:5970,2034:219:0:1874,2035:208:0:2853,2036:128:6:5707,2037:215:0:2635,2038:204:0:1195,2039:124:5:1371,2040:212:0:1389,2041:201:0:2921,2042:122:2:6994,2043:210:0:3474,2044:130:7:7461,2045:217:0:3365,2046:206:0:2637,2047:126:5:5293,2048:214:0:694,2049:202:0:1461';
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import 'event.dart';
|
||||||
|
|
||||||
|
/// 한 번 울릴 알림.
|
||||||
|
class ReminderPlan {
|
||||||
|
const ReminderPlan(this.key, this.fireAt, this.leadDays, this.text);
|
||||||
|
final String key;
|
||||||
|
final DateTime fireAt;
|
||||||
|
final int leadDays;
|
||||||
|
final String text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 설정에 적은 "1일 전, 2일 전 …" 글자를 숫자로. 0 은 당일. 잘못된 칸은 버린다.
|
||||||
|
List<int> parseLeadDays(String? raw) {
|
||||||
|
if (raw == null) return const [];
|
||||||
|
final set = <int>{};
|
||||||
|
for (final p in raw.split(RegExp(r'[,\s]+'))) {
|
||||||
|
final n = int.tryParse(p.replaceAll('일', '').replaceAll('전', ''));
|
||||||
|
if (n != null && n >= 0 && n <= 365) set.add(n);
|
||||||
|
}
|
||||||
|
return (set.toList()..sort((a, b) => b.compareTo(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
String leadDaysText(List<int> days) => days.join(', ');
|
||||||
|
|
||||||
|
/// 앞으로 [horizonDays] 안에 울려야 할 알림 전체. 이미 지난 시각은 뺀다.
|
||||||
|
/// [nameOf] 로 사람 이름을 얻는다. 알림은 [hour]시(기본 9시)에 울린다.
|
||||||
|
List<ReminderPlan> planReminders(
|
||||||
|
Iterable<FamilyEvent> events,
|
||||||
|
DateTime now,
|
||||||
|
List<int> leadDays,
|
||||||
|
String Function(String personId) nameOf, {
|
||||||
|
int horizonDays = 60,
|
||||||
|
int hour = 9,
|
||||||
|
}) {
|
||||||
|
final out = <ReminderPlan>[];
|
||||||
|
for (final e in events) {
|
||||||
|
final date = e.nextFrom(now);
|
||||||
|
for (final lead in leadDays) {
|
||||||
|
final fire = DateTime(date.year, date.month, date.day - lead, hour);
|
||||||
|
if (!fire.isAfter(now)) continue;
|
||||||
|
if (fire.difference(now).inDays > horizonDays) continue;
|
||||||
|
final when = lead == 0 ? '오늘' : (lead == 1 ? '내일' : '$lead일 뒤');
|
||||||
|
out.add(ReminderPlan('${e.key}@$lead', fire, lead, '$when ${e.label(nameOf(e.personId))}입니다'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort((a, b) => a.fireAt.compareTo(b.fireAt));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
/// 유추: "어디서 봤지?" — 내 명함과 상대 명함을 견주어 사이를 추측한다.
|
||||||
|
///
|
||||||
|
/// 저장소·화면을 모르는 순수 계산이라 따로 시험하고 그대로 어디서나 쓴다.
|
||||||
|
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/card_snapshot.dart';
|
||||||
|
export 'src/guess.dart';
|
||||||
|
export 'src/inferer.dart';
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/// 유추에 쓰는 명함 내용. 저장소 모양과 상관없이 종류와 항목(이름→값)만 안다.
|
||||||
|
class CardSnapshot {
|
||||||
|
const CardSnapshot(this.kind, this.fields);
|
||||||
|
|
||||||
|
final String kind;
|
||||||
|
final Map<String, String> fields;
|
||||||
|
|
||||||
|
String? operator [](String key) {
|
||||||
|
final v = fields[key]?.trim();
|
||||||
|
return (v == null || v.isEmpty) ? null : v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/// "어디서 봤지?"의 한 가지 추측.
|
||||||
|
class Guess {
|
||||||
|
const Guess({
|
||||||
|
required this.relation,
|
||||||
|
required this.detail,
|
||||||
|
required this.confidence,
|
||||||
|
required this.reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String relation; // 동창 | 동문 | 직장 동료 | 전 직장 동료 | 같은 모임 | 같은 여행 | 가족
|
||||||
|
final String detail; // 근거가 된 이름 (예: 가람초 2007)
|
||||||
|
final int confidence; // 0~100
|
||||||
|
final String reason; // 사용자에게 보여 줄 설명
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$relation($detail) $confidence%';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이미 아는 사람 중 내 명함과 겹치는 사람 (자동 연동 추천).
|
||||||
|
class Recommendation {
|
||||||
|
const Recommendation(this.personId, this.guess);
|
||||||
|
|
||||||
|
final String personId;
|
||||||
|
final Guess guess;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
|
||||||
|
import 'card_snapshot.dart';
|
||||||
|
import 'guess.dart';
|
||||||
|
|
||||||
|
/// 내 명함들과 상대 명함들을 견주어 어떤 사이인지 추측한다.
|
||||||
|
/// 저장소·화면과 상관없는 순수 계산이다.
|
||||||
|
class Inferer {
|
||||||
|
const Inferer();
|
||||||
|
|
||||||
|
static final _suffix = RegExp(r'(초등학교|중학교|고등학교|대학원|대학교|학교|초|중|고|대)$');
|
||||||
|
|
||||||
|
/// 학교·회사·모임 이름을 비교하기 좋게 다듬는다. (가람 초등학교 = 가람초)
|
||||||
|
static String normalize(String name) {
|
||||||
|
var s = name.toLowerCase().replaceAll(RegExp(r'\s+'), '');
|
||||||
|
s = s.replaceAll(RegExp(r'\(주\)|주식회사|㈜'), '');
|
||||||
|
return s.replaceFirst(_suffix, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 신뢰도 높은 순으로 돌려준다. 같은 근거는 한 번만.
|
||||||
|
List<Guess> infer({required List<CardSnapshot> mine, required List<CardSnapshot> theirs}) {
|
||||||
|
final out = <String, Guess>{};
|
||||||
|
void add(Guess g) {
|
||||||
|
final k = '${g.relation}|${g.detail}';
|
||||||
|
final old = out[k];
|
||||||
|
if (old == null || old.confidence < g.confidence) out[k] = g;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final t in theirs) {
|
||||||
|
if (t.kind == CardKinds.family) {
|
||||||
|
add(Guess(
|
||||||
|
relation: '가족',
|
||||||
|
detail: t[CardKeys.relation] ?? '',
|
||||||
|
confidence: 95,
|
||||||
|
reason: '가족 명함을 받았어요',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (final m in mine) {
|
||||||
|
_school(m, t, add);
|
||||||
|
_company(m, t, add);
|
||||||
|
_named(m, t, CardKeys.group, '같은 모임', add);
|
||||||
|
_named(m, t, CardKeys.trip, '같은 여행', add);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final list = out.values.toList()..sort((a, b) => b.confidence.compareTo(a.confidence));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _school(CardSnapshot m, CardSnapshot t, void Function(Guess) add) {
|
||||||
|
final a = m[CardKeys.school], b = t[CardKeys.school];
|
||||||
|
if (a == null || b == null || normalize(a) != normalize(b)) return;
|
||||||
|
final ya = m[CardKeys.year], yb = t[CardKeys.year];
|
||||||
|
if (ya != null && yb != null && ya == yb) {
|
||||||
|
add(Guess(relation: '동창', detail: '$b $yb', confidence: 95, reason: '$b 에서 같은 해($yb)에 다녔어요'));
|
||||||
|
} else if (ya != null && yb != null) {
|
||||||
|
add(Guess(relation: '동문', detail: '$b ($yb)', confidence: 70, reason: '$b 동문이에요 (나 $ya, 상대 $yb)'));
|
||||||
|
} else {
|
||||||
|
add(Guess(relation: '동문', detail: b, confidence: 60, reason: '$b 동문이에요'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _company(CardSnapshot m, CardSnapshot t, void Function(Guess) add) {
|
||||||
|
final a = m[CardKeys.company], b = t[CardKeys.company];
|
||||||
|
if (a == null || b == null || normalize(a) != normalize(b)) return;
|
||||||
|
final former = m[CardKeys.former] == 'true' || t[CardKeys.former] == 'true';
|
||||||
|
add(Guess(
|
||||||
|
relation: former ? '전 직장 동료' : '직장 동료',
|
||||||
|
detail: b,
|
||||||
|
confidence: former ? 80 : 90,
|
||||||
|
reason: former ? '$b 에서 함께 일한 적이 있어요' : '$b 에서 함께 일해요',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _named(CardSnapshot m, CardSnapshot t, String key, String relation, void Function(Guess) add) {
|
||||||
|
final a = m[key], b = t[key];
|
||||||
|
if (a == null || b == null || normalize(a) != normalize(b)) return;
|
||||||
|
add(Guess(relation: relation, detail: b, confidence: 85, reason: '$b 에 함께했어요'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이미 아는 사람들 중 나와 겹치는 사람을 찾는다 (자동 연동 추천). 사람마다 가장 그럴듯한 것 하나.
|
||||||
|
List<Recommendation> recommend({
|
||||||
|
required List<CardSnapshot> mine,
|
||||||
|
required Map<String, List<CardSnapshot>> others,
|
||||||
|
int minConfidence = 60,
|
||||||
|
}) {
|
||||||
|
final out = <Recommendation>[];
|
||||||
|
for (final e in others.entries) {
|
||||||
|
final guesses = infer(mine: mine, theirs: e.value);
|
||||||
|
if (guesses.isNotEmpty && guesses.first.confidence >= minConfidence) {
|
||||||
|
out.add(Recommendation(e.key, guesses.first));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort((a, b) => b.guess.confidence.compareTo(a.guess.confidence));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
/// 키: 기기 보안 저장소의 키 관리, 단어 12개 형태의 복구 코드
|
||||||
|
///
|
||||||
|
/// core 의 KeyProvider 약속을 지키는 구현이다. 이 모듈을 다른 구현으로 바꾸거나 빼도
|
||||||
|
/// KeyProvider 만 쓰는 모듈은 바뀌지 않는다.
|
||||||
|
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/identity_keys.dart';
|
||||||
|
export 'src/local_identity.dart';
|
||||||
|
export 'src/local_key_provider.dart';
|
||||||
|
export 'src/recovery_phrase.dart' show isValidRecoveryPhrase, normalizePhrase;
|
||||||
|
export 'src/recovery_shares.dart';
|
||||||
|
export 'src/shamir.dart';
|
||||||
|
export 'src/shared_cipher.dart';
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
|
||||||
|
String bytesToHex(List<int> bytes) =>
|
||||||
|
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
|
||||||
|
List<int> hexToBytes(String hex) => [
|
||||||
|
for (var i = 0; i < hex.length; i += 2) int.parse(hex.substring(i, i + 2), radix: 16),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 비밀값 하나에서 용도([label])별로 서로 다른 키를 만든다 (HKDF-SHA256).
|
||||||
|
/// 용도가 다르면 키도 완전히 달라서, 한 키가 노출돼도 다른 용도는 안전하다.
|
||||||
|
Future<List<int>> deriveKeyBytes(List<int> secret, String label, {int length = 32}) async {
|
||||||
|
final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: length);
|
||||||
|
final key = await hkdf.deriveKey(
|
||||||
|
secretKey: SecretKey(secret),
|
||||||
|
nonce: const <int>[],
|
||||||
|
info: utf8.encode(label),
|
||||||
|
);
|
||||||
|
return key.extractBytes();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
|
||||||
|
/// 용도 이름으로 비밀값을 만들어 주는 함수. 기본 비밀에서 용도별로 서로 다른 값이 나온다.
|
||||||
|
typedef SecretDeriver = Future<List<int>> Function(String label);
|
||||||
|
|
||||||
|
/// 내 신원 키(X25519). 기본 비밀에서 만들어지므로 복구 코드로 되살리면 같은 키가 나온다.
|
||||||
|
/// 공개 키만 상대에게 알리고, 비밀 키는 이 객체 밖으로 나가지 않는다.
|
||||||
|
class IdentityKeys {
|
||||||
|
IdentityKeys(this._derive);
|
||||||
|
|
||||||
|
static const label = 'friendly_map/identity/v1';
|
||||||
|
|
||||||
|
final SecretDeriver _derive;
|
||||||
|
SimpleKeyPair? _pair;
|
||||||
|
|
||||||
|
Future<SimpleKeyPair> keyPair() async =>
|
||||||
|
_pair ??= await X25519().newKeyPairFromSeed(await _derive(label));
|
||||||
|
|
||||||
|
Future<List<int>> publicKeyBytes() async => (await (await keyPair()).extractPublicKey()).bytes;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
|
||||||
|
import 'identity_keys.dart';
|
||||||
|
|
||||||
|
/// 내 공개 키를 내주는 [IdentityProvider] 구현.
|
||||||
|
class LocalIdentityProvider implements IdentityProvider {
|
||||||
|
LocalIdentityProvider(this._keys);
|
||||||
|
|
||||||
|
final IdentityKeys _keys;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> myPublicKey() async => base64Encode(await _keys.publicKeyBytes());
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
import 'package:friendly_map/modules/platform/platform.dart';
|
||||||
|
|
||||||
|
import 'encoding.dart';
|
||||||
|
import 'recovery_phrase.dart';
|
||||||
|
|
||||||
|
/// 12단어 복구 코드에서 키를 만드는 [KeyProvider] 구현.
|
||||||
|
/// 코드의 원본 값은 기기 보안 저장소에만 두고, 저장소 암호는 쓸 때마다 만들어 낸다.
|
||||||
|
class LocalKeyProvider implements KeyProvider {
|
||||||
|
LocalKeyProvider(this._store);
|
||||||
|
|
||||||
|
static const _entropyName = 'master_entropy_v1';
|
||||||
|
static const _databaseLabel = 'friendly_map/database/v1';
|
||||||
|
static const _mailboxLabel = 'friendly_map/mailbox/v1';
|
||||||
|
|
||||||
|
final SecureKeyStore _store;
|
||||||
|
|
||||||
|
Future<bool> isSetUp() async => (await _store.read(_entropyName)) != null;
|
||||||
|
|
||||||
|
/// 처음 한 번: 새 복구 코드를 만들어 기기에 맡기고, 사용자에게 보여 줄 12단어를 돌려준다.
|
||||||
|
Future<String> setUpNew() async {
|
||||||
|
final phrase = newRecoveryPhrase();
|
||||||
|
await _store.write(_entropyName, phraseToEntropyHex(phrase));
|
||||||
|
return phrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 새 기기에서 12단어로 복구한다. 코드가 올바르지 않으면 false.
|
||||||
|
Future<bool> restore(String phrase) async {
|
||||||
|
if (!isValidRecoveryPhrase(phrase)) return false;
|
||||||
|
await _store.write(_entropyName, phraseToEntropyHex(phrase));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 설정에서 복구 코드를 다시 확인할 때. 아직 만들지 않았으면 null.
|
||||||
|
Future<String?> showPhrase() async {
|
||||||
|
final hex = await _store.read(_entropyName);
|
||||||
|
return hex == null ? null : entropyHexToPhrase(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 기본 비밀에서 [label] 용도의 값을 만든다 (신원 키, 보관함 비밀값 등). 기본 비밀 자체는 내주지 않는다.
|
||||||
|
Future<List<int>> derive(String label) async {
|
||||||
|
final hex = await _store.read(_entropyName);
|
||||||
|
if (hex == null) {
|
||||||
|
throw StateError('키가 아직 없습니다. setUpNew 또는 restore 를 먼저 하세요.');
|
||||||
|
}
|
||||||
|
return deriveKeyBytes(hexToBytes(hex), label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 보관함 서버에서 내 우편함을 열 때 내는 비밀값.
|
||||||
|
Future<String> mailboxToken() async => bytesToHex(await derive(_mailboxLabel));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> databasePassword() async => bytesToHex(await derive(_databaseLabel));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import 'package:bip39/bip39.dart' as bip39;
|
||||||
|
|
||||||
|
/// 단어 12개 복구 코드 (128비트). 이 코드가 곧 마스터 키의 원본이다.
|
||||||
|
String newRecoveryPhrase() => bip39.generateMnemonic();
|
||||||
|
|
||||||
|
/// 공백·대소문자를 정리한다.
|
||||||
|
String normalizePhrase(String phrase) =>
|
||||||
|
phrase.trim().toLowerCase().split(RegExp(r'\s+')).join(' ');
|
||||||
|
|
||||||
|
/// 단어 12개이고 검사 값이 맞는 코드인지.
|
||||||
|
bool isValidRecoveryPhrase(String phrase) {
|
||||||
|
final p = normalizePhrase(phrase);
|
||||||
|
return p.split(' ').length == 12 && bip39.validateMnemonic(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
String phraseToEntropyHex(String phrase) => bip39.mnemonicToEntropy(normalizePhrase(phrase));
|
||||||
|
|
||||||
|
String entropyHexToPhrase(String hex) => bip39.entropyToMnemonic(hex);
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
|
||||||
|
import 'encoding.dart';
|
||||||
|
import 'recovery_phrase.dart';
|
||||||
|
import 'shamir.dart';
|
||||||
|
|
||||||
|
/// 복구 코드(단어 12개)를 친구들에게 나눠 맡기는 조각 만들기·합치기.
|
||||||
|
///
|
||||||
|
/// 조각 한 장은 글자 하나다: `fmr1.<필요한 수>.<확인값>.<번호>:<값>`.
|
||||||
|
/// 확인값은 원래 비밀의 해시 앞 2바이트라서, 조각이 모자라거나 섞이면 합친 결과가 다르다는 걸 알 수 있다.
|
||||||
|
class RecoveryShares {
|
||||||
|
RecoveryShares._();
|
||||||
|
|
||||||
|
static const prefix = 'fmr1.';
|
||||||
|
|
||||||
|
static Future<String> _tag(List<int> entropy) async =>
|
||||||
|
bytesToHex((await Sha256().hash(entropy)).bytes.sublist(0, 2));
|
||||||
|
|
||||||
|
/// [phrase] 를 [n] 조각으로 나눈다. 그중 [k] 조각이 모이면 되살릴 수 있다.
|
||||||
|
static Future<List<String>> split(String phrase, {required int n, required int k}) async {
|
||||||
|
final entropy = hexToBytes(phraseToEntropyHex(phrase));
|
||||||
|
final tag = await _tag(entropy);
|
||||||
|
return [for (final s in Shamir.split(entropy, n: n, k: k)) '$prefix$k.$tag.${s.encode()}'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 조각 글자인지 (공백·줄바꿈 정리 후).
|
||||||
|
static bool looksLikeShare(String text) => text.trim().startsWith(prefix);
|
||||||
|
|
||||||
|
/// 조각 글자들을 합쳐 단어 12개를 돌려준다. 조각이 모자라거나 서로 다른 묶음이면 null.
|
||||||
|
static Future<String?> combine(Iterable<String> codes) async {
|
||||||
|
final parsed = <_Parsed>[];
|
||||||
|
for (final c in codes) {
|
||||||
|
final p = _Parsed.tryParse(c);
|
||||||
|
if (p != null) parsed.add(p);
|
||||||
|
}
|
||||||
|
if (parsed.isEmpty) return null;
|
||||||
|
final first = parsed.first;
|
||||||
|
// 같은 묶음(필요한 수·확인값이 같은 것)만, 같은 번호는 한 번만 쓴다.
|
||||||
|
final byX = <int, _Parsed>{
|
||||||
|
for (final p in parsed)
|
||||||
|
if (p.k == first.k && p.tag == first.tag) p.share.x: p,
|
||||||
|
};
|
||||||
|
if (byX.length < first.k) return null;
|
||||||
|
final entropy = Shamir.combine([for (final p in byX.values.take(first.k)) p.share]);
|
||||||
|
if (entropy.length != 16 || await _tag(entropy) != first.tag) return null;
|
||||||
|
return entropyHexToPhrase(bytesToHex(entropy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Parsed {
|
||||||
|
_Parsed(this.k, this.tag, this.share);
|
||||||
|
|
||||||
|
final int k;
|
||||||
|
final String tag;
|
||||||
|
final Share share;
|
||||||
|
|
||||||
|
static _Parsed? tryParse(String text) {
|
||||||
|
try {
|
||||||
|
final t = text.trim();
|
||||||
|
if (!t.startsWith(RecoveryShares.prefix)) return null;
|
||||||
|
final parts = t.substring(RecoveryShares.prefix.length).split('.');
|
||||||
|
if (parts.length != 3) return null;
|
||||||
|
final k = int.parse(parts[0]);
|
||||||
|
if (k < 2 || parts[1].length != 4) return null;
|
||||||
|
return _Parsed(k, parts[1], Share.decode(parts[2]));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
/// 비밀을 [n] 조각으로 나누고, 그중 [k] 조각만 모이면 되살리는 방법 (Shamir, GF(256)).
|
||||||
|
/// k-1 조각으로는 비밀에 대해 아무것도 알 수 없다. 이 파일은 암호 라이브러리 없이 순수 계산만 한다.
|
||||||
|
class Shamir {
|
||||||
|
Shamir._();
|
||||||
|
|
||||||
|
static final List<int> _exp = List.filled(512, 0);
|
||||||
|
static final List<int> _log = List.filled(256, 0);
|
||||||
|
static bool _ready = false;
|
||||||
|
|
||||||
|
static void _init() {
|
||||||
|
if (_ready) return;
|
||||||
|
var x = 1;
|
||||||
|
for (var i = 0; i < 255; i++) {
|
||||||
|
_exp[i] = x;
|
||||||
|
_log[x] = i;
|
||||||
|
var y = x << 1;
|
||||||
|
if (y & 0x100 != 0) y ^= 0x11b;
|
||||||
|
x = y ^ x; // x * 3
|
||||||
|
}
|
||||||
|
for (var i = 255; i < 512; i++) {
|
||||||
|
_exp[i] = _exp[i - 255];
|
||||||
|
}
|
||||||
|
_ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _mul(int a, int b) => (a == 0 || b == 0) ? 0 : _exp[_log[a] + _log[b]];
|
||||||
|
static int _div(int a, int b) => a == 0 ? 0 : _exp[_log[a] + 255 - _log[b]];
|
||||||
|
|
||||||
|
/// 조각 하나: 번호(1~255)와 비밀과 같은 길이의 값.
|
||||||
|
static List<Share> split(List<int> secret, {required int n, required int k, Random? random}) {
|
||||||
|
_init();
|
||||||
|
if (k < 2 || n < k || n > 255) throw ArgumentError('2 <= k <= n <= 255 이어야 합니다');
|
||||||
|
final rng = random ?? Random.secure();
|
||||||
|
final shares = [for (var i = 1; i <= n; i++) Share(i, Uint8List(secret.length))];
|
||||||
|
for (var pos = 0; pos < secret.length; pos++) {
|
||||||
|
final coeff = [secret[pos], for (var j = 1; j < k; j++) rng.nextInt(256)];
|
||||||
|
for (final s in shares) {
|
||||||
|
var y = 0;
|
||||||
|
for (var j = k - 1; j >= 0; j--) {
|
||||||
|
y = _mul(y, s.x) ^ coeff[j];
|
||||||
|
}
|
||||||
|
s.data[pos] = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shares;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 조각들로 비밀을 되살린다. 필요한 수보다 적으면 엉뚱한 값이 나온다 (호출하는 쪽에서 수를 확인할 것).
|
||||||
|
static List<int> combine(List<Share> shares) {
|
||||||
|
_init();
|
||||||
|
if (shares.isEmpty) return const [];
|
||||||
|
final len = shares.first.data.length;
|
||||||
|
final out = List<int>.filled(len, 0);
|
||||||
|
for (var pos = 0; pos < len; pos++) {
|
||||||
|
var acc = 0;
|
||||||
|
for (final si in shares) {
|
||||||
|
var num = 1, den = 1;
|
||||||
|
for (final sj in shares) {
|
||||||
|
if (identical(si, sj)) continue;
|
||||||
|
num = _mul(num, sj.x);
|
||||||
|
den = _mul(den, si.x ^ sj.x);
|
||||||
|
}
|
||||||
|
acc ^= _mul(si.data[pos], _div(num, den));
|
||||||
|
}
|
||||||
|
out[pos] = acc;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Share {
|
||||||
|
Share(this.x, this.data);
|
||||||
|
|
||||||
|
final int x;
|
||||||
|
final Uint8List data;
|
||||||
|
|
||||||
|
/// 글자로 바꾸기: '번호:16진수'
|
||||||
|
String encode() => '$x:${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
|
||||||
|
|
||||||
|
static Share decode(String text) {
|
||||||
|
final i = text.indexOf(':');
|
||||||
|
final hex = text.substring(i + 1);
|
||||||
|
return Share(
|
||||||
|
int.parse(text.substring(0, i)),
|
||||||
|
Uint8List.fromList([for (var p = 0; p < hex.length; p += 2) int.parse(hex.substring(p, p + 2), radix: 16)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
|
||||||
|
import 'identity_keys.dart';
|
||||||
|
|
||||||
|
/// 상대와 둘만 아는 키로 내용을 잠그고 푸는 [PayloadCipher] 구현.
|
||||||
|
///
|
||||||
|
/// 내 비밀 키 + 상대 공개 키(X25519)로 둘만 아는 값을 만들고, 거기에 단위 ID(명함 장·대화방)를
|
||||||
|
/// 섞어(HKDF) 단위마다 다른 키를 만든다. 잠금은 AES-256-GCM 이라 중간에 바뀌면 풀리지 않는다.
|
||||||
|
class SharedPayloadCipher implements PayloadCipher {
|
||||||
|
SharedPayloadCipher(this._keys);
|
||||||
|
|
||||||
|
final IdentityKeys _keys;
|
||||||
|
final X25519 _x = X25519();
|
||||||
|
final AesGcm _aes = AesGcm.with256bits();
|
||||||
|
final Hkdf _hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
||||||
|
|
||||||
|
Future<SecretKey> _unitKey(String peerPublicKey, String context) async {
|
||||||
|
final theirs = base64Decode(peerPublicKey);
|
||||||
|
final mine = await _keys.publicKeyBytes();
|
||||||
|
final shared = await _x.sharedSecretKey(
|
||||||
|
keyPair: await _keys.keyPair(),
|
||||||
|
remotePublicKey: SimplePublicKey(theirs, type: KeyPairType.x25519),
|
||||||
|
);
|
||||||
|
final first = _less(mine, theirs);
|
||||||
|
return _hkdf.deriveKey(
|
||||||
|
secretKey: shared,
|
||||||
|
nonce: first ? [...mine, ...theirs] : [...theirs, ...mine],
|
||||||
|
info: utf8.encode('friendly_map/unit/v1/$context'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _less(List<int> a, List<int> b) {
|
||||||
|
for (var i = 0; i < a.length && i < b.length; i++) {
|
||||||
|
if (a[i] != b[i]) return a[i] < b[i];
|
||||||
|
}
|
||||||
|
return a.length <= b.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> seal({
|
||||||
|
required String plain,
|
||||||
|
required String peerPublicKey,
|
||||||
|
required String context,
|
||||||
|
}) async {
|
||||||
|
final box = await _aes.encrypt(utf8.encode(plain), secretKey: await _unitKey(peerPublicKey, context));
|
||||||
|
return base64Encode(box.concatenation());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> open({
|
||||||
|
required String sealed,
|
||||||
|
required String peerPublicKey,
|
||||||
|
required String context,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final box = SecretBox.fromConcatenation(
|
||||||
|
base64Decode(sealed),
|
||||||
|
nonceLength: 12,
|
||||||
|
macLength: 16,
|
||||||
|
);
|
||||||
|
final clear = await _aes.decrypt(box, secretKey: await _unitKey(peerPublicKey, context));
|
||||||
|
return utf8.decode(clear);
|
||||||
|
} catch (_) {
|
||||||
|
return null; // 키가 다르거나 중간에 바뀜
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/// 가족 관계: 가계도(부모-자식·배우자)로 두 사람의 촌수와 호칭을 계산한다.
|
||||||
|
///
|
||||||
|
/// 순수 계산만 한다(저장·화면 없음). 어떤 모듈도 쓰지 않는다.
|
||||||
|
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/graph.dart';
|
||||||
|
export 'src/relation.dart' show Relation, relate;
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// 성별 (호칭을 정할 때만 쓴다). 모르면 [unknown].
|
||||||
|
enum Gender {
|
||||||
|
male,
|
||||||
|
female,
|
||||||
|
unknown;
|
||||||
|
|
||||||
|
static Gender parse(String? s) => Gender.values.where((g) => g.name == s).firstOrNull ?? Gender.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가계도의 한 사람. 출생 연도를 알면 형·동생, 큰아버지·작은아버지를 가른다.
|
||||||
|
class KinPerson {
|
||||||
|
const KinPerson(this.id, {this.gender = Gender.unknown, this.birthYear});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final Gender gender;
|
||||||
|
final int? birthYear;
|
||||||
|
|
||||||
|
KinPerson copyWith({Gender? gender, int? birthYear}) =>
|
||||||
|
KinPerson(id, gender: gender ?? this.gender, birthYear: birthYear ?? this.birthYear);
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {'id': id, 'g': gender.name, if (birthYear != null) 'y': birthYear};
|
||||||
|
|
||||||
|
factory KinPerson.fromJson(Map<String, Object?> j) =>
|
||||||
|
KinPerson(j['id'] as String, gender: Gender.parse(j['g'] as String?), birthYear: j['y'] as int?);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가계도: 사람, 부모-자식, 배우자만 안다. 나는 [meId] 로 들어간다. 계산은 relation.dart 가 한다.
|
||||||
|
class KinGraph {
|
||||||
|
KinGraph();
|
||||||
|
|
||||||
|
static const meId = 'me';
|
||||||
|
|
||||||
|
final Map<String, KinPerson> _people = {};
|
||||||
|
final Map<String, Set<String>> _parents = {}; // 자식 → 부모들
|
||||||
|
final Map<String, Set<String>> _children = {}; // 부모 → 자식들
|
||||||
|
final Map<String, Set<String>> _spouses = {};
|
||||||
|
|
||||||
|
Iterable<KinPerson> get people => _people.values;
|
||||||
|
KinPerson? person(String id) => _people[id];
|
||||||
|
bool has(String id) => _people.containsKey(id);
|
||||||
|
|
||||||
|
Set<String> parentsOf(String id) => _parents[id] ?? const {};
|
||||||
|
Set<String> childrenOf(String id) => _children[id] ?? const {};
|
||||||
|
Set<String> spousesOf(String id) => _spouses[id] ?? const {};
|
||||||
|
|
||||||
|
void put(KinPerson p) => _people[p.id] = p;
|
||||||
|
|
||||||
|
/// [parent] 가 [child] 의 부모. 자기 자신이나 이미 후손인 사람을 부모로 두는 순환은 거절(false).
|
||||||
|
bool addParent(String child, String parent) {
|
||||||
|
if (child == parent || !has(child) || !has(parent)) return false;
|
||||||
|
if (_isAncestor(child, parent)) return false; // child 가 parent 의 조상이면 순환
|
||||||
|
if (parentsOf(child).length >= 2 && !parentsOf(child).contains(parent)) return false; // 부모는 최대 둘
|
||||||
|
(_parents[child] ??= {}).add(parent);
|
||||||
|
(_children[parent] ??= {}).add(child);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool addSpouse(String a, String b) {
|
||||||
|
if (a == b || !has(a) || !has(b)) return false;
|
||||||
|
(_spouses[a] ??= {}).add(b);
|
||||||
|
(_spouses[b] ??= {}).add(a);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void removePerson(String id) {
|
||||||
|
for (final p in parentsOf(id).toList()) {
|
||||||
|
_children[p]?.remove(id);
|
||||||
|
}
|
||||||
|
for (final c in childrenOf(id).toList()) {
|
||||||
|
_parents[c]?.remove(id);
|
||||||
|
}
|
||||||
|
for (final s in spousesOf(id).toList()) {
|
||||||
|
_spouses[s]?.remove(id);
|
||||||
|
}
|
||||||
|
_parents.remove(id);
|
||||||
|
_children.remove(id);
|
||||||
|
_spouses.remove(id);
|
||||||
|
_people.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeParent(String child, String parent) {
|
||||||
|
_parents[child]?.remove(parent);
|
||||||
|
_children[parent]?.remove(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeSpouse(String a, String b) {
|
||||||
|
_spouses[a]?.remove(b);
|
||||||
|
_spouses[b]?.remove(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isAncestor(String maybeAncestor, String of) {
|
||||||
|
final seen = <String>{};
|
||||||
|
final stack = [of];
|
||||||
|
while (stack.isNotEmpty) {
|
||||||
|
final x = stack.removeLast();
|
||||||
|
if (!seen.add(x)) continue;
|
||||||
|
for (final p in parentsOf(x)) {
|
||||||
|
if (p == maybeAncestor) return true;
|
||||||
|
stack.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => jsonEncode({
|
||||||
|
'people': [for (final p in _people.values) p.toJson()],
|
||||||
|
'parent': [
|
||||||
|
for (final e in _parents.entries)
|
||||||
|
for (final p in e.value) [e.key, p],
|
||||||
|
],
|
||||||
|
'spouse': [
|
||||||
|
for (final e in _spouses.entries)
|
||||||
|
for (final s in e.value)
|
||||||
|
if (e.key.compareTo(s) < 0) [e.key, s],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory KinGraph.fromJson(String? text) {
|
||||||
|
final g = KinGraph();
|
||||||
|
if (text == null || text.isEmpty) return g;
|
||||||
|
try {
|
||||||
|
final j = (jsonDecode(text) as Map).cast<String, Object?>();
|
||||||
|
for (final p in (j['people'] as List).cast<Map>()) {
|
||||||
|
g.put(KinPerson.fromJson(p.cast<String, Object?>()));
|
||||||
|
}
|
||||||
|
for (final e in (j['parent'] as List).cast<List>()) {
|
||||||
|
g.addParent(e[0] as String, e[1] as String);
|
||||||
|
}
|
||||||
|
for (final e in (j['spouse'] as List).cast<List>()) {
|
||||||
|
g.addSpouse(e[0] as String, e[1] as String);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
return KinGraph(); // 깨진 데이터는 빈 가계도
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import 'graph.dart';
|
||||||
|
import 'titles.dart';
|
||||||
|
|
||||||
|
/// 두 사람 사이의 관계. [chon] 은 촌수(배우자는 0), 모르는 사이면 null.
|
||||||
|
class Relation {
|
||||||
|
const Relation(this.kind, this.title, {this.chon, this.up = 0, this.down = 0});
|
||||||
|
|
||||||
|
/// self | blood | spouse | inlaw | none
|
||||||
|
final String kind;
|
||||||
|
|
||||||
|
/// "b 는 a 의 [title]" 에 들어가는 말.
|
||||||
|
final String title;
|
||||||
|
final int? chon;
|
||||||
|
final int up; // 혈족일 때: a 에서 공통 조상까지 올라간 단계
|
||||||
|
final int down; // 공통 조상에서 b 까지 내려간 단계
|
||||||
|
|
||||||
|
bool get related => kind != 'none';
|
||||||
|
|
||||||
|
/// "삼촌 (3촌)" 처럼 읽기 좋게.
|
||||||
|
String get label => chon == null || kind == 'self' ? title : (chon == 0 ? title : '$title ($chon촌)');
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Anc {
|
||||||
|
const _Anc(this.dist, this.via);
|
||||||
|
final int dist;
|
||||||
|
final String? via; // 처음 올라간 부모
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, _Anc> _ancestors(KinGraph g, String id) {
|
||||||
|
final out = <String, _Anc>{id: const _Anc(0, null)};
|
||||||
|
var frontier = <String>[id];
|
||||||
|
var d = 0;
|
||||||
|
while (frontier.isNotEmpty) {
|
||||||
|
d++;
|
||||||
|
final next = <String>[];
|
||||||
|
for (final x in frontier) {
|
||||||
|
for (final p in g.parentsOf(x)) {
|
||||||
|
if (out.containsKey(p)) continue;
|
||||||
|
out[p] = _Anc(d, x == id ? p : out[x]!.via);
|
||||||
|
next.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 혈족 관계의 (올라간 단계, 내려간 단계, a 의 첫 부모, b 의 첫 부모). 이어지지 않으면 null.
|
||||||
|
({int up, int down, String? aVia, String? bVia})? bloodPath(KinGraph g, String a, String b) {
|
||||||
|
final aa = _ancestors(g, a), bb = _ancestors(g, b);
|
||||||
|
({int up, int down, String? aVia, String? bVia})? best;
|
||||||
|
for (final id in (aa.keys.toList()..sort())) {
|
||||||
|
final x = bb[id];
|
||||||
|
if (x == null) continue;
|
||||||
|
final up = aa[id]!.dist, down = x.dist;
|
||||||
|
if (best == null || up + down < best.up + best.down) {
|
||||||
|
best = (up: up, down: down, aVia: aa[id]!.via, bVia: x.via);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "b 는 a 의 무엇인가". 혈족, 배우자, 배우자 쪽 인척(혈족의 배우자·배우자의 혈족)을 찾는다.
|
||||||
|
Relation relate(KinGraph g, String a, String b) {
|
||||||
|
if (!g.has(a) || !g.has(b)) return const Relation('none', '');
|
||||||
|
if (a == b) return const Relation('self', '본인', chon: 0);
|
||||||
|
final blood = bloodPath(g, a, b);
|
||||||
|
if (blood != null) {
|
||||||
|
final chon = blood.up + blood.down;
|
||||||
|
return Relation('blood', bloodTitle(g, a, b, blood.up, blood.down, blood.aVia, blood.bVia), chon: chon, up: blood.up, down: blood.down);
|
||||||
|
}
|
||||||
|
if (g.spousesOf(a).contains(b)) return Relation('spouse', spouseTitle(g.person(b)!), chon: 0);
|
||||||
|
// b 가 a 의 혈족(또는 a 본인)의 배우자
|
||||||
|
Relation? best;
|
||||||
|
for (final s in g.spousesOf(b)) {
|
||||||
|
final r = s == a ? const Relation('self', '본인', chon: 0) : relate0(g, a, s);
|
||||||
|
if (r == null) continue;
|
||||||
|
final t = Relation('inlaw', spouseOfRelativeTitle(g, a, b, s, r), chon: r.chon);
|
||||||
|
if (best == null || (t.chon ?? 99) < (best.chon ?? 99)) best = t;
|
||||||
|
}
|
||||||
|
// b 가 a 의 배우자의 혈족
|
||||||
|
for (final s in g.spousesOf(a)) {
|
||||||
|
final r = relate0(g, s, b);
|
||||||
|
if (r == null) continue;
|
||||||
|
final t = Relation('inlaw', relativeOfSpouseTitle(g, a, b, s, r), chon: r.chon);
|
||||||
|
if (best == null || (t.chon ?? 99) < (best.chon ?? 99)) best = t;
|
||||||
|
}
|
||||||
|
return best ?? const Relation('none', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 혈족만 따진 관계 (배우자를 건너지 않는다).
|
||||||
|
Relation? relate0(KinGraph g, String a, String b) {
|
||||||
|
final p = bloodPath(g, a, b);
|
||||||
|
if (p == null) return null;
|
||||||
|
return Relation('blood', bloodTitle(g, a, b, p.up, p.down, p.aVia, p.bVia), chon: p.up + p.down, up: p.up, down: p.down);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import 'graph.dart';
|
||||||
|
import 'relation.dart';
|
||||||
|
|
||||||
|
Gender _g(KinGraph g, String id) => g.person(id)?.gender ?? Gender.unknown;
|
||||||
|
|
||||||
|
String _pick(Gender gender, String male, String female, String unknown) =>
|
||||||
|
switch (gender) { Gender.male => male, Gender.female => female, Gender.unknown => unknown };
|
||||||
|
|
||||||
|
/// x 가 y 보다 나이가 많은가. 출생 연도를 모르면 null.
|
||||||
|
bool? _older(KinGraph g, String x, String y) {
|
||||||
|
final a = g.person(x)?.birthYear, b = g.person(y)?.birthYear;
|
||||||
|
if (a == null || b == null || a == b) return null;
|
||||||
|
return a < b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 친가(아버지 쪽) · 외가(어머니 쪽) 앞말. 부모의 성별을 모르면 빈 글자.
|
||||||
|
String _side(KinGraph g, String? parent) =>
|
||||||
|
parent == null ? '' : _pick(_g(g, parent), '친', '외', '');
|
||||||
|
|
||||||
|
/// 형·누나·오빠·언니·동생. [aId] 입장에서 [bId] 를 부르는 말. 나이를 모르면 null.
|
||||||
|
String? _siblingWord(KinGraph g, String aId, String bId) {
|
||||||
|
final elder = _older(g, bId, aId);
|
||||||
|
if (elder == null) return null;
|
||||||
|
final ga = _g(g, aId), gb = _g(g, bId);
|
||||||
|
if (!elder) return _pick(gb, '남동생', '여동생', '동생');
|
||||||
|
return switch ((ga, gb)) {
|
||||||
|
(Gender.male, Gender.male) => '형',
|
||||||
|
(Gender.male, Gender.female) => '누나',
|
||||||
|
(Gender.female, Gender.male) => '오빠',
|
||||||
|
(Gender.female, Gender.female) => '언니',
|
||||||
|
(_, Gender.male) => '형·오빠',
|
||||||
|
(_, Gender.female) => '누나·언니',
|
||||||
|
_ => '손윗사람',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 혈족: [a] 에서 [up] 단계 올라가 [down] 단계 내려간 자리에 있는 [b] 를 [a] 가 부르는 말.
|
||||||
|
/// [aVia] 는 a 의 부모 중 공통 조상 쪽으로 올라간 사람, [bVia] 는 b 의 부모 중 같은 쪽 사람.
|
||||||
|
String bloodTitle(KinGraph g, String a, String b, int up, int down, String? aVia, String? bVia) {
|
||||||
|
final gb = _g(g, b);
|
||||||
|
if (up == 0) {
|
||||||
|
return switch (down) {
|
||||||
|
1 => _pick(gb, '아들', '딸', '자녀'),
|
||||||
|
2 => _pick(gb, '손자', '손녀', '손주'),
|
||||||
|
3 => _pick(gb, '증손자', '증손녀', '증손'),
|
||||||
|
_ => '$down대 후손',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (down == 0) {
|
||||||
|
final side = _side(g, aVia);
|
||||||
|
return switch (up) {
|
||||||
|
1 => _pick(gb, '아버지', '어머니', '부모'),
|
||||||
|
2 => _pick(gb, '$side할아버지', '$side할머니', '$side조부모'),
|
||||||
|
3 => _pick(gb, '증조할아버지', '증조할머니', '증조부모'),
|
||||||
|
4 => _pick(gb, '고조할아버지', '고조할머니', '고조부모'),
|
||||||
|
_ => '$up대 조상',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (up == 1 && down == 1) {
|
||||||
|
return _siblingWord(g, a, b) ?? _pick(gb, '형제', '자매', '형제자매');
|
||||||
|
}
|
||||||
|
if (up == 1 && down == 2) return _pick(gb, '조카', '조카딸', '조카');
|
||||||
|
if (up == 1 && down == 3) return '조카의 자녀';
|
||||||
|
if (up == 2 && down == 1) return _uncleAunt(g, b, aVia);
|
||||||
|
if (up == 2 && down == 2) {
|
||||||
|
final qGender = bVia == null ? Gender.unknown : _g(g, bVia);
|
||||||
|
final pGender = aVia == null ? Gender.unknown : _g(g, aVia);
|
||||||
|
final base = switch ((pGender, qGender)) {
|
||||||
|
(Gender.male, Gender.male) => '친사촌',
|
||||||
|
(Gender.male, Gender.female) => '고종사촌',
|
||||||
|
(Gender.female, Gender.male) => '외사촌',
|
||||||
|
(Gender.female, Gender.female) => '이종사촌',
|
||||||
|
_ => '사촌',
|
||||||
|
};
|
||||||
|
final w = _siblingWord(g, a, b);
|
||||||
|
return w == null ? base : '$base $w';
|
||||||
|
}
|
||||||
|
if (up == 2 && down == 3) return '사촌의 자녀';
|
||||||
|
if (up == 3 && down == 1) {
|
||||||
|
final side = aVia == null ? Gender.unknown : _g(g, aVia);
|
||||||
|
if (side == Gender.female) return _pick(gb, '외종조부', '이모할머니', '외종조부모');
|
||||||
|
return _pick(gb, '큰·작은할아버지', '고모할머니', '할아버지의 형제자매');
|
||||||
|
}
|
||||||
|
if (up == 3 && down == 2) {
|
||||||
|
final side = aVia == null ? Gender.unknown : _g(g, aVia);
|
||||||
|
if (side != Gender.male) return '부모의 사촌';
|
||||||
|
return _pick(gb, '당숙', '당고모', '아버지의 사촌');
|
||||||
|
}
|
||||||
|
if (up == 3 && down == 3) return '육촌';
|
||||||
|
if (up == 4 && down == 4) return '팔촌';
|
||||||
|
return '${up + down}촌 친척';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _uncleAunt(KinGraph g, String b, String? parent) {
|
||||||
|
final gb = _g(g, b);
|
||||||
|
final pg = parent == null ? Gender.unknown : _g(g, parent);
|
||||||
|
switch (pg) {
|
||||||
|
case Gender.male:
|
||||||
|
if (gb == Gender.female) return '고모';
|
||||||
|
if (gb == Gender.male) {
|
||||||
|
final elder = parent == null ? null : _older(g, b, parent);
|
||||||
|
return elder == null ? '삼촌(큰·작은아버지)' : (elder ? '큰아버지' : '작은아버지');
|
||||||
|
}
|
||||||
|
return '아버지의 형제자매';
|
||||||
|
case Gender.female:
|
||||||
|
return _pick(gb, '외삼촌', '이모', '어머니의 형제자매');
|
||||||
|
case Gender.unknown:
|
||||||
|
return _pick(gb, '삼촌', '고모·이모', '부모의 형제자매');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String spouseTitle(KinPerson b) => _pick(b.gender, '남편', '아내', '배우자');
|
||||||
|
|
||||||
|
/// b 가 a 의 혈족 s 의 배우자일 때 a 가 b 를 부르는 말.
|
||||||
|
String spouseOfRelativeTitle(KinGraph g, String a, String b, String s, Relation r) {
|
||||||
|
final gb = _g(g, b), ga = _g(g, a), gs = _g(g, s);
|
||||||
|
if (r.up == 0 && r.down == 1) return _pick(gb, '사위', '며느리', '자녀의 배우자');
|
||||||
|
if (r.up == 1 && r.down == 0) return _pick(gb, '새아버지(계부)', '새어머니(계모)', '부모의 배우자');
|
||||||
|
if (r.up == 1 && r.down == 1) {
|
||||||
|
final elder = _older(g, s, a);
|
||||||
|
if (ga == Gender.male) {
|
||||||
|
if (gs == Gender.male) return elder == null ? '형수·제수' : (elder ? '형수' : '제수');
|
||||||
|
if (gs == Gender.female) return elder == null ? '매형·매제' : (elder ? '매형' : '매제');
|
||||||
|
} else if (ga == Gender.female) {
|
||||||
|
if (gs == Gender.male) return elder == null ? '새언니·올케' : (elder ? '새언니' : '올케');
|
||||||
|
if (gs == Gender.female) return elder == null ? '형부·제부' : (elder ? '형부' : '제부');
|
||||||
|
}
|
||||||
|
return '형제자매의 배우자';
|
||||||
|
}
|
||||||
|
if (r.up == 2 && r.down == 1) {
|
||||||
|
final path = bloodPath(g, a, s);
|
||||||
|
final side = _side(g, path?.aVia);
|
||||||
|
if (gs == Gender.male) {
|
||||||
|
if (side == '외') return '외숙모';
|
||||||
|
final elder = path?.aVia == null ? null : _older(g, s, path!.aVia!);
|
||||||
|
return elder == null ? '숙모' : (elder ? '큰어머니' : '작은어머니');
|
||||||
|
}
|
||||||
|
if (gs == Gender.female) return side == '외' ? '이모부' : '고모부';
|
||||||
|
}
|
||||||
|
return '${r.title}의 배우자';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// b 가 a 의 배우자 s 의 혈족일 때 a 가 b 를 부르는 말. [r] 은 s 입장에서 본 b.
|
||||||
|
String relativeOfSpouseTitle(KinGraph g, String a, String b, String s, Relation r) {
|
||||||
|
final gb = _g(g, b), ga = _g(g, a);
|
||||||
|
if (r.up == 1 && r.down == 0) {
|
||||||
|
if (ga == Gender.male) return _pick(gb, '장인', '장모', '배우자의 부모');
|
||||||
|
if (ga == Gender.female) return _pick(gb, '시아버지', '시어머니', '배우자의 부모');
|
||||||
|
return '배우자의 부모';
|
||||||
|
}
|
||||||
|
if (r.up == 1 && r.down == 1) {
|
||||||
|
final elder = _older(g, b, s);
|
||||||
|
if (ga == Gender.male) {
|
||||||
|
if (gb == Gender.male) return '처남';
|
||||||
|
if (gb == Gender.female) return elder == null ? '처형·처제' : (elder ? '처형' : '처제');
|
||||||
|
} else if (ga == Gender.female) {
|
||||||
|
if (gb == Gender.male) return elder == null ? '시아주버니·시동생' : (elder ? '시아주버니' : '시동생');
|
||||||
|
if (gb == Gender.female) return '시누이';
|
||||||
|
}
|
||||||
|
return '배우자의 형제자매';
|
||||||
|
}
|
||||||
|
if (r.up == 0 && r.down == 1) return '배우자의 자녀';
|
||||||
|
return '배우자의 ${r.title}';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
/// 배치: 명함 틀, 홈 구성 — 모든 화면이 템플릿이고 사용자가 지우기·고치기·순서 바꾸기를 할 수 있게 한다.
|
||||||
|
///
|
||||||
|
/// 저장소를 모르고 TextStore 약속만 쓴다. 이 파일은 모듈의 공개 입구다.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/card_template.dart';
|
||||||
|
export 'src/home_item.dart';
|
||||||
|
export 'src/ordered_store.dart';
|
||||||
|
export 'src/text_store.dart';
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import 'package:friendly_map/modules/core/core.dart';
|
||||||
|
|
||||||
|
import 'ordered_store.dart';
|
||||||
|
import 'text_store.dart';
|
||||||
|
|
||||||
|
/// 명함에 넣을 수 있는 항목 하나.
|
||||||
|
class FieldDef {
|
||||||
|
const FieldDef(this.key, this.label, {this.hint = ''});
|
||||||
|
|
||||||
|
final String key;
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {'key': key, 'label': label, 'hint': hint};
|
||||||
|
|
||||||
|
factory FieldDef.fromJson(Map<String, Object?> m) =>
|
||||||
|
FieldDef(m['key'] as String, m['label'] as String, hint: (m['hint'] as String?) ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 명함 틀. 종류(개인·회사·학교·그룹·가족)마다 기본 틀이 있고, 사용자가 고치거나 새로 만든다.
|
||||||
|
class CardTemplate {
|
||||||
|
const CardTemplate({required this.id, required this.kind, required this.title, required this.fields});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String kind;
|
||||||
|
final String title;
|
||||||
|
final List<FieldDef> fields;
|
||||||
|
|
||||||
|
CardTemplate copyWith({String? title, List<FieldDef>? fields}) =>
|
||||||
|
CardTemplate(id: id, kind: kind, title: title ?? this.title, fields: fields ?? this.fields);
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() =>
|
||||||
|
{'id': id, 'kind': kind, 'title': title, 'fields': [for (final f in fields) f.toJson()]};
|
||||||
|
|
||||||
|
factory CardTemplate.fromJson(Map<String, Object?> m) => CardTemplate(
|
||||||
|
id: m['id'] as String,
|
||||||
|
kind: m['kind'] as String,
|
||||||
|
title: m['title'] as String,
|
||||||
|
fields: [for (final f in m['fields'] as List) FieldDef.fromJson((f as Map).cast<String, Object?>())],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 명함 틀에 넣을 수 있는 알려진 항목들 (유추에 쓰이는 이름이라 틀을 고쳐도 "어디서 봤지?"가 계속 동작한다).
|
||||||
|
List<FieldDef> knownFieldDefs() => const [
|
||||||
|
FieldDef(CardKeys.name, '이름'),
|
||||||
|
FieldDef(CardKeys.phone, '전화번호'),
|
||||||
|
FieldDef(CardKeys.email, '이메일'),
|
||||||
|
FieldDef(CardKeys.birthday, '생일', hint: '예: 03-14'),
|
||||||
|
FieldDef(CardKeys.company, '회사'),
|
||||||
|
FieldDef(CardKeys.position, '직책'),
|
||||||
|
FieldDef(CardKeys.former, '지금은 다니지 않음', hint: 'true 또는 비움'),
|
||||||
|
FieldDef(CardKeys.school, '학교'),
|
||||||
|
FieldDef(CardKeys.level, '과정', hint: '초등 · 중등 · 고등 · 대학 · 대학원'),
|
||||||
|
FieldDef(CardKeys.year, '연도', hint: '입학 또는 졸업 연도'),
|
||||||
|
FieldDef(CardKeys.major, '전공·반'),
|
||||||
|
FieldDef(CardKeys.group, '모임 이름'),
|
||||||
|
FieldDef(CardKeys.trip, '여행 이름'),
|
||||||
|
FieldDef(CardKeys.relation, '가족 관계', hint: '예: 어머니, 동생'),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<CardTemplate> defaultCardTemplates() => const [
|
||||||
|
CardTemplate(id: 'tpl-personal', kind: CardKinds.personal, title: '개인 명함', fields: [
|
||||||
|
FieldDef(CardKeys.name, '이름'),
|
||||||
|
FieldDef(CardKeys.phone, '전화번호'),
|
||||||
|
FieldDef(CardKeys.email, '이메일'),
|
||||||
|
FieldDef(CardKeys.birthday, '생일', hint: '예: 03-14'),
|
||||||
|
]),
|
||||||
|
CardTemplate(id: 'tpl-company', kind: CardKinds.company, title: '회사 명함', fields: [
|
||||||
|
FieldDef(CardKeys.name, '이름'),
|
||||||
|
FieldDef(CardKeys.company, '회사'),
|
||||||
|
FieldDef(CardKeys.position, '직책'),
|
||||||
|
FieldDef(CardKeys.former, '지금은 다니지 않음', hint: 'true 또는 비움'),
|
||||||
|
FieldDef(CardKeys.email, '이메일'),
|
||||||
|
]),
|
||||||
|
CardTemplate(id: 'tpl-school', kind: CardKinds.school, title: '학교 명함', fields: [
|
||||||
|
FieldDef(CardKeys.name, '이름'),
|
||||||
|
FieldDef(CardKeys.school, '학교'),
|
||||||
|
FieldDef(CardKeys.level, '과정', hint: '초등 · 중등 · 고등 · 대학 · 대학원'),
|
||||||
|
FieldDef(CardKeys.year, '연도', hint: '입학 또는 졸업 연도'),
|
||||||
|
FieldDef(CardKeys.major, '전공·반'),
|
||||||
|
]),
|
||||||
|
CardTemplate(id: 'tpl-group', kind: CardKinds.group, title: '그룹·여행 명함', fields: [
|
||||||
|
FieldDef(CardKeys.name, '이름'),
|
||||||
|
FieldDef(CardKeys.group, '모임 이름'),
|
||||||
|
FieldDef(CardKeys.trip, '여행 이름'),
|
||||||
|
]),
|
||||||
|
CardTemplate(id: 'tpl-family', kind: CardKinds.family, title: '가족 명함', fields: [
|
||||||
|
FieldDef(CardKeys.name, '이름'),
|
||||||
|
FieldDef(CardKeys.relation, '가족 관계', hint: '예: 어머니, 동생'),
|
||||||
|
FieldDef(CardKeys.phone, '전화번호'),
|
||||||
|
FieldDef(CardKeys.birthday, '생일'),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 명함 틀 목록 (사용자가 지우기·고치기·순서 바꾸기·새로 만들기 가능).
|
||||||
|
OrderedStore<CardTemplate> cardTemplateStore(TextStore store) => OrderedStore<CardTemplate>(
|
||||||
|
store: store,
|
||||||
|
key: 'layout_card_templates',
|
||||||
|
defaults: defaultCardTemplates,
|
||||||
|
toJson: (t) => t.toJson(),
|
||||||
|
fromJson: CardTemplate.fromJson,
|
||||||
|
idOf: (t) => t.id,
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import 'ordered_store.dart';
|
||||||
|
import 'text_store.dart';
|
||||||
|
|
||||||
|
/// 홈 화면의 한 구역.
|
||||||
|
class HomeItem {
|
||||||
|
const HomeItem({required this.id, required this.title, this.visible = true});
|
||||||
|
|
||||||
|
final String id; // chats | network | family | discover | cards | company | school | group
|
||||||
|
final String title;
|
||||||
|
final bool visible;
|
||||||
|
|
||||||
|
HomeItem copyWith({String? title, bool? visible}) =>
|
||||||
|
HomeItem(id: id, title: title ?? this.title, visible: visible ?? this.visible);
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {'id': id, 'title': title, 'visible': visible};
|
||||||
|
|
||||||
|
factory HomeItem.fromJson(Map<String, Object?> m) => HomeItem(
|
||||||
|
id: m['id'] as String,
|
||||||
|
title: m['title'] as String,
|
||||||
|
visible: (m['visible'] as bool?) ?? true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<HomeItem> defaultHomeItems() => const [
|
||||||
|
HomeItem(id: 'chats', title: '채팅'),
|
||||||
|
HomeItem(id: 'network', title: '인맥 관계도'),
|
||||||
|
HomeItem(id: 'family', title: '가족'),
|
||||||
|
HomeItem(id: 'gathering', title: '가족 모임'),
|
||||||
|
HomeItem(id: 'company', title: '회사'),
|
||||||
|
HomeItem(id: 'school', title: '학교'),
|
||||||
|
HomeItem(id: 'group', title: '그룹·여행'),
|
||||||
|
HomeItem(id: 'location', title: '위치'),
|
||||||
|
HomeItem(id: 'discover', title: '찾기·추천'),
|
||||||
|
HomeItem(id: 'cards', title: '내 명함'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 홈 구성 (롱프레스 편집: 숨기기·지우기·순서 바꾸기·이름 바꾸기).
|
||||||
|
OrderedStore<HomeItem> homeLayoutStore(TextStore store) => OrderedStore<HomeItem>(
|
||||||
|
store: store,
|
||||||
|
key: 'layout_home',
|
||||||
|
defaults: defaultHomeItems,
|
||||||
|
toJson: (i) => i.toJson(),
|
||||||
|
fromJson: HomeItem.fromJson,
|
||||||
|
idOf: (i) => i.id,
|
||||||
|
legacyIds: const ['chats', 'network', 'family', 'company', 'school', 'group', 'location', 'discover', 'cards'],
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'text_store.dart';
|
||||||
|
|
||||||
|
/// 사용자가 지우고, 고치고, 순서를 바꿀 수 있는 목록을 저장하는 공통 도구.
|
||||||
|
/// 명함 템플릿과 홈 구성이 모두 이것으로 움직인다. 처음엔 [defaults] 로 시작하고,
|
||||||
|
/// 사용자가 바꾼 뒤에는 바뀐 목록을 쓴다. [reset] 으로 언제든 처음으로 되돌린다.
|
||||||
|
class OrderedStore<T> {
|
||||||
|
OrderedStore({
|
||||||
|
required this.store,
|
||||||
|
required this.key,
|
||||||
|
required this.defaults,
|
||||||
|
required this.toJson,
|
||||||
|
required this.fromJson,
|
||||||
|
required this.idOf,
|
||||||
|
this.legacyIds = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextStore store;
|
||||||
|
final String key;
|
||||||
|
final List<T> Function() defaults;
|
||||||
|
final Map<String, Object?> Function(T) toJson;
|
||||||
|
final T Function(Map<String, Object?>) fromJson;
|
||||||
|
final String Function(T) idOf;
|
||||||
|
|
||||||
|
/// 이 장치가 "새 기본 항목 알아채기"를 쓰기 전에 이미 있던 기본 항목들의 ID.
|
||||||
|
/// 저장된 기록이 있는데 본 적 있는 목록이 없으면 이 ID 들은 이미 본 것으로 친다(지운 항목이 되살아나지 않게).
|
||||||
|
final List<String> legacyIds;
|
||||||
|
|
||||||
|
String get _seenKey => '$key.seen';
|
||||||
|
|
||||||
|
Future<List<T>> load() async {
|
||||||
|
final text = await store.read(key);
|
||||||
|
if (text == null) return List.of(defaults());
|
||||||
|
try {
|
||||||
|
final list = [
|
||||||
|
for (final m in (jsonDecode(text) as List)) fromJson((m as Map).cast<String, Object?>()),
|
||||||
|
];
|
||||||
|
return await _withNewDefaults(list);
|
||||||
|
} catch (_) {
|
||||||
|
return List.of(defaults()); // 깨졌으면 처음 상태로
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 앱이 업데이트되며 생긴 새 기본 항목을, 아직 본 적 없는 것만 알맞은 자리에 끼운다.
|
||||||
|
/// 사용자가 지운 항목은 이미 본 것이라 되살아나지 않는다.
|
||||||
|
Future<List<T>> _withNewDefaults(List<T> list) async {
|
||||||
|
final defs = defaults();
|
||||||
|
final seenText = await store.read(_seenKey);
|
||||||
|
final seen = seenText == null
|
||||||
|
? (legacyIds.isEmpty ? {for (final d in defs) idOf(d)} : legacyIds.toSet())
|
||||||
|
: (jsonDecode(seenText) as List).cast<String>().toSet();
|
||||||
|
final fresh = [for (final d in defs) if (!seen.contains(idOf(d))) d];
|
||||||
|
if (fresh.isEmpty) {
|
||||||
|
if (seenText == null) await store.write(_seenKey, jsonEncode(seen.toList()));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
for (final d in fresh) {
|
||||||
|
final at = defs.indexWhere((e) => idOf(e) == idOf(d));
|
||||||
|
final prev = at > 0 ? idOf(defs[at - 1]) : null;
|
||||||
|
final i = prev == null ? -1 : list.indexWhere((e) => idOf(e) == prev);
|
||||||
|
list.insert(i >= 0 ? i + 1 : list.length, d);
|
||||||
|
seen.add(idOf(d));
|
||||||
|
}
|
||||||
|
await store.write(_seenKey, jsonEncode(seen.toList()));
|
||||||
|
await store.write(key, jsonEncode([for (final i in list) toJson(i)]));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> save(List<T> items) async {
|
||||||
|
await store.write(key, jsonEncode([for (final i in items) toJson(i)]));
|
||||||
|
if (await store.read(_seenKey) == null) {
|
||||||
|
await store.write(_seenKey, jsonEncode([for (final d in defaults()) idOf(d)]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<T>> reset() async {
|
||||||
|
final d = List.of(defaults());
|
||||||
|
await save(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 같은 ID 가 있으면 바꾸고, 없으면 끝에 붙인다.
|
||||||
|
Future<List<T>> upsert(T item) async {
|
||||||
|
final list = await load();
|
||||||
|
final i = list.indexWhere((e) => idOf(e) == idOf(item));
|
||||||
|
if (i >= 0) {
|
||||||
|
list[i] = item;
|
||||||
|
} else {
|
||||||
|
list.add(item);
|
||||||
|
}
|
||||||
|
await save(list);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<T>> remove(String id) async {
|
||||||
|
final list = (await load())..removeWhere((e) => idOf(e) == id);
|
||||||
|
await save(list);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [from] 번째를 [to] 번째 자리로 옮긴다 (목록 끝을 넘으면 끝으로).
|
||||||
|
Future<List<T>> move(int from, int to) async {
|
||||||
|
final list = await load();
|
||||||
|
if (from < 0 || from >= list.length) return list;
|
||||||
|
final item = list.removeAt(from);
|
||||||
|
list.insert(to.clamp(0, list.length), item);
|
||||||
|
await save(list);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
/// 글자 하나를 이름으로 저장·읽는 약속. 배치 모듈은 저장소를 모르고 이 약속만 쓴다.
|
||||||
|
abstract class TextStore {
|
||||||
|
Future<String?> read(String key);
|
||||||
|
Future<void> write(String key, String value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 메모리에만 두는 구현 (시험·미리보기용).
|
||||||
|
class MemoryTextStore implements TextStore {
|
||||||
|
final Map<String, String> data = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> read(String key) async => data[key];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(String key, String value) async => data[key] = value;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
/// 플랫폼: 기기마다 달라지는 기능의 약속(인터페이스)
|
||||||
|
///
|
||||||
|
/// 안드로이드 구현은 platform_android 에 있다. 다른 플랫폼을 추가할 때는
|
||||||
|
/// 같은 약속을 지키는 새 모듈만 만들면 다른 모듈은 바뀌지 않는다.
|
||||||
|
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/call_log_provider.dart';
|
||||||
|
export 'src/device_storage_probe.dart';
|
||||||
|
export 'src/lan_discovery.dart';
|
||||||
|
export 'src/location_provider.dart';
|
||||||
|
export 'src/motion_sensor.dart';
|
||||||
|
export 'src/notifier.dart';
|
||||||
|
export 'src/secure_key_store.dart';
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
/// 플랫폼: 화면 부품이 필요한 약속 (Flutter 가 필요한 부분만 따로 둔다).
|
||||||
|
///
|
||||||
|
/// 화면(ui) 모듈에서만 import 한다.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/qr_scanner_view.dart';
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
enum CallKind { incoming, outgoing, missed }
|
||||||
|
|
||||||
|
/// 기기 통화 기록 한 줄.
|
||||||
|
class CallLogEntry {
|
||||||
|
const CallLogEntry(this.number, this.at, this.seconds, this.kind);
|
||||||
|
final String number;
|
||||||
|
final DateTime at;
|
||||||
|
final int seconds;
|
||||||
|
final CallKind kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 기기의 통화 기록. 권한이 없으면 빈 목록.
|
||||||
|
abstract class CallLogProvider {
|
||||||
|
/// [number] 와 같은 번호(끝 8자리 기준)의 기록, 최근 것부터.
|
||||||
|
Future<List<CallLogEntry>> forNumber(String number, {int limit = 200});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 전화 걸기 · 문자 보내기 화면 띄우기.
|
||||||
|
abstract class PhoneActions {
|
||||||
|
Future<bool> dial(String number);
|
||||||
|
Future<bool> sms(String number);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
/// 기기의 저장 공간 사용률(%)을 알려 주는 도구. 구현은 플랫폼 쪽에서 붙인다.
|
||||||
|
abstract class DeviceStorageProbe {
|
||||||
|
Future<int> usedPercent();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/// 같은 네트워크에서 발견한 상대.
|
||||||
|
class LanPeer {
|
||||||
|
const LanPeer({required this.phone, required this.host, required this.port});
|
||||||
|
|
||||||
|
final String phone; // 표준 형태 전화번호
|
||||||
|
final String host;
|
||||||
|
final int port;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is LanPeer && other.phone == phone && other.host == host && other.port == port;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(phone, host, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 같은 네트워크에서 서로 찾기. 구현은 플랫폼 쪽에서 붙인다.
|
||||||
|
abstract class LanDiscovery {
|
||||||
|
/// 내가 여기 있다고 알린다.
|
||||||
|
Future<void> advertise({required String phone, required int port});
|
||||||
|
|
||||||
|
/// 같은 네트워크에서 발견되는 상대들.
|
||||||
|
Stream<LanPeer> get peers;
|
||||||
|
|
||||||
|
Future<void> start();
|
||||||
|
Future<void> stop();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
/// 위도·경도 한 점 (도 단위).
|
||||||
|
class GeoPoint {
|
||||||
|
const GeoPoint(this.lat, this.lng);
|
||||||
|
|
||||||
|
final double lat;
|
||||||
|
final double lng;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => other is GeoPoint && other.lat == lat && other.lng == lng;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(lat, lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 기기의 현재 위치. 플랫폼마다 구현이 다르다.
|
||||||
|
abstract class LocationProvider {
|
||||||
|
/// 현재 위치. 권한이 없거나 켜져 있지 않거나 얻지 못하면 null.
|
||||||
|
Future<GeoPoint?> current();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
/// 기기의 가속도 센서. 흔들기 감지에 쓴다 (폰 중심 기능).
|
||||||
|
abstract class MotionSensor {
|
||||||
|
/// 가속도 크기(중력 포함, m/s²)를 계속 내보낸다. 가만히 있으면 약 9.8.
|
||||||
|
Stream<double> get accelerationMagnitude;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/// 예약 알림 하나.
|
||||||
|
class ScheduledNotice {
|
||||||
|
const ScheduledNotice(this.key, this.fireAt, this.text);
|
||||||
|
final String key;
|
||||||
|
final DateTime fireAt;
|
||||||
|
final String text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 기기 알림. 예약은 통째로 갈아끼운다(이전 예약은 모두 취소).
|
||||||
|
abstract class Notifier {
|
||||||
|
Future<void> replaceAll(List<ScheduledNotice> notices);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// QR 코드를 읽는 화면 부품을 만들어 주는 약속 (카메라 사용, 플랫폼마다 다름).
|
||||||
|
abstract class QrScannerViewFactory {
|
||||||
|
/// 코드를 읽으면 [onCode] 로 글자를 넘기는 부품.
|
||||||
|
Widget build({required void Function(String code) onCode});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/// 기기 보안 저장소 (안드로이드 Keystore, iOS Keychain 등). 작은 비밀값을 이름으로 맡긴다.
|
||||||
|
///
|
||||||
|
/// 플랫폼마다 구현이 다르므로 다른 모듈은 이 약속(인터페이스)만 쓴다.
|
||||||
|
abstract class SecureKeyStore {
|
||||||
|
Future<String?> read(String name);
|
||||||
|
Future<void> write(String name, String value);
|
||||||
|
Future<void> delete(String name);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
/// 플랫폼(안드로이드): platform 의 약속을 안드로이드에서 구현한다.
|
||||||
|
///
|
||||||
|
/// 안드로이드 전용 플러그인(보안 저장소, 센서, 카메라, NSD, SQLCipher)은 이 모듈에서만 import 한다.
|
||||||
|
/// 앱 시작 코드(main.dart)가 이 구현을 만들어 다른 모듈에 넘겨 준다.
|
||||||
|
/// 다른 플랫폼을 붙일 때는 같은 약속을 지키는 platform_<이름> 모듈을 새로 만든다.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/android_call_log.dart';
|
||||||
|
export 'src/android_database.dart';
|
||||||
|
export 'src/android_lan_discovery.dart';
|
||||||
|
export 'src/android_location_provider.dart';
|
||||||
|
export 'src/android_motion_sensor.dart';
|
||||||
|
export 'src/android_notifier.dart';
|
||||||
|
export 'src/android_qr_scanner.dart';
|
||||||
|
export 'src/android_secure_key_store.dart';
|
||||||
|
export 'src/android_storage_probe.dart';
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import 'package:call_log/call_log.dart' as cl;
|
||||||
|
import 'package:friendly_map/modules/platform/platform.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
String _tail(String n) {
|
||||||
|
final d = n.replaceAll(RegExp(r'[^0-9]'), '');
|
||||||
|
return d.length <= 8 ? d : d.substring(d.length - 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 안드로이드 통화 기록 (READ_CALL_LOG 권한 필요. 거절되면 빈 목록).
|
||||||
|
class AndroidCallLog implements CallLogProvider {
|
||||||
|
@override
|
||||||
|
Future<List<CallLogEntry>> forNumber(String number, {int limit = 200}) async {
|
||||||
|
final want = _tail(number);
|
||||||
|
if (want.isEmpty) return const [];
|
||||||
|
try {
|
||||||
|
final all = await cl.CallLog.query(number: number);
|
||||||
|
final out = <CallLogEntry>[];
|
||||||
|
for (final e in all) {
|
||||||
|
if (_tail(e.number ?? '') != want) continue;
|
||||||
|
final kind = switch (e.callType) {
|
||||||
|
cl.CallType.incoming => CallKind.incoming,
|
||||||
|
cl.CallType.outgoing => CallKind.outgoing,
|
||||||
|
_ => CallKind.missed,
|
||||||
|
};
|
||||||
|
out.add(CallLogEntry(number, DateTime.fromMillisecondsSinceEpoch(e.timestamp ?? 0), e.duration ?? 0, kind));
|
||||||
|
if (out.length >= limit) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 전화 걸기·문자는 기본 전화/문자 앱 화면을 연다.
|
||||||
|
class AndroidPhoneActions implements PhoneActions {
|
||||||
|
@override
|
||||||
|
Future<bool> dial(String number) => _open(Uri(scheme: 'tel', path: number));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> sms(String number) => _open(Uri(scheme: 'sms', path: number));
|
||||||
|
|
||||||
|
Future<bool> _open(Uri u) async {
|
||||||
|
try {
|
||||||
|
return await launchUrl(u);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue