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
diadmin 2026-10-26 10:43:20 +09:00
commit 076412dcdb
257 changed files with 16941 additions and 0 deletions

4
.gitattributes vendored Normal file
View File

@ -0,0 +1,4 @@
* text=auto
*.sh text eol=lf
*.png binary
*.jar binary

52
.gitignore vendored Normal file
View File

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

30
.metadata Normal file
View File

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

102
README.md Normal file
View File

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

5
analysis_options.yaml Normal file
View File

@ -0,0 +1,5 @@
analyzer:
exclude:
- build/**
- android/**
include: package:flutter_lints/flutter.yaml

14
android/.gitignore vendored Normal file
View File

@ -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
android/.gitkeep Normal file
View File

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
package com.friendlymap.friendly_map
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

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

View File

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

View File

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

View File

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

View File

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

24
android/build.gradle.kts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

14
lib/main.dart Normal file
View File

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

26
lib/modules/app/app.dart Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'; // 가족: 어머니, 동생 …
}

View File

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

View File

@ -0,0 +1,5 @@
/// 내 공개 키를 내주는 약속. 상대는 이 값으로 나와 둘만의 암호 키를 만든다.
abstract class IdentityProvider {
/// 공개 키 (base64). 공개해도 안전하다.
Future<String> myPublicKey();
}

View File

@ -0,0 +1,6 @@
/// 암호 키를 내주는 약속. 키 모듈은 이 약속을 지키는 하나의 구현일 뿐이라,
/// 다른 구현으로 바꾸거나 빼도 이 약속을 쓰는 모듈은 바뀌지 않는다.
abstract class KeyProvider {
/// 암호화된 저장소를 여는 암호.
Future<String> databasePassword();
}

View File

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

View File

@ -0,0 +1,11 @@
/// 문자 요청 결과.
enum VerifyStart { sent, rateLimited, failed, unavailable }
/// 이 기기가 내 전화번호의 주인인지 문자로 확인하는 약속. 보관함 서버의 방식이든 다른 방식이든 이 약속만 지키면 된다.
abstract class PhoneVerifier {
/// 내 번호로 확인 번호 문자를 요청한다.
Future<VerifyStart> requestCode();
/// 받은 확인 번호를 넣는다. 맞으면 true.
Future<bool> confirm(String code);
}

View File

@ -0,0 +1,5 @@
/// 기준 시각을 알려 주는 약속. 기기 시계 오차를 바로잡는 데 쓴다.
abstract class TimeSource {
/// 기준 시각 (1970년부터 지난 1/100초). 알 수 없으면 null.
Future<int?> referenceStamp();
}

View File

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

View File

@ -0,0 +1,9 @@
/// 발견: 새 친구를 만나는 방법 (QR, 흔들기, 같은 네트워크 주소록)
///
/// 센서·네트워크는 platform 의 약속으로만 받는다. 이 모듈은 숫자·글자 계산만 해서 어디서나 시험된다.
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
library;
export 'src/lan_directory.dart';
export 'src/qr_payload.dart';
export 'src/shake_detector.dart';

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export 'src/calls.dart';
export 'src/event.dart';
export 'src/lunar.dart';
export 'src/reminders.dart';

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
/// 유추: "어디서 봤지?" — 내 명함과 상대 명함을 견주어 사이를 추측한다.
///
/// 저장소·화면을 모르는 순수 계산이라 따로 시험하고 그대로 어디서나 쓴다.
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
library;
export 'src/card_snapshot.dart';
export 'src/guess.dart';
export 'src/inferer.dart';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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; // 키가 다르거나 중간에 바뀜
}
}
}

View File

@ -0,0 +1,8 @@
/// 가족 관계: 가계도(부모-자식·배우자)로 두 사람의 촌수와 호칭을 계산한다.
///
/// 순수 계산만 한다(저장·화면 없음). 어떤 모듈도 쓰지 않는다.
/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다.
library;
export 'src/graph.dart';
export 'src/relation.dart' show Relation, relate;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
/// 플랫폼: 화면 부품이 필요한 약속 (Flutter 가 필요한 부분만 따로 둔다).
///
/// 화면(ui) 모듈에서만 import 한다.
library;
export 'src/qr_scanner_view.dart';

View File

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

View File

@ -0,0 +1,4 @@
/// 기기의 저장 공간 사용률(%)을 알려 주는 도구. 구현은 플랫폼 쪽에서 붙인다.
abstract class DeviceStorageProbe {
Future<int> usedPercent();
}

View File

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

View File

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

View File

@ -0,0 +1,5 @@
/// 기기의 가속도 센서. 흔들기 감지에 쓴다 (폰 중심 기능).
abstract class MotionSensor {
/// 가속도 크기(중력 포함, m/s²)를 계속 내보낸다. 가만히 있으면 약 9.8.
Stream<double> get accelerationMagnitude;
}

View File

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

View File

@ -0,0 +1,7 @@
import 'package:flutter/widgets.dart';
/// QR 코드를 읽는 화면 부품을 만들어 주는 약속 (카메라 사용, 플랫폼마다 다름).
abstract class QrScannerViewFactory {
/// 코드를 읽으면 [onCode] 로 글자를 넘기는 부품.
Widget build({required void Function(String code) onCode});
}

View File

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

View File

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

View File

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